Compare commits

...
34 Commits
Author SHA1 Message Date
okooo 81aeb37aee 终端粘贴截图:写成文件并把路径打进输入行
本地终端和已保存主机的远程终端里,剪贴板只有图、没有文字时,⌘V / Ctrl+Shift+V 或右键粘贴不再丢掉图片。图存成 PNG 等文件后,把路径当作文字粘进输入行,给终端里的 Agent 读。

本地写到系统临时目录 shellrs-pastes/(目录 0700、文件 0600),超过 20 个或超过 7 天的旧文件会清掉。已保存的主机另开一条 SFTP 把文件写到远端 /tmp/shellrs-paste-….png,连上后再粘路径;登录要问答就直接失败,不弹框。超过 8 MB 拒绝。外部连接和临时连接提示不支持。剪贴板里有文字时仍粘文字。
2026-10-13 02:26:58 +08:00
XuzandClaude Opus 5.5 fd4d37946b 已连接的标签不再显示状态点,只在连接中和断开时显示
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:46:08 +08:00
Nex-Z 743bd9d5e5 标签显示连接状态,远程终端断线后可按 Enter 重连 2026-10-13 00:40:05 +08:00
XuzandClaude Opus 5.5 8642ab5611 主机名称不再标为必填,CLI 帮助说明默认用地址
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:08:16 +08:00
Nex-Z 6a94958cd9 修正 UI 测试读取异步结果的时序 2026-10-13 00:01:21 +08:00
Nex-Z 31a509167c UI 测试使用跨平台语义快捷键 2026-10-13 00:01:21 +08:00
Nex-Z 78fd216fb6 主机名称留空时默认使用连接地址 2026-10-13 00:01:21 +08:00
Xuz 0dfa3fdf77 发布 0.1.10-beta.2 并补充 WinSCP 兼容更新日志 2026-10-12 22:22:53 +08:00
Xuz ff1efaf151 Windows 版本资源同时兼容 Xshell 和 WinSCP 堡垒机校验 2026-10-12 22:22:28 +08:00
Xuz bd7b8c1e3a 发布 0.1.10-beta.1 并补充更新日志 2026-10-12 20:38:17 +08:00
Xuz 959e0b1d9d Windows 版本资源增加 Xshell 标识以兼容堡垒机启动器 2026-10-12 20:35:39 +08:00
XuzandClaude Opus 5.5 e3397dabc7 主机密钥变更时可以直接「更新并连接」,不用手动改 known_hosts
服务器重装或换了密钥后,连接只弹出「服务器主机密钥已变更」并要求手动处理 known_hosts。现在
这个弹框和首次连接一样等用户回答:「更新并连接」删掉这台主机(host:port)记下的全部密钥、
记下现在的再继续登录(同 ssh-keygen -R),「取消」不连接。跳板主机同样。测试连接、外部 CLI
和端口转发的自动重连没人能回答,当场拒绝并照旧说明原因。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:49:58 +08:00
XuzandClaude Opus 5.5 3b06f6f516 版本号改为 0.1.9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:44:55 +08:00
XuzandClaude Opus 5.5 1f542b692d Windows 安装程序覆盖安装时也按系统语言显示,不沿用上次安装的英文
Inno Setup 的 UsePreviousLanguage 默认打开,覆盖安装时沿用上一次安装的语言。0.1.9-beta.1
之前的安装程序只有英文,于是装过旧版的电脑上新安装程序仍是英文。现在关掉它,每次都按
Windows 的显示语言选择。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:44:44 +08:00
XuzandClaude Opus 5.5 69e641c2bd Windows 本地终端的标签只显示程序名,不显示完整路径
Windows 控制台把正在运行的程序的完整路径设成标题,标签显示成
「C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe」「C:\Windows\system32\cmd.exe」。
现在标题里的程序路径显示为程序名(powershell、cmd、cmd - ping localhost),其余标题不变。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:41:27 +08:00
XuzandClaude Opus 5.5 f69518950e 版本号改为 0.1.9-beta.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:56:01 +08:00
XuzandClaude Opus 5.5 cf80884221 Windows 安装程序按系统语言显示简体中文或英文
脚本原来只在编译机自带 ChineseSimplified.isl 时才加中文,而 Inno Setup 安装时不带这份非官方
翻译,CI 打出来的安装程序一直是英文。现在把 issrc is-6_7_1(windows-2025 上的版本)的
ChineseSimplified.isl 放进 packaging/windows/Languages,英文和简体中文都打进去,按 Windows
的显示语言自动选择、不弹选择框;其他语言用英文。Inno Setup 低于 6.5 时编译报错。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:55:42 +08:00
Xuz da00974786 调整应用图标外部留白以匹配 macOS 图标尺寸 2026-10-11 20:13:31 +08:00
XuzandClaude Opus 5.5 4acba3a0f8 自定义快捷键可以用 Shift 加 Insert、Delete 等键,如 Shift+Insert 粘贴
快捷键原来只认带 Ctrl / Alt(macOS 是 ⌘⌥⌃)的组合和功能键,Shift+Insert 这种 Windows、
Linux 上常用的粘贴键被当成打字的键拒绝。现在 Shift 加 Insert、Delete、Home、End、
PageUp、PageDown 也可以设;单按这些键和 Shift 加方向键仍然拒绝,和输入框的键冲突时照样
拒绝。功能键只收 F1–F12,与提示文字一致。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:54:24 +08:00
XuzandClaude Opus 5.5 cd489a5fc2 设置 › 终端新增「本地终端」:指定 Shell;Windows 默认改用 PowerShell
本地终端运行的程序可以写路径或命令,带参数;输入框右端的下拉列出装了的常用 shell
(zsh、bash、sh、fish、nu、pwsh,从 /etc/shells、PATH 和 Homebrew 目录找),旁边
「选择文件…」用系统的文件选择框。留空是自动检测,新开和重新启动的本地终端生效。
只写一个 shell 时和默认一样按登录 shell 启动,带参数或不是 shell 的照写执行。

Windows 的默认 shell 原来是 cmd.exe,现在先用 PowerShell 7,其次 Windows PowerShell,
都没有才用 cmd.exe。

工作区改为每次打开、重启本地终端时向 LocalTerminalTransportProvider 要 factory,
UI 测试注入的 FixedLocalTerminalTransportProvider 记下每次要的 shell。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:44:25 +08:00
XuzandClaude Opus 5.5 9799f88ade 版本号改为 0.1.8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:22:26 +08:00
XuzandClaude Opus 5.5 dc5adddd06 启动后不久检查更新时,下好的新版本在重启时找不到
更新程序启动 30 秒后清理旧的暂存,原来会删掉所有 .ShellRS.app.update-*(Linux 是
.<AppImage>.update-*),包括启动后手动检查、切换渠道刚下载暂存好的新版本,于是「重启并
安装」报找不到文件,重试才从下载目录重新暂存。现在清理只删不比当前版本新的暂存,和下载
目录的规则一致。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:21:47 +08:00
XuzandClaude Opus 5.5 db0a0faf05 发布流程:lipo -verify_arch 的参数顺序写反了,macOS 两个架构都在构建后失败
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:49:57 +08:00
XuzandClaude Opus 5.5 026e0d44d8 版本号改为 0.1.8-beta.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:34:46 +08:00
bigtan e91bab0d09 macOS 按 Apple Silicon 和 Intel 分别发布安装包
发布流程改为按 aarch64、x86_64 构建矩阵,分别签名、公证并上传 DMG 和升级 ZIP,
不再合成 universal 应用。两个任务使用独立缓存和产物名称,发布时收集全部架构。

更新清单保留原有平台键,分别记录对应架构包的地址、大小和 SHA-256。
已有 universal 客户端按运行架构升级;Rosetta 下的 Intel 版仍接收 Intel 包。
同步更新中英文下载说明、发布文档和示例清单,增加双架构校验与错包拒绝断言。

验证:52 项更新模块测试通过,Clippy、格式、YAML 和 shell 语法检查通过。
2026-10-10 22:33:22 +08:00
XuzandClaude Opus 5.5 5ca6e53366 设置 › 终端新增「终端交互」:选中复制、右键行为
「选中复制」打开后,鼠标选中的文字在松开时进入系统剪贴板,单击没选中东西时剪贴板不变;
默认关闭。「右键行为」可选无动作、粘贴、显示菜单,默认显示菜单;右键从不交给要求鼠标的
程序。设置存在 settings.json 的 terminal_interaction,终端经全局量 TerminalInteraction
读取,改动立即作用于已打开的终端。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:24:04 +08:00
XuzandClaude Opus 5.5 0aa1d4452d ESXi 等只在键盘交互式认证里收密码的主机,用保存的密码自动登录
ESXi 的 sshd 关了 password 方式,只开 publickey 和 keyboard-interactive。保存的密码只走
password 方式,于是测试连接报「服务器要求键盘交互式认证」,外部 CLI 和端口转发连不上,
在应用里每次连接都要手输。

现在「密码」登录遇到键盘交互式认证的第一个单独的隐藏问题,就用保存的密码(测试连接时是
对话框里填的)回答,同 WinSCP 默认的「用密码响应第一个提示」。一次登录里保存的密码只发
一次;密码之后的验证码等问题照样问人。被拒或没有密码时,不能询问的连接报缺的是密码,应用
里的询问写明保存的密码被拒。没有问题的一轮直接回空,不再弹框。外部 CLI 遇到密码以外的问题
不再让人「保存密码」。

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

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

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

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

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

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

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

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

No files matched your search

+17 -13
View File
@@ -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
+3
View File
@@ -28,7 +28,9 @@ on:
- "src/sftp/sync.rs"
- "src/credential/credential_dialog.rs"
- "src/settings/settings_panel.rs"
- "src/terminal/local_shell.rs"
- "src/app/shortcuts.rs"
- "src/terminal/paste_image.rs"
- "src/workspace/window_state.rs"
- "src/testing.rs"
- "tests/cli_bin.rs"
@@ -57,4 +59,5 @@ jobs:
- run: "cargo test --lib cli::"
- run: "cargo test --lib update::"
- run: "cargo test --lib sftp::tests::local_listing"
- run: "cargo test --lib terminal::local_shell"
- run: cargo test --test cli_bin
+110
View File
@@ -2,6 +2,116 @@
每个版本一节,标题写成 `## [版本号]`,可以在后面加日期。发布流程(`.github/workflows/release.yml`)把与 tag 同版本的那一节原样作为更新说明:显示在 GitHub Release 和官网的更新日志里(ShellRS 的更新对话框用「查看更新内容」打开官网的更新日志),用 Markdown 书写,面向使用者。
## [0.1.10-beta.2]
### 改进
- 服务器主机密钥变更时,可在核验指纹后选择「更新并连接」,无需手动修改 known_hosts。
### 修复
- 修复 Windows 下部分堡垒机配置 ShellRS 为 Xshell 或 WinSCP 客户端时,提示「请使用官方客户端」的问题。
## [0.1.10-beta.1]
### 改进
- 服务器主机密钥变更时,可在核验指纹后选择「更新并连接」,无需手动修改 known_hosts。
### 修复
- 修复 Windows 下部分堡垒机配置 ShellRS 为 Xshell 客户端时,提示「请使用官方客户端」的问题。
## [0.1.9]
### 新增
- 设置 › 终端新增「本地终端」:可指定本地终端运行的 shell 和参数,下拉列出本机装的常用 shell。
- 自定义快捷键可以用 Shift 加 Insert、Delete 等键,如 Shift+Insert 粘贴。
### 改进
- Windows 本地终端默认使用 PowerShell,标签只显示程序名。
- Windows 安装程序在中文系统上显示中文。
- 应用图标大小与 macOS 上的其他应用一致。
## [0.1.9-beta.1]
### 新增
- 设置 › 终端新增「本地终端」:可指定本地终端运行的 shell 和参数,下拉列出本机装的常用 shell。
- 自定义快捷键可以用 Shift 加 Insert、Delete 等键,如 Shift+Insert 粘贴。
### 改进
- Windows 本地终端默认使用 PowerShell。
- Windows 安装程序在中文系统上显示中文。
- 应用图标大小与 macOS 上的其他应用一致。
## [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]
### 新增
+11 -3
View File
@@ -42,6 +42,9 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH
- 删除不确认:`hosts delete` 遇到有打开标签的主机报 `host_in_use`,`--force` 才关标签;删凭据时用它的主机改回自己登录。临时连接和外部连接只能 `show`。
- `sync` 只从本机到主机,大小和修改时间都没变的跳过;`--delete` 不删 `.filepart` 和 `.shellrs-….backup`,本地读不了的目录不清理,拒绝同步到 `/`。
- `exec --terminal`(`exec --json` 里 `"terminal": true`):给只允许登录一次的堡垒机(外部连接上 exec 重新登录会被拒,另开通道又会断会话)。把命令打进这台主机已打开的远程终端(右侧栏跟的那个,否则最新已连接的),所有远程终端都能用,不限外部连接。先发 ^C,**不做忙碌检测**(用户定的:用的人有基础,交给 Agent 的标签由用户负责;别加回光标行、密码提示之类的检查),只拒绝备用屏和同一终端已有一次运行。只支持 Linux 上的 bash、dash、busybox ash。输出经管道(没有颜色和分页器)、stderr 并入;`--json` 多一项 `context`(user、host、cwd、shell、interp)。
- **本地终端**:「设置 › 终端 › 本地终端 › Shell」是一个输入框(路径或命令加参数),右端下拉只列常用 shell 里装了的(Unix 是 `COMMON_SHELLS`:zsh、bash、sh、fish、nu、pwsh,从 `/etc/shells`、PATH 和 Homebrew 目录找;csh、ksh、dash 等不列,用户嫌长;Windows 是 pwsh、powershell、cmd),旁边「选择文件…」;这一行上下排(标题和说明在上,输入框占满整行)。新开和重启的本地终端生效,正在运行的不动。
- 留空是自动检测:Unix 是 `$SHELL` 或 passwd 里的登录 shell;Windows 优先 PowerShell 7,其次 Windows PowerShell,都没有才 cmd(用户定的:现代 Windows 应先 PowerShell)。
- 只写一个已知的 shell(`SHELL_NAMES`)时和默认一样按登录 shell 启动(`SHELL` 设成它、argv0 前加 `-`),否则 Homebrew 写在 `~/.zprofile` 的 PATH 就没了;带参数或不是 shell(如 `tmux`,它会在每个窗口起 `$SHELL`)的照写执行。
- **开始页**:没有标签时显示「最近连接」。「快速连接」搜索保存的主机、回车连接;多选做好但关着(常量 `MULTIPLE`)。
- **右侧栏**:窗口最右一列竖排的工具切换。
- 显示规则:只跟着 SSH 远程终端出现,SFTP、本地终端、设置、开始页都不显示。它是工作区级的一份,开合和所选工具在所有终端间共用。默认收起,宽 320 px,只能往宽里拉。⌘⌥B / Ctrl+Alt+B 切换。
@@ -70,6 +73,8 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH
- 只认 UTF-8(不做 GBK),上限 5 MB,约 15 种语言语法高亮。
- 远程文件原地改写:截断后写原文件,不走 `.filepart` 加改名。保存前核对,被改过就问「覆盖」。
- **设置 › 应用 › 窗口**:「记住窗口大小」(含最大化)和「记住窗口位置」两个开关,默认都开,下次启动生效。屏幕不在了或窗口不在屏幕上就居中,比屏幕大就缩小;全屏不记。
- **终端交互**:「选中复制」默认关;「右键行为」无动作 / 粘贴 / 显示菜单,默认显示菜单。本地终端和已保存主机的远程终端里,剪贴板是图(没有文字)时粘贴会把图存成文件并把路径打进输入行,给终端里的 Agent 读;外部连接和临时连接不做。
- **标签连接状态**:远程终端和 SFTP 标签按自己的连接(不是主机的汇总)在图标右下角显示状态点:连接中黄、断开红,已连接不显示(用户定的:有点就说明有情况;常亮的绿点太吵,三色点也只靠颜色区分)。远程终端断开后(失败、退出、断开、取消认证)按无修饰的 Enter 在原标签重连,本地终端不做。
- **终端通知**:只做程序请求的(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`)。两种外观下悬停和选中都要分得开。页面「重置」对卡片和开关各管各的。
@@ -78,7 +83,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH
- **键盘快捷键**:设置左栏单独一个分类,排在「关键字高亮」下面,分通用、标签、终端、编辑器四组。
- ⌘= / ⌘- / ⌘0 在终端(和它的查找条)里调终端字号(每次 1 像素,经设置写进「终端 › 字号」,范围同那里的 10–28,`FONT_SIZE_RANGE`),在终端外缩放界面(基准字号 12–20,步长 2,不保存)。同主流终端,别改回去。
- 只收主要功能(含新加的下一个 / 上一个 / 切换到标签 1…9),不收 SFTP 文件列表、图片预览的键和 Enter、方向键、Tab、Esc 这类固定键。
- 每行:按键(点了录制「按下组合键」,Esc 或失焦放弃)、禁用、恢复默认。键要带 ⌘ / Ctrl / Alt 或是 F1–F24;撞上同一处的另一个快捷键或输入框等内置键就拒绝并提示占用者,不抢、不允许重复。
- 每行:按键(点了录制「按下组合键」,Esc 或失焦放弃)、禁用、恢复默认。键要带 ⌘ / Ctrl / Alt,或是 F1–F12(F12 以上不收,用户定的),或是 Shift 加 Insert、Delete、Home、End、PageUp、PageDown(Shift+Insert 粘贴;提示文字不提这条,只写 F1–F12);撞上同一处的另一个快捷键或输入框等内置键就拒绝并提示占用者,不抢、不允许重复。
- 存在 `settings.json` 的 `shortcuts`,只存和默认不同的(`null` 是禁用)。`Shortcut` 的 `id` 一经发布不能改。
- **关键字高亮**:设置左栏单独一个分类,排在「终端」下面,三组:常规(总开关)、预览(当前终端主题下的示例输出)、规则(N)。规则存在 `settings.json`,所有终端共用。
- 规则表在行里直接改,不弹对话框:拖动把手(一直显示,只有把手能拖)、启用、正则表达式、备注、颜色(gpui-kit `ColorPicker` 加 `#rrggbb` 输入框)、通知、删除(不确认)。规则都是正则,区分大小写;颜色是用户数据,任意 RGB,不跟主题;不做加粗。重叠时靠前的优先。
@@ -129,7 +134,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- `SshConnector`:所有登录都走它(终端、SFTP、exec、测试连接、端口转发、外部 CLI)。
- 远程终端的传输:pty、系统探测、延迟、旁路 exec。
- 代理握手;外部 CLI 用的 `run_command`。
- `terminal/`:本地 PTY、驱动 `alacritty_terminal` 的引擎、`TerminalView`(网格、选区、查找、清屏)、关键字高亮(`highlight.rs`)、终端字体全局量、右侧栏工具共用的 `ExecTarget`。
- `terminal/`:本地 PTY 和它跑的 shell(`local_shell.rs`:命令行拆分、已安装的 shell、Windows 的默认 shell)、驱动 `alacritty_terminal` 的引擎、`TerminalView`(网格、选区、查找、清屏)、关键字高亮(`highlight.rs`)、终端字体全局量、右侧栏工具共用的 `ExecTarget`。
- `sftp/`:可注入的传输和本地目录接口、`RemotePath`、`RemoteFs` 协议适配、worker(批次、三次重连、空闲断网检测),以及:
- `upload.rs` / `download.rs`:断点续传,续传记录由 `journal.rs` 管;
- `operations.rs`:删除、重命名、新建、改权限;
@@ -264,6 +269,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- 每次先发 `none`,再按 `LoginMethod` 走。
- 密码和口令先查钥匙串,查不到或被拒才弹框;被拒时不删钥匙串条目。
- 失败的说明一律经 `describe_login_error`,它沿错误链找真正的原因。
- 主机密钥和记下的不一致时也问(`HostKeyChanged`):「更新并连接」删掉这台主机(`host:port`)记下的全部密钥再记新的(同 `ssh-keygen -R`),「取消」不连。没人能答的(测试连接、exec、CLI 传输、转发重连)的回调返回 `false`,当场拒绝,不能返回 `true` 却不回答,否则登录一直等。
- **算法按 OpenSSH 的默认来**:加了 `ecdh-sha2-nistp*`,素数组交换最小 2048 位,因为用户的堡垒机是老 Java 上的 Apache SSHD。没有加 SHA-1、CBC 这些 OpenSSH 默认也不开的算法。
- **区分断网和要人处理**:`is_network_error`,它要显式认 `russh::Error::IO`。只有网络类错误才自动重连。agent 的错误只格式化进文字,不挂进错误链。
- **跳板。** 每台跳板开好通道就丢掉自己的 `Handle`,整条链靠下一跳攥着的通道流活着:最终连接一断,链依次关闭。问答写明是哪台跳板;跳板和代理的失败包一层 `RouteFailure`。
@@ -280,6 +286,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`。「名称」单元格才是项目,拖文件只从图标和文件名开始,其余地方按下拖动是框选。
@@ -351,11 +358,12 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- **查找条必须是终端 `div` 的兄弟**,不能是子元素,否则输入会被发给 shell;它还要 `.occlude()`。
- **清屏只在本地模拟器里做**,全屏程序运行时不做。终端里的 ⌘K 是清屏,比全局的 `FocusSearch` 深一层,所以优先。
- **本地终端每次打开、重启都向 `LocalTerminalTransportProvider` 要新的 factory**(同远程的 `factory_for`),带上当时设置里的 `LocalShell`;UI 测试注入 `FixedLocalTerminalTransportProvider`,它记下每次要的 shell。命令行在传输线程上解析(要查文件系统),拆不开(引号没配对)或起不来都报在终端里,报错里写着用户写的命令。
- **终端字体走全局量 `TerminalFont`**(终端模块不依赖 settings),格子在下一帧 prepaint 时按新尺寸重排。
- **终端颜色走全局量 `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
View File
@@ -7738,7 +7738,7 @@ dependencies = [
[[package]]
name = "shellrs"
version = "0.1.6"
version = "0.1.10-beta.2"
dependencies = [
"alacritty_terminal",
"anyhow",
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "shellrs"
version = "0.1.6"
version = "0.1.10-beta.2"
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",
] }
+3 -1
View File
@@ -13,6 +13,7 @@
<a href="#license"><img src="https://img.shields.io/badge/license-GPL--3.0-blue" alt="License: GPL-3.0"></a>
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey" alt="Platform: macOS | Windows | Linux">
<img src="https://img.shields.io/badge/rust-1.98-orange" alt="Rust 1.98">
<a href="https://linux.do"><img src="https://img.shields.io/badge/LINUX-DO-FFB003.svg?logo=data:image/svg%2bxml;base64,DQo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjEwMCIgaGVpZ2h0PSIxMDAiPjxwYXRoIGQ9Ik00Ni44Mi0uMDU1aDYuMjVxMjMuOTY5IDIuMDYyIDM4IDIxLjQyNmM1LjI1OCA3LjY3NiA4LjIxNSAxNi4xNTYgOC44NzUgMjUuNDV2Ni4yNXEtMi4wNjQgMjMuOTY4LTIxLjQzIDM4LTExLjUxMiA3Ljg4NS0yNS40NDUgOC44NzRoLTYuMjVxLTIzLjk3LTIuMDY0LTM4LjAwNC0yMS40M1EuOTcxIDY3LjA1Ni0uMDU0IDUzLjE4di02LjQ3M0MxLjM2MiAzMC43ODEgOC41MDMgMTguMTQ4IDIxLjM3IDguODE3IDI5LjA0NyAzLjU2MiAzNy41MjcuNjA0IDQ2LjgyMS0uMDU2IiBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDojZWNlY2VjO2ZpbGwtb3BhY2l0eToxIi8+PHBhdGggZD0iTTQ3LjI2NiAyLjk1N3EyMi41My0uNjUgMzcuNzc3IDE1LjczOGE0OS43IDQ5LjcgMCAwIDEgNi44NjcgMTAuMTU3cS00MS45NjQuMjIyLTgzLjkzIDAgOS43NS0xOC42MTYgMzAuMDI0LTI0LjM4N2E2MSA2MSAwIDAgMSA5LjI2Mi0xLjUwOCIgc3R5bGU9InN0cm9rZTpub25lO2ZpbGwtcnVsZTpldmVub2RkO2ZpbGw6IzE5MTkxOTtmaWxsLW9wYWNpdHk6MSIvPjxwYXRoIGQ9Ik03Ljk4IDcwLjkyNmMyNy45NzctLjAzNSA1NS45NTQgMCA4My45My4xMTNRODMuNDI2IDg3LjQ3MyA2Ni4xMyA5NC4wODZxLTE4LjgxIDYuNTQ0LTM2LjgzMi0xLjg5OC0xNC4yMDMtNy4wOS0yMS4zMTctMjEuMjYyIiBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDojZjlhZjAwO2ZpbGwtb3BhY2l0eToxIi8+PC9zdmc+" alt="LINUX DO"></a>
</p>
<p align="center"><b>English</b> | <a href="README.zh-CN.md">简体中文</a></p>
@@ -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
View File
@@ -13,6 +13,7 @@
<a href="#许可证"><img src="https://img.shields.io/badge/license-GPL--3.0-blue" alt="License: GPL-3.0"></a>
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey" alt="Platform: macOS | Windows | Linux">
<img src="https://img.shields.io/badge/rust-1.98-orange" alt="Rust 1.98">
<a href="https://linux.do"><img src="https://img.shields.io/badge/LINUX-DO-FFB003.svg?logo=data:image/svg%2bxml;base64,DQo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjEwMCIgaGVpZ2h0PSIxMDAiPjxwYXRoIGQ9Ik00Ni44Mi0uMDU1aDYuMjVxMjMuOTY5IDIuMDYyIDM4IDIxLjQyNmM1LjI1OCA3LjY3NiA4LjIxNSAxNi4xNTYgOC44NzUgMjUuNDV2Ni4yNXEtMi4wNjQgMjMuOTY4LTIxLjQzIDM4LTExLjUxMiA3Ljg4NS0yNS40NDUgOC44NzRoLTYuMjVxLTIzLjk3LTIuMDY0LTM4LjAwNC0yMS40M1EuOTcxIDY3LjA1Ni0uMDU0IDUzLjE4di02LjQ3M0MxLjM2MiAzMC43ODEgOC41MDMgMTguMTQ4IDIxLjM3IDguODE3IDI5LjA0NyAzLjU2MiAzNy41MjcuNjA0IDQ2LjgyMS0uMDU2IiBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDojZWNlY2VjO2ZpbGwtb3BhY2l0eToxIi8+PHBhdGggZD0iTTQ3LjI2NiAyLjk1N3EyMi41My0uNjUgMzcuNzc3IDE1LjczOGE0OS43IDQ5LjcgMCAwIDEgNi44NjcgMTAuMTU3cS00MS45NjQuMjIyLTgzLjkzIDAgOS43NS0xOC42MTYgMzAuMDI0LTI0LjM4N2E2MSA2MSAwIDAgMSA5LjI2Mi0xLjUwOCIgc3R5bGU9InN0cm9rZTpub25lO2ZpbGwtcnVsZTpldmVub2RkO2ZpbGw6IzE5MTkxOTtmaWxsLW9wYWNpdHk6MSIvPjxwYXRoIGQ9Ik03Ljk4IDcwLjkyNmMyNy45NzctLjAzNSA1NS45NTQgMCA4My45My4xMTNRODMuNDI2IDg3LjQ3MyA2Ni4xMyA5NC4wODZxLTE4LjgxIDYuNTQ0LTM2LjgzMi0xLjg5OC0xNC4yMDMtNy4wOS0yMS4zMTctMjEuMjYyIiBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDojZjlhZjAwO2ZpbGwtb3BhY2l0eToxIi8+PC9zdmc+" alt="LINUX DO"></a>
</p>
<p align="center"><a href="README.md">English</a> | <b>简体中文</b></p>
@@ -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 及更新的版本) |
+5 -4
View File
@@ -13,11 +13,12 @@ ShellRS 最终版设计说明
最终规格:
- 画布:1024 × 1024。
- 底板:x=64、y=64、宽高=896、圆角半径=176。
- 底板:x=100、y=100、宽高=824、圆角半径约 161.86;四周透明留白各 100。
- 底板和内容整体以画布中心缩放至原尺寸的 824/896(约 91.96%),与本机 VS Code、ChatGPT 的 macOS 图标外轮廓尺寸一致。
- 底板色:#082F63;主体与字母色:#FFF9EB;光标色:#00D9C5。
- 内容在原居中版本基础上整体等比缩小至 90%,保持字形比例和底部对齐。
- 内容顶部 y=256.4、底部 y=767.6;底板内上下留白各 192.4。
- 内容左边界约 x=168.4、右边界约 x=855.6;底板内左右留白各约 104.4。
- 内部内容保留此前 90% 的相对缩放,再随整个图标一起缩放,保持字形比例、居中和底部对齐。
- 内容顶部约 y=276.94、底部约 y=747.06;底板内上下留白各约 176.94。
- 内容左边界约 x=196.02、右边界约 x=827.98;底板内左右留白各约 96.02。
- SVG 字母已转为路径,无外部字体依赖。
- PNG:1024 × 1024,保留透明通道。
- ICNS:多尺寸,包含 1024 × 1024。
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 39 KiB

+3 -1
View File
@@ -1,6 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-labelledby="title desc">
<title id="title">ShellRS</title>
<desc id="desc">Approved centered artwork uniformly scaled to 90 percent, preserving glyphs and alignment; side padding approximately 104.4 and vertical padding 192.4.</desc>
<desc id="desc">Complete artwork fits an 824px tile centered within a 1024px canvas, with 100px transparent outer margins. Original glyph proportions, symmetry and alignment are preserved.</desc>
<g id="macos-footprint" transform="translate(512 512) scale(0.9196428571428571) translate(-512 -512)">
<rect x="64" y="64" width="896" height="896" rx="176" fill="#082F63"/>
<g id="padding-adjustment" transform="translate(512 512) scale(0.9) translate(-512 -512)">
<g id="centered-mark" transform="translate(2.235799 -48)">
@@ -9,4 +10,5 @@
<path id="letters" fill="#FFF9EB" fill-rule="evenodd" d="M840.5570 704.0286 L832.0306 704.7053 L823.5042 706.3731 L820.0937 707.3839 L815.8305 709.0366 L810.7146 711.5192 L806.4514 714.0701 L802.1883 717.5490 L799.4819 720.2757 L797.4334 722.8336 L795.7694 725.3915 L793.6099 729.6547 L792.2704 733.0653 L791.0868 738.1811 L790.5990 742.4443 L790.5413 747.5601 L790.7635 750.1180 L791.7002 755.2339 L792.4558 757.7918 L794.3592 762.0550 L796.9964 766.3182 L799.6303 769.4577 L802.1883 771.8218 L805.5988 774.5461 L808.5691 776.5498 L815.8305 780.2746 L822.6516 783.0124 L843.9675 790.3191 L849.0833 792.5025 L852.4939 794.4116 L854.1992 795.6653 L856.1929 797.8658 L856.9959 799.5710 L857.3406 802.9816 L857.0343 805.5395 L856.3500 807.2448 L855.2772 808.9501 L854.1992 810.1125 L852.4939 811.5435 L849.0833 813.2610 L845.6728 814.2227 L839.7043 814.9011 L836.2938 814.8548 L832.0306 814.4166 L826.9148 813.3169 L822.6516 811.7738 L818.3884 809.8504 L814.1385 807.2448 L808.1567 802.7252 L805.5988 801.8951 L803.8935 801.9264 L802.1883 802.3942 L800.4830 803.3971 L799.1660 804.6869 L788.3227 819.1817 L787.7227 820.8870 L787.6062 822.5923 L788.2000 825.1502 L789.3987 826.8035 L796.2198 832.2166 L800.4830 834.7954 L807.3041 838.1734 L814.1252 840.6524 L820.9463 842.3956 L826.0621 843.2675 L831.1780 843.6589 L835.4411 844.0000 L839.7043 843.9669 L848.2307 843.3549 L853.3465 842.5103 L860.1676 840.7675 L865.2835 838.9890 L872.1046 835.5885 L874.6625 833.8692 L879.7783 829.6732 L882.3866 826.8555 L885.0715 823.4449 L887.5226 819.1817 L889.3776 814.9185 L890.4223 811.5080 L891.3656 806.3921 L891.5284 799.5710 L891.2722 794.4552 L890.3791 790.1920 L889.5345 787.6341 L887.6063 783.3709 L886.0805 780.8130 L883.4668 777.4025 L879.7783 773.7502 L875.5151 770.3861 L867.8414 766.1816 L856.7571 761.8914 L839.7043 756.5976 L832.8832 753.9576 L831.1780 753.0884 L828.6200 751.4797 L826.3680 749.2654 L825.7118 748.4128 L824.9427 746.7075 L824.7073 745.0022 L824.7202 742.4443 L825.1086 740.7390 L825.8338 739.0337 L828.0465 736.4758 L829.4727 735.3398 L832.8832 733.7190 L836.2938 732.8784 L839.7043 732.5214 L843.1149 732.4138 L847.3781 732.6438 L849.9360 732.9085 L854.1992 733.7941 L859.3150 735.4061 L863.3650 737.3285 L870.3993 741.2306 L872.9572 741.5687 L875.5151 740.9282 L876.3678 740.4199 L877.7431 739.0337 L887.7516 724.5389 L888.5664 722.8336 L888.8099 721.1283 L888.7196 719.4231 L887.9358 717.7178 L886.5994 716.2542 L884.8941 714.9159 L878.9257 711.5689 L872.9572 709.0028 L865.2835 706.5861 L861.0203 705.5923 L855.0518 704.6492 L846.5254 704.0000 Z M671.7346 707.3358 L670.2331 708.3388 L669.1767 709.4809 L668.1227 711.7493 L668.0006 712.6020 L668.0000 833.6766 L668.2078 835.3818 L668.9052 837.0871 L670.8820 838.9687 L673.4399 839.9149 L694.7559 839.9270 L696.1266 839.6450 L698.1664 838.4701 L699.4174 837.0871 L700.2151 835.3818 L700.3392 834.5292 L700.4254 797.0131 L700.7243 796.6365 L701.5770 796.5234 L713.5139 796.4421 L714.3665 796.6669 L714.6869 797.0131 L739.8139 836.2345 L740.7983 837.4204 L742.6358 838.7924 L745.0615 839.8042 L756.9984 839.9557 L772.3459 839.9086 L774.0512 839.0725 L775.3151 837.9398 L776.3634 836.2345 L776.8081 834.5292 L776.6441 832.8239 L775.8796 831.1187 L749.4882 791.8973 L749.1454 791.0447 L754.3676 788.4868 L758.6726 785.9288 L762.9669 782.6042 L764.8512 780.8130 L768.4362 776.5498 L770.1243 773.9919 L772.4055 769.7287 L774.0738 765.4655 L775.1644 761.2023 L776.0290 755.2339 L776.0817 749.2654 L775.8429 745.0022 L775.0365 739.8864 L774.0512 735.9451 L772.3748 731.3600 L770.0694 727.0968 L767.6365 723.6863 L764.6722 720.4655 L760.4090 716.6653 L757.8511 714.8908 L753.5879 712.4428 L749.3247 710.6256 L740.7983 708.1383 L734.8299 707.2194 L728.0087 706.9237 L674.2926 706.9272 Z M701.5770 734.7052 L700.7243 734.8952 L700.3621 735.6232 L700.3383 768.0234 L700.7243 769.1438 L702.4296 769.3109 L726.3035 769.2572 L730.5667 768.4278 L733.1246 767.5406 L735.6825 766.1302 L737.5993 764.6129 L739.1455 762.9076 L740.2873 761.2023 L741.8388 757.7918 L742.6996 752.6760 L742.7503 750.9707 L742.0800 745.8548 L741.1045 743.2969 L739.4596 740.7390 L737.3878 738.7072 L735.4242 737.3285 L733.1246 736.2431 L730.5667 735.4203 L727.1561 734.8472 Z"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

+3 -1
View File
@@ -14,7 +14,9 @@ fn main() {
resource
.set_icon("assets/logo/shellrs.ico")
.set("ProductName", "ShellRS")
.set("FileDescription", "ShellRS");
.set("FileDescription", "ShellRS")
// Bastion launchers identify Xshell and WinSCP through this resource.
.set("OriginalFilename", "ShellRS-Xshell-WinSCP.exe");
if let Err(error) = resource.compile() {
panic!("cannot embed the icon: {error}");
}
+10 -6
View File
@@ -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,16 +192,19 @@ 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.
Remote terminal and SFTP tabs show their own connection state: a yellow dot at the lower right of the icon while connecting, a red one when disconnected, and none when connected; hover over the dot to see the state in words. After a remote connection fails, exits, is disconnected or has authentication cancelled, press an unmodified Enter with the terminal focused to reconnect that tab using the current host settings. Held keys, pasted input, search fields and authentication fields do not trigger reconnection. Local terminals retain their restart action.
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.
- **Paste image**: in a local terminal or a remote terminal on a saved host, when the clipboard holds a screenshot (and no text), ⌘V (Ctrl+Shift+V on Windows and Linux) or Paste on the context menu writes the image to a file and pastes that path, for an agent in the terminal to read. Locally it goes in the system temp folder; on a saved host it is written over SFTP to `/tmp` on the host. Text on the clipboard is still pasted as text. External connections and quick connections don’t do this.
## Right sidebar
@@ -251,7 +255,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 +285,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 +389,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.
+14 -8
View File
@@ -12,11 +12,11 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
## 主机列表
左侧栏里保存的每一项是一台主机:地址、端口和认证方式。同一台机器用不同用户登录,就保存成两台主机。
左侧栏里保存的每一项是一台主机:地址、端口和认证方式。同一台机器用不同用户登录,就保存成两台主机。名称可以留空,保存时默认使用地址;也可以填写自定义名称。
主机对话框的「认证方式」有三种:
- **密码**:填用户名和密码,密码保存在系统钥匙串;密码留空则每次连接时询问。只用密码登录,不会先拿 SSH Agent 或私钥去试。
- **密码**:填用户名和密码,密码保存在系统钥匙串;密码留空则每次连接时询问。只用密码登录,不会先拿 SSH Agent 或私钥去试。服务器只在键盘交互式认证里收密码时(如 ESXi),保存的密码会自动回答它唯一的隐藏问题;还有验证码等别的问题时照样询问。
- **使用凭据**:选一条保存好的凭据,用户名和登录方式都来自凭据(见下面「凭据」),对话框里不再填用户名和密码。「测试连接」同样使用凭据保存的密码。要用私钥登录,就建一条密钥凭据。
- **无密码**:只填用户名。依次尝试服务器免认证、SSH Agent 和 `~/.ssh` 下的默认私钥(`id_ed25519`、`id_ecdsa`、`id_rsa`),和命令行 `ssh` 默认的做法一样。服务器要求密码时连接失败并说明原因,不会询问;服务器在密钥之后再要验证码时照样询问。
@@ -108,9 +108,11 @@ 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、重新连接在标签的右键菜单里。改动立即作用于所有已打开的终端。
- **终端 → 本地终端**:「Shell」是新开或重新启动的本地终端运行的程序,写路径或命令,可以带参数(如 `/bin/zsh -l`、`pwsh.exe -NoLogo`);路径里有空格就用引号括起来(Windows 只认双引号)。输入框右端的下拉列出本机装了的常用 shell(macOS / Linux 是 zsh、bash、sh、fish、nu、pwsh 里装了的,包括 Homebrew 装的;csh、ksh、dash 等不列,要用就「选择文件…」;Windows 是 PowerShell 7(装了的话)、Windows PowerShell 和 cmd.exe),旁边的「选择文件…」用系统的文件选择框挑一个程序。留空(默认)时自动检测系统默认的 shell:macOS / Linux 是用户的登录 shell,Windows 优先 PowerShell 7,其次系统自带的 Windows PowerShell,都没有才用 cmd.exe。只写一个 shell、不带参数时(如 `/bin/bash`、`zsh`),和默认一样按登录 shell 启动,会读 `~/.zprofile` 等登录配置;带了参数或不是 shell 的程序就照写的启动。已经在运行的本地终端不受影响,重新启动后生效。
- **关键字高亮**:总开关、预览和规则表,终端里按规则给文字上色、匹配时提醒,见「终端」一节。
- **键盘快捷键**:主要功能的快捷键,分通用、标签、终端、编辑器四组,每行是功能名和它的按键。点按键进入「按下组合键」,按下新的组合就换上;Esc 或点别处放弃。行尾两个按钮:禁用(之后显示「无」,再点一下恢复默认)、恢复默认;页面上方的「重置」也能逐项恢复。改动立即生效,工具栏提示和右键菜单里显示的按键跟着变。
- 组合键要带 ⌘、⌥ 或 ⌃(其他平台 Ctrl 或 Alt),或者是 F1–F12;「切换到标签 1…9」按任意一个带修饰键的数字即可,九个一起换。
- 组合键要带 ⌘、⌥ 或 ⌃(其他平台 Ctrl 或 Alt),或者是 F1–F12,也可以是 Shift 加 Insert、Delete、Home、End、PageUp、PageDown(如用 Shift+Insert 粘贴);「切换到标签 1…9」按任意一个带修饰键的数字即可,九个一起换。
- 已被同一处的另一个快捷键、或输入框等内置功能占用的组合会被拒绝,并提示被谁占用;想换就先改或禁用那一个。终端、编辑器里的快捷键可以和通用快捷键同键,在终端、编辑器里以前者为准(如 ⌘K 在终端里是清屏)。
- 默认(macOS / 其他平台):新建主机 ⌘N / Ctrl+N,新建分组 ⌘⇧N / Ctrl+Shift+N,打开设置 ⌘, / Ctrl+,,显示或隐藏侧栏 ⌘B / Ctrl+B,显示或隐藏右侧栏 ⌘⌥B / Ctrl+Alt+B,聚焦搜索 ⌘K / Ctrl+K,放大、缩小界面和实际大小 ⌘= ⌘- ⌘0 / Ctrl+= Ctrl+- Ctrl+0,退出 ⌘Q / Ctrl+Q;新建本地终端 ⌘T / Ctrl+T,关闭标签 ⌘W / Ctrl+W,下一个标签 ⌘⇧] / Ctrl+Tab,上一个标签 ⌘⇧[ / Ctrl+Shift+Tab,切换到标签 1…9 ⌘1…⌘9 / Alt+1…9(9 是最后一个);终端的复制、粘贴、查找、查找下一个、查找上一个、清屏 ⌘C ⌘V ⌘F ⌘G ⌘⇧G ⌘K / Ctrl+Shift+C、Ctrl+Shift+V、Ctrl+Shift+F、F3、Shift+F3、Ctrl+Shift+K,放大、缩小字号和默认字号 ⌘= ⌘- ⌘0 / Ctrl+= Ctrl+- Ctrl+0;编辑器保存 ⌘S / Ctrl+S。标签切换在当前标签所在的那组标签里进行,首尾循环。⌘= / ⌘- / ⌘0 在终端里调终端字号(每次 1 像素,范围同「终端 › 字号」的 10–28,写进设置,下次启动保留),在终端外缩放整个界面(五档,重启后回到默认)。
- SFTP 文件列表、图片预览的快捷键和 Enter、方向键、Tab、Esc 这些固定键不在这里改。
@@ -197,16 +199,20 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口]
## 终端
右键终端内容区可复制、粘贴、查找、清屏;远程终端另有打开 SFTP、重新连接和断开连接。断开连接只断开这一个标签,标签保留,可随时重新连接,同一台主机的其他标签和 SFTP 不受影响。
远程终端和 SFTP 标签按各自的连接显示状态:连接中图标右下角是黄点,断开是红点,已连接不显示;鼠标移到点上可看到文字说明。远程终端连接失败、退出、手动断开或取消认证后,显示「按 Enter 重新连接」;聚焦终端后按无修饰键的 Enter,在原标签使用主机最新配置重连。按住按键、粘贴、查找或认证输入框不会触发重连。本地终端仍使用重新启动。
右键终端内容区可复制、粘贴、查找、清屏;远程终端另有打开 SFTP、重新连接和断开连接。「设置 › 终端 › 终端交互」可以把右键改成直接粘贴或不做任何事,也可以打开「选中复制」。断开连接只断开这一个标签,标签保留,可随时重新连接,同一台主机的其他标签和 SFTP 不受影响。
- **主机密钥**:第一次连接某台主机时要确认它的主机密钥(「信任并连接」),记在 ShellRS 自己的 `known_hosts` 里,之后不再问。服务器的密钥和记下的不一致时(重装了系统、换了密钥,也可能是有人冒充),弹框列出记下的和现在的指纹:「更新并连接」删掉这台主机记下的旧密钥、换成现在的再继续连接;「取消」不连接。测试连接、外部 CLI 和端口转发的自动重连遇到这种情况只报错、不询问,在 ShellRS 里连接一次这台主机更新即可。
- **回滚历史**:每个终端保留最近 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 插件)直接进本机剪贴板。远程程序读不了本机剪贴板。
- **粘贴图片**:本地终端和已保存主机的远程终端里,剪贴板是截图(没有文字)时,⌘V(其他平台 Ctrl+Shift+V)或右键粘贴会把图存成文件,再把路径粘进输入行,给终端里的 Agent 去读。本地写到系统临时目录;已保存的主机经 SFTP 写到远端 `/tmp`。剪贴板里有文字时仍粘文字。外部连接和临时连接不支持。
## 右侧栏
@@ -257,7 +263,7 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以
每一侧面板都有两行工具栏,下方是路径标签。面板太窄放不下时,按钮从右往左整个隐藏,不会换行(对应的命令在右键菜单和快捷键里仍然可用)。新开的 SFTP 标签本地、远程两侧各占一半;两侧和队列之间的分隔线拖到哪里,在这个标签关闭之前都会记住,切到别的标签再回来也不变:
- 第一行(导航):
- 「目录列表」:列出从根到当前目录的每一级,本地另有主目录、桌面、文稿、下载,选中即前往。
- 「目录列表」:列出从根到当前目录的每一级,本地另有主目录、桌面、文稿、下载,Windows 上还有各个驱动器(如「C: 本地磁盘」「D: 本地磁盘」),选中即前往。
- 书签:点按钮打开「打开目录/书签」(见下);旁边的下拉箭头列出这台主机这一侧的书签,点击即前往,也可添加或移除当前路径。书签保存在本地数据库,删除主机时一并删除。
- 上级目录、根目录、主目录、刷新、后退、前进。
- 显示 / 不显示隐藏文件:名称以 `.` 开头的文件和目录。睁开的眼睛表示正在显示,划掉的眼睛表示不显示。本地和远程各自一个开关,互不影响;每一侧的选择对所有 SFTP 标签生效,保存在设置里,默认都不显示。不显示时面板底部写明「隐藏 N 项」;显示时隐藏文件的文字颜色浅一些。
@@ -287,7 +293,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 +397,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
View File
@@ -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 地址。
+2 -5
View File
@@ -19,9 +19,6 @@ host.proxy_kind.socks5:
zh-CN: SOCKS5 代理
en: SOCKS5 proxy
host.draft.name:
zh-CN: 请输入名称
en: Enter a name
host.draft.address:
zh-CN: 请输入地址
en: Enter an address
@@ -127,8 +124,8 @@ host.dialog.name:
zh-CN: 名称
en: Name
host.dialog.name_placeholder:
zh-CN: 例如 web-01
en: e.g. web-01
zh-CN: 可选,默认用地址
en: Optional; the address if left empty
host.dialog.temporary_name_placeholder:
zh-CN: 可选,默认用地址
en: Optional; the address if left empty
+57
View File
@@ -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
@@ -116,6 +141,38 @@ settings.notifications.bell_description:
zh-CN: 终端就在当前标签时不提示。
en: Not when the terminal is the current tab.
# 终端 › 本地终端.
settings.local_terminal.title:
zh-CN: 本地终端
en: Local terminal
settings.local_terminal.shell:
zh-CN: Shell
en: Shell
settings.local_terminal.shell_description:
zh-CN: 新开或重新启动的本地终端运行的程序:路径或命令,可以带参数。留空时自动检测系统默认的 shell。
en: "The program new and restarted local terminals run: a path or a command, with any arguments. Leave it empty to use the system’s default shell."
# The empty field and the first choice of its menu, with the shell that
# runs: 「自动检测(/bin/zsh)」.
settings.local_terminal.automatic:
zh-CN: 自动检测(%{shell})
en: Automatic (%{shell})
# The same before the default shell is known.
settings.local_terminal.automatic_unknown:
zh-CN: 自动检测
en: Automatic
settings.local_terminal.installed:
zh-CN: 已安装的 shell
en: Installed shells
settings.local_terminal.browse:
zh-CN: 选择文件…
en: Choose file…
settings.local_terminal.browse_prompt:
zh-CN: 选择 shell
en: Choose a shell
settings.local_terminal.keywords:
zh-CN: shell,本地终端,命令,参数,bash,zsh,fish,PowerShell,cmd
en: shell,local terminal,command,arguments,bash,zsh,fish,PowerShell,cmd
settings.highlight.options:
zh-CN: 常规
en: Options
+12 -3
View File
@@ -15,6 +15,18 @@ sftp.place.documents:
sftp.place.downloads:
zh-CN: 下载
en: Downloads
sftp.place.drive_local:
zh-CN: "%{drive} 本地磁盘"
en: "%{drive} Local disk"
sftp.place.drive_removable:
zh-CN: "%{drive} 可移动磁盘"
en: "%{drive} Removable disk"
sftp.place.drive_network:
zh-CN: "%{drive} 网络驱动器"
en: "%{drive} Network drive"
sftp.place.drive_optical:
zh-CN: "%{drive} CD 驱动器"
en: "%{drive} CD drive"
sftp.error.remote_path_invalid:
zh-CN: 远程路径不能为空或包含空字符
@@ -305,9 +317,6 @@ sftp.client.no_path:
sftp.client.no_link_target:
zh-CN: 无法读取链接目标
en: Couldn’t read the link target
sftp.client.bad_rename_reply:
zh-CN: 服务器返回了无效的重命名响应
en: The server sent an invalid reply to the rename
sftp.client.ambiguous_name:
zh-CN: 远端名称包含无法可靠表示的字符,已停止操作
en: A remote name has characters that can’t be shown reliably, so the operation stopped
+2 -2
View File
@@ -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
+31
View File
@@ -71,6 +71,26 @@ terminal.command.multiline:
zh-CN: 这是一条多行命令,在这个终端里只能直接执行
en: This is a multi-line command, which this terminal can only run directly
# A screenshot pasted for an agent in the terminal
terminal.paste.image_empty:
zh-CN: 剪贴板里的图片是空的
en: The image on the clipboard is empty
terminal.paste.image_too_large:
zh-CN: 图片太大,粘贴不了
en: The image is too large to paste
terminal.paste.image_save_failed:
zh-CN: 没法把图片存到本机:%{error}
en: "Couldn’t save the image on this computer: %{error}"
terminal.paste.image_upload_failed:
zh-CN: 没法把图片传到主机:%{error}
en: "Couldn’t send the image to the host: %{error}"
terminal.paste.image_unsupported:
zh-CN: 外部连接和未保存的主机不能粘贴图片
en: External connections and unsaved hosts can’t paste images
terminal.paste.image_title:
zh-CN: 粘贴图片
en: Paste image
# State of a terminal
terminal.lifecycle.starting:
zh-CN: 正在启动
@@ -170,6 +190,13 @@ terminal.pty.open_failed:
terminal.pty.shell_failed:
zh-CN: 无法启动系统默认 shell
en: Couldn’t start the default shell
# The program 设置 › 终端 › 本地终端 names, as written there, and why.
terminal.pty.command_failed:
zh-CN: 无法启动 %{command}:%{error}
en: "Couldn’t start %{command}: %{error}"
terminal.pty.unmatched_quote:
zh-CN: 设置 → 终端 → 本地终端里的 Shell 有引号没有配对:%{command}
en: "The shell in Settings → Terminal → Local terminal has an unmatched quote: %{command}"
terminal.pty.reader_failed:
zh-CN: 无法读取本地 PTY
en: Couldn’t read from the local PTY
@@ -191,3 +218,7 @@ terminal.pty.resize_error:
terminal.pty.exit_status:
zh-CN: 无法读取 shell 退出状态
en: Couldn’t read the shell’s exit status
terminal.notice.enter_reconnect:
zh-CN: 按 Enter 重新连接
en: Press Enter to reconnect
+8 -4
View File
@@ -128,11 +128,15 @@ workspace.host_key.changed_title:
zh-CN: 服务器主机密钥已变更
en: Server host key changed
workspace.host_key.changed:
zh-CN: "主机:%{host}:%{port}\n算法:%{algorithm}\n已保存指纹:%{old}\n服务器当前指纹:%{fingerprint}\n\n连接已阻断。请核验服务器身份后手动处理:%{path}"
en: "Host: %{host}:%{port}\nAlgorithm: %{algorithm}\nSaved fingerprint: %{old}\nServer’s fingerprint now: %{fingerprint}\n\nThe connection was blocked. Verify the server’s identity, then sort it out by hand in %{path}"
zh-CN: "主机:%{host}:%{port}\n算法:%{algorithm}\n已保存指纹:%{old}\n服务器当前指纹:%{fingerprint}\n\n服务器重装过或换过密钥时,更新后即可连接。否则可能有人在冒充这台服务器,请先核实。"
en: "Host: %{host}:%{port}\nAlgorithm: %{algorithm}\nSaved fingerprint: %{old}\nServer’s fingerprint now: %{fingerprint}\n\nIf the server was reinstalled or its keys were replaced, update the saved key to connect. Otherwise someone may be posing as the server, so verify it first."
workspace.host_key.changed_via_jump_host:
zh-CN: "跳板主机:%{jump_host}\n主机:%{host}:%{port}\n算法:%{algorithm}\n已保存指纹:%{old}\n服务器当前指纹:%{fingerprint}\n\n连接已阻断。请核验服务器身份后手动处理:%{path}"
en: "Jump host: %{jump_host}\nHost: %{host}:%{port}\nAlgorithm: %{algorithm}\nSaved fingerprint: %{old}\nServer’s fingerprint now: %{fingerprint}\n\nThe connection was blocked. Verify the server’s identity, then sort it out by hand in %{path}"
zh-CN: "跳板主机:%{jump_host}\n主机:%{host}:%{port}\n算法:%{algorithm}\n已保存指纹:%{old}\n服务器当前指纹:%{fingerprint}\n\n服务器重装过或换过密钥时,更新后即可连接。否则可能有人在冒充这台服务器,请先核实。"
en: "Jump host: %{jump_host}\nHost: %{host}:%{port}\nAlgorithm: %{algorithm}\nSaved fingerprint: %{old}\nServer’s fingerprint now: %{fingerprint}\n\nIf the server was reinstalled or its keys were replaced, update the saved key to connect. Otherwise someone may be posing as the server, so verify it first."
# Replaces the saved keys of the host with the one it shows now.
workspace.host_key.update:
zh-CN: 更新并连接
en: Update and connect
workspace.auth.title:
zh-CN: SSH 认证
en: SSH authentication
+12 -8
View File
@@ -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
}
+6 -6
View File
@@ -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"
}
@@ -0,0 +1,418 @@
; *** Inno Setup version 6.5.0+ Chinese Simplified messages ***
;
; To download user-contributed translations of this file, go to:
; https://jrsoftware.org/files/istrans/
;
; Note: When translating this text, do not add periods (.) to the end of
; messages that didn't have them already, because on those messages Inno
; Setup adds the periods automatically (appending a period would result in
; two periods being displayed).
;
; Maintained by Zhenghan Yang
; Email: 847320916@QQ.com
; Translation based on network resource
; The latest Translation is on https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
;
[LangOptions]
; The following three entries are very important. Be sure to read and
; understand the '[LangOptions] section' topic in the help file.
LanguageName=简体中文
; If Language Name display incorrect, uncomment next line
; LanguageName=<7B80><4F53><4E2D><6587>
; About LanguageID, to reference link:
; https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/a9eac961-e77d-41a6-90a5-ce1a8b0cdb9c
LanguageID=$0804
; About CodePage, to reference link:
; https://docs.microsoft.com/en-us/windows/win32/intl/code-page-identifiers
LanguageCodePage=936
; If the language you are translating to requires special font faces or
; sizes, uncomment any of the following entries and change them accordingly.
;DialogFontName=
;DialogFontSize=9
;DialogFontBaseScaleWidth=7
;DialogFontBaseScaleHeight=15
;WelcomeFontName=Segoe UI
;WelcomeFontSize=14
[Messages]
; *** 应用程序标题
SetupAppTitle=安装
SetupWindowTitle=安装 - %1
UninstallAppTitle=卸载
UninstallAppFullTitle=%1 卸载
; *** Misc. common
InformationTitle=信息
ConfirmTitle=确认
ErrorTitle=错误
; *** SetupLdr messages
SetupLdrStartupMessage=现在将安装 %1。您想要继续吗?
LdrCannotCreateTemp=无法创建临时文件。安装程序已中止
LdrCannotExecTemp=无法执行临时目录中的文件。安装程序已中止
HelpTextNote=
; *** 启动错误消息
LastErrorMessage=%1。%n%n错误 %2: %3
SetupFileMissing=安装目录中缺少文件 %1。请修正这个问题或者获取程序的新副本。
SetupFileCorrupt=安装文件已损坏。请获取程序的新副本。
SetupFileCorruptOrWrongVer=安装文件已损坏,或是与这个安装程序的版本不兼容。请修正这个问题或获取新的程序副本。
InvalidParameter=无效的命令行参数:%n%n%1
SetupAlreadyRunning=安装程序正在运行。
WindowsVersionNotSupported=此程序不支持当前计算机运行的 Windows 版本。
WindowsServicePackRequired=此程序需要 %1 服务包 %2 或更高版本。
NotOnThisPlatform=此程序不能在 %1 上运行。
OnlyOnThisPlatform=此程序只能在 %1 上运行。
OnlyOnTheseArchitectures=此程序只能安装到为下列处理器架构设计的 Windows 版本中:%n%n%1
WinVersionTooLowError=此程序需要 %1 版本 %2 或更高。
WinVersionTooHighError=此程序不能安装于 %1 版本 %2 或更高。
AdminPrivilegesRequired=在安装此程序时您必须以管理员身份登录。
PowerUserPrivilegesRequired=在安装此程序时您必须以管理员身份或有权限的用户组身份登录。
SetupAppRunningError=安装程序发现 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
UninstallAppRunningError=卸载程序发现 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
; *** 启动问题
PrivilegesRequiredOverrideTitle=选择安装程序模式
PrivilegesRequiredOverrideInstruction=选择安装模式
PrivilegesRequiredOverrideText1=%1 可以为所有用户安装(需要管理员权限),或仅为您安装。
PrivilegesRequiredOverrideText2=%1 可以仅为您安装,或为所有用户安装(需要管理员权限)。
PrivilegesRequiredOverrideAllUsers=为所有用户安装(&A)
PrivilegesRequiredOverrideAllUsersRecommended=为所有用户安装(&A) (建议选项)
PrivilegesRequiredOverrideCurrentUser=仅为我安装(&M)
PrivilegesRequiredOverrideCurrentUserRecommended=仅为我安装(&M) (建议选项)
; *** 其他错误
ErrorCreatingDir=安装程序无法创建目录“%1”
ErrorTooManyFilesInDir=无法在目录“%1”中创建文件,因为里面包含太多文件
; *** 安装程序公共消息
ExitSetupTitle=退出安装程序
ExitSetupMessage=安装程序尚未完成。如果现在退出,将不会安装该程序。%n%n您之后可以再次运行安装程序完成安装。%n%n现在退出安装程序吗?
AboutSetupMenuItem=关于安装程序(&A)...
AboutSetupTitle=关于安装程序
AboutSetupMessage=%1 版本 %2%n%3%n%n%1 主页:%n%4
AboutSetupNote=
TranslatorNote=简体中文翻译由Kira(847320916@qq.com)维护。项目地址:https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
; *** 按钮
ButtonBack=< 上一步(&B)
ButtonNext=下一步(&N) >
ButtonInstall=安装(&I)
ButtonOK=确定
ButtonCancel=取消
ButtonYes=是(&Y)
ButtonYesToAll=全是(&A)
ButtonNo=否(&N)
ButtonNoToAll=全否(&O)
ButtonFinish=完成(&F)
ButtonBrowse=浏览(&B)...
ButtonWizardBrowse=浏览(&R)...
ButtonNewFolder=新建文件夹(&M)
; *** “选择语言”对话框消息
SelectLanguageTitle=选择安装语言
SelectLanguageLabel=选择安装时使用的语言。
; *** 公共向导文字
ClickNext=点击“下一步”继续,或点击“取消”退出安装程序。
BeveledLabel=
BrowseDialogTitle=浏览文件夹
BrowseDialogLabel=在下面的列表中选择一个文件夹,然后点击“确定”。
NewFolderName=新建文件夹
; *** “欢迎”向导页
WelcomeLabel1=欢迎使用 [name] 安装向导
WelcomeLabel2=现在将安装 [name/ver] 到您的电脑中。%n%n建议您在继续安装前关闭所有其他应用程序。
; *** “密码”向导页
WizardPassword=密码
PasswordLabel1=这个安装程序有密码保护。
PasswordLabel3=请输入密码,然后点击“下一步”继续。密码区分大小写。
PasswordEditLabel=密码(&P):
IncorrectPassword=您输入的密码不正确,请重新输入。
; *** “许可协议”向导页
WizardLicense=许可协议
LicenseLabel=请在继续安装前阅读以下重要信息。
LicenseLabel3=请仔细阅读下列许可协议。在继续安装前您必须同意这些协议条款。
LicenseAccepted=我同意此协议(&A)
LicenseNotAccepted=我不同意此协议(&D)
; *** “信息”向导页
WizardInfoBefore=信息
InfoBeforeLabel=请在继续安装前阅读以下重要信息。
InfoBeforeClickLabel=准备好继续安装后,点击“下一步”。
WizardInfoAfter=信息
InfoAfterLabel=请在继续安装前阅读以下重要信息。
InfoAfterClickLabel=准备好继续安装后,点击“下一步”。
; *** “用户信息”向导页
WizardUserInfo=用户信息
UserInfoDesc=请输入您的信息。
UserInfoName=用户名(&U):
UserInfoOrg=组织(&O):
UserInfoSerial=序列号(&S):
UserInfoNameRequired=您必须输入用户名。
; *** “选择目标目录”向导页
WizardSelectDir=选择目标位置
SelectDirDesc=您想将 [name] 安装在哪里?
SelectDirLabel3=安装程序将安装 [name] 到下面的文件夹中。
SelectDirBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
DiskSpaceGBLabel=至少需要有 [gb] GB 的可用磁盘空间。
DiskSpaceMBLabel=至少需要有 [mb] MB 的可用磁盘空间。
CannotInstallToNetworkDrive=安装程序无法安装到一个网络驱动器。
CannotInstallToUNCPath=安装程序无法安装到一个 UNC 路径。
InvalidPath=您必须输入一个带驱动器卷标的完整路径,例如:%n%nC:\APP%n%n或UNC路径:%n%n\\server\share
InvalidDrive=您选定的驱动器或 UNC 共享不存在或不能访问。请选择其他位置。
DiskSpaceWarningTitle=磁盘空间不足
DiskSpaceWarning=安装程序至少需要 %1 KB 的可用空间才能安装,但选定驱动器只有 %2 KB 的可用空间。%n%n您一定要继续吗?
DirNameTooLong=文件夹名称或路径太长。
InvalidDirName=文件夹名称无效。
BadDirName32=文件夹名称不能包含下列任何字符:%n%n%1
DirExistsTitle=文件夹已存在
DirExists=文件夹:%n%n%1%n%n已经存在。您一定要安装到这个文件夹中吗?
DirDoesntExistTitle=文件夹不存在
DirDoesntExist=文件夹:%n%n%1%n%n不存在。您想要创建此文件夹吗?
; *** “选择组件”向导页
WizardSelectComponents=选择组件
SelectComponentsDesc=您想安装哪些程序组件?
SelectComponentsLabel2=选中您想安装的组件;取消您不想安装的组件。然后点击“下一步”继续。
FullInstallation=完全安装
; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language)
CompactInstallation=简洁安装
CustomInstallation=自定义安装
NoUninstallWarningTitle=组件已存在
NoUninstallWarning=安装程序检测到下列组件已安装在您的电脑中:%n%n%1%n%n取消选中这些组件不会卸载它们。%n%n确定要继续吗?
ComponentSize1=%1 KB
ComponentSize2=%1 MB
ComponentsDiskSpaceGBLabel=当前选择的组件需要至少 [gb] GB 的磁盘空间。
ComponentsDiskSpaceMBLabel=当前选择的组件需要至少 [mb] MB 的磁盘空间。
; *** “选择附加任务”向导页
WizardSelectTasks=选择附加任务
SelectTasksDesc=您想要安装程序执行哪些附加任务?
SelectTasksLabel2=选择您想要安装程序在安装 [name] 时执行的附加任务,然后点击“下一步”。
; *** “选择开始菜单文件夹”向导页
WizardSelectProgramGroup=选择开始菜单文件夹
SelectStartMenuFolderDesc=安装程序应该在哪里放置程序的快捷方式?
SelectStartMenuFolderLabel3=安装程序将在下列“开始”菜单文件夹中创建程序的快捷方式。
SelectStartMenuFolderBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
MustEnterGroupName=您必须输入一个文件夹名。
GroupNameTooLong=文件夹名或路径太长。
InvalidGroupName=无效的文件夹名字。
BadGroupName=文件夹名不能包含下列任何字符:%n%n%1
NoProgramGroupCheck2=不创建开始菜单文件夹(&D)
; *** “准备安装”向导页
WizardReady=准备安装
ReadyLabel1=安装程序准备就绪,现在可以开始安装 [name] 到您的电脑。
ReadyLabel2a=点击“安装”继续此安装程序。如果您想重新考虑或修改任何设置,点击“上一步”。
ReadyLabel2b=点击“安装”继续此安装程序。
ReadyMemoUserInfo=用户信息:
ReadyMemoDir=目标位置:
ReadyMemoType=安装类型:
ReadyMemoComponents=已选择组件:
ReadyMemoGroup=开始菜单文件夹:
ReadyMemoTasks=附加任务:
; *** TExtractionWizardPage 向导页面与 ExtractArchive
ExtractingLabel=正在解压文件...
ButtonStopExtraction=停止解压(&S)
StopExtraction=您确定要停止解压吗?
ErrorExtractionAborted=解压已中止
ErrorExtractionFailed=解压失败:%1
; *** 压缩文件解压失败详情
ArchiveIncorrectPassword=压缩文件密码不正确
ArchiveIsCorrupted=压缩文件已损坏
ArchiveUnsupportedFormat=不支持的压缩文件格式
; *** TDownloadWizardPage 向导页面和 DownloadTemporaryFile
DownloadingLabel2=正在下载文件...
ButtonStopDownload=停止下载(&S)
StopDownload=您确定要停止下载吗?
ErrorDownloadAborted=下载已中止
ErrorDownloadFailed=下载失败:%1 %2
ErrorDownloadSizeFailed=获取下载大小失败:%1 %2
ErrorProgress=无效的进度:%1 / %2
ErrorFileSize=文件大小错误:预期 %1,实际 %2
; *** “正在准备安装”向导页
WizardPreparing=正在准备安装
PreparingDesc=安装程序正在准备安装 [name] 到您的电脑。
PreviousInstallNotCompleted=先前的程序安装或卸载未完成,您需要重启您的电脑以完成。%n%n在重启电脑后,再次运行安装程序以完成 [name] 的安装。
CannotContinue=安装程序不能继续。请点击“取消”退出。
ApplicationsFound=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。
ApplicationsFound2=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。安装完成后,安装程序将尝试重新启动这些应用程序。
CloseApplications=自动关闭应用程序(&A)
DontCloseApplications=不要关闭应用程序(&D)
ErrorCloseApplications=安装程序无法自动关闭所有应用程序。建议您在继续之前,关闭所有在使用需要由安装程序更新的文件的应用程序。
PrepareToInstallNeedsRestart=安装程序必须重启您的计算机。计算机重启后,请再次运行安装程序以完成 [name] 的安装。%n%n是否立即重新启动?
; *** “正在安装”向导页
WizardInstalling=正在安装
InstallingLabel=安装程序正在安装 [name] 到您的电脑,请稍候。
; *** “安装完成”向导页
FinishedHeadingLabel=[name] 安装完成
FinishedLabelNoIcons=安装程序已在您的电脑中安装了 [name]。
FinishedLabel=安装程序已在您的电脑中安装了 [name]。您可以通过已安装的快捷方式运行此应用程序。
ClickFinish=点击“完成”退出安装程序。
FinishedRestartLabel=为完成 [name] 的安装,安装程序必须重新启动您的电脑。要立即重启吗?
FinishedRestartMessage=为完成 [name] 的安装,安装程序必须重新启动您的电脑。%n%n要立即重启吗?
ShowReadmeCheck=是,我想查阅自述文件
YesRadio=是,立即重启电脑(&Y)
NoRadio=否,稍后重启电脑(&N)
; used for example as 'Run MyProg.exe'
RunEntryExec=运行 %1
; used for example as 'View Readme.txt'
RunEntryShellExec=查阅 %1
; *** “安装程序需要下一张磁盘”提示
ChangeDiskTitle=安装程序需要下一张磁盘
SelectDiskLabel2=请插入磁盘 %1 并点击“确定”。%n%n如果这个磁盘中的文件可以在下列文件夹之外的文件夹中找到,请输入正确的路径或点击“浏览”。
PathLabel=路径(&P):
FileNotInDir2=“%2”中找不到文件“%1”。请插入正确的磁盘或选择其他文件夹。
SelectDirectoryLabel=请指定下一张磁盘的位置。
; *** 安装阶段消息
SetupAborted=安装程序未完成安装。%n%n请修正这个问题并重新运行安装程序。
AbortRetryIgnoreSelectAction=选择操作
AbortRetryIgnoreRetry=重试(&T)
AbortRetryIgnoreIgnore=忽略错误并继续(&I)
AbortRetryIgnoreCancel=关闭安装程序
RetryCancelSelectAction=选择操作
RetryCancelRetry=重试(&T)
RetryCancelCancel=取消(&C)
; *** 安装状态消息
StatusClosingApplications=正在关闭应用程序...
StatusCreateDirs=正在创建目录...
StatusExtractFiles=正在提取文件...
StatusDownloadFiles=正在下载文件...
StatusCreateIcons=正在创建快捷方式...
StatusCreateIniEntries=正在创建 INI 条目...
StatusCreateRegistryEntries=正在创建注册表条目...
StatusRegisterFiles=正在注册文件...
StatusSavingUninstall=正在保存卸载信息...
StatusRunProgram=正在完成安装...
StatusRestartingApplications=正在重启应用程序...
StatusRollback=正在撤销更改...
; *** 其他错误
ErrorInternal2=内部错误:%1
ErrorFunctionFailedNoCode=%1 失败
ErrorFunctionFailed=%1 失败;错误代码 %2
ErrorFunctionFailedWithMessage=%1 失败;错误代码 %2.%n%3
ErrorExecutingProgram=无法执行文件:%n%1
; *** 注册表错误
ErrorRegOpenKey=打开注册表项时出错:%n%1\%2
ErrorRegCreateKey=创建注册表项时出错:%n%1\%2
ErrorRegWriteKey=写入注册表项时出错:%n%1\%2
; *** INI 错误
ErrorIniEntry=在文件“%1”中创建 INI 条目时出错。
; *** 文件复制错误
FileAbortRetryIgnoreSkipNotRecommended=跳过此文件(&S) (不推荐)
FileAbortRetryIgnoreIgnoreNotRecommended=忽略错误并继续(&I) (不推荐)
SourceIsCorrupted=源文件已损坏
SourceDoesntExist=源文件“%1”不存在
SourceVerificationFailed=源文件验证失败: %1
VerificationSignatureDoesntExist=签名文件“%1”不存在
VerificationSignatureInvalid=签名文件“%1”无效
VerificationKeyNotFound=签名文件“%1”使用了未知密钥
VerificationFileNameIncorrect=文件名不正确
VerificationFileTagIncorrect=文件标签不正确
VerificationFileSizeIncorrect=文件大小不正确
VerificationFileHashIncorrect=文件哈希值不正确
ExistingFileReadOnly2=无法替换现有文件,它是只读的。
ExistingFileReadOnlyRetry=移除只读属性并重试(&R)
ExistingFileReadOnlyKeepExisting=保留现有文件(&K)
ErrorReadingExistingDest=尝试读取现有文件时出错:
FileExistsSelectAction=选择操作
FileExists2=文件已经存在。
FileExistsOverwriteExisting=覆盖已存在的文件(&O)
FileExistsKeepExisting=保留现有的文件(&K)
FileExistsOverwriteOrKeepAll=为所有冲突文件执行此操作(&D)
ExistingFileNewerSelectAction=选择操作
ExistingFileNewer2=现有的文件比安装程序将要安装的文件还要新。
ExistingFileNewerOverwriteExisting=覆盖已存在的文件(&O)
ExistingFileNewerKeepExisting=保留现有的文件(&K) (推荐)
ExistingFileNewerOverwriteOrKeepAll=为所有冲突文件执行此操作(&D)
ErrorChangingAttr=尝试更改下列现有文件的属性时出错:
ErrorCreatingTemp=尝试在目标目录创建文件时出错:
ErrorReadingSource=尝试读取下列源文件时出错:
ErrorCopying=尝试复制下列文件时出错:
ErrorDownloading=下载文件时出错:
ErrorExtracting=解压压缩文件时出错:
ErrorReplacingExistingFile=尝试替换现有文件时出错:
ErrorRestartReplace=重启并替换失败:
ErrorRenamingTemp=尝试重命名下列目标目录中的一个文件时出错:
ErrorRegisterServer=无法注册 DLL/OCX:%1
ErrorRegSvr32Failed=RegSvr32 失败;退出代码 %1
ErrorRegisterTypeLib=无法注册类库:%1
; *** 卸载显示名字标记
; used for example as 'My Program (32-bit)'
UninstallDisplayNameMark=%1 (%2)
; used for example as 'My Program (32-bit, All users)'
UninstallDisplayNameMarks=%1 (%2, %3)
UninstallDisplayNameMark32Bit=32 位
UninstallDisplayNameMark64Bit=64 位
UninstallDisplayNameMarkAllUsers=所有用户
UninstallDisplayNameMarkCurrentUser=当前用户
; *** 安装后错误
ErrorOpeningReadme=尝试打开自述文件时出错。
ErrorRestartingComputer=安装程序无法重启电脑,请手动重启。
; *** 卸载消息
UninstallNotFound=文件“%1”不存在。无法卸载。
UninstallOpenError=文件“%1”不能被打开。无法卸载。
UninstallUnsupportedVer=此版本的卸载程序无法识别卸载日志文件“%1”的格式。无法卸载
UninstallUnknownEntry=卸载日志中遇到一个未知条目 (%1)
ConfirmUninstall=您确认要完全移除 %1 及其所有组件吗?
UninstallOnlyOnWin64=仅允许在 64 位 Windows 中卸载此程序。
OnlyAdminCanUninstall=仅使用管理员权限的用户能完成此卸载。
UninstallStatusLabel=正在从您的电脑中移除 %1,请稍候。
UninstalledAll=已顺利从您的电脑中移除 %1。
UninstalledMost=%1 卸载完成。%n%n有部分内容未能被删除,但您可以手动删除它们。
UninstalledAndNeedsRestart=为完成 %1 的卸载,需要重启您的电脑。%n%n立即重启电脑吗?
UninstallDataCorrupted=文件“%1”已损坏。无法卸载
; *** 卸载状态消息
ConfirmDeleteSharedFileTitle=删除共享的文件吗?
ConfirmDeleteSharedFile2=系统表示下列共享的文件已不有其他程序使用。您希望卸载程序删除这些共享的文件吗?%n%n如果删除这些文件,但仍有程序在使用这些文件,则这些程序可能出现异常。如果您不能确定,请选择“否”,在系统中保留这些文件以免引发问题。
SharedFileNameLabel=文件名:
SharedFileLocationLabel=位置:
WizardUninstalling=卸载状态
StatusUninstalling=正在卸载 %1...
; *** Shutdown block reasons
ShutdownBlockReasonInstallingApp=正在安装 %1。
ShutdownBlockReasonUninstallingApp=正在卸载 %1。
; The custom messages below aren't used by Setup itself, but if you make
; use of them in your scripts, you'll want to translate them.
[CustomMessages]
NameAndVersion=%1 版本 %2
AdditionalIcons=附加快捷方式:
CreateDesktopIcon=创建桌面快捷方式(&D)
CreateQuickLaunchIcon=创建快速启动栏快捷方式(&Q)
ProgramOnTheWeb=%1 网站
UninstallProgram=卸载 %1
LaunchProgram=运行 %1
AssocFileExtension=将 %2 文件扩展名与 %1 建立关联(&A)
AssocingFileExtension=正在将 %2 文件扩展名与 %1 建立关联...
AutoStartProgramGroupDescription=启动:
AutoStartProgram=自动启动 %1
AddonHostProgramNotFound=您选择的文件夹中无法找到 %1。%n%n您要继续吗?
+16 -4
View File
@@ -15,6 +15,11 @@
#define BuildDir "..\..\target\release"
#endif
#define IconFile AddBackslash(SourcePath) + "..\..\assets\logo\shellrs.ico"
; Languages\ChineseSimplified.isl is UTF-8 without a BOM, and has the
; messages of 6.5 onwards.
#if Ver < EncodeVer(6, 5, 0)
#error Inno Setup 6.5 or later is needed
#endif
[Setup]
AppId={{45D9E9F5-84BB-4B09-ABE8-1FFE107ACF96}
@@ -40,16 +45,23 @@ UninstallDisplayName=ShellRS
; A running ShellRS is closed through the Restart Manager rather than
; making the setup fail on files in use.
CloseApplications=yes
; The setup speaks the language Windows shows, without asking: Simplified
; Chinese on a Chinese Windows, English on any other, as ShellRS itself.
ShowLanguageDialog=no
; Not the language of the copy it replaces: up to 0.1.9-beta.1 every setup
; was English, which would otherwise stay with each installed copy.
UsePreviousLanguage=no
#if FileExists(IconFile)
SetupIconFile={#IconFile}
#endif
[Languages]
#if FileExists(AddBackslash(CompilerPath) + "Languages\ChineseSimplified.isl")
Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl"
#else
; The first is the one for a language not listed.
Name: "english"; MessagesFile: "compiler:Default.isl"
#endif
; Not among the translations Inno Setup installs, so kept here: issrc's
; Files/Languages/Unofficial/ChineseSimplified.isl at is-6_7_1, the version
; on the windows-2025 runner. Update it with Inno Setup.
Name: "chinesesimplified"; MessagesFile: "Languages\ChineseSimplified.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
+2 -1
View File
@@ -77,7 +77,8 @@ icon_assets!(
Zap,
ExternalLink,
Highlighter,
GripVertical
GripVertical,
FileSearch
]
);
+34 -3
View File
@@ -487,7 +487,7 @@ fn same_keystroke(a: &Keystroke, b: &Keystroke) -> bool {
/// Why a key cannot be a shortcut.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Refusal {
/// A plain key: it would stop typing it.
/// A plain key, or Shift with one: it would stop typing it.
NeedsModifier,
/// 切换到标签 1…9 takes a modifier with a digit.
NeedsDigit,
@@ -544,7 +544,10 @@ pub fn check(
return Err(Refusal::NeedsDigit);
}
keystroke.key = "1".into();
} else if !held && !is_function_key(&keystroke.key) {
} else if !held
&& !is_function_key(&keystroke.key)
&& !(modifiers.shift && is_editing_key(&keystroke.key))
{
return Err(Refusal::NeedsModifier);
}
let keys = keystroke.unparse();
@@ -583,10 +586,22 @@ pub fn check(
Ok(keys)
}
/// F1–F12, which type nothing. The keys above F12 are left out: hardly a
/// keyboard has them.
fn is_function_key(key: &str) -> bool {
key.strip_prefix('f')
.and_then(|number| number.parse::<u8>().ok())
.is_some_and(|number| (1..=24).contains(&number))
.is_some_and(|number| (1..=12).contains(&number))
}
/// Keys that type nothing with Shift either, as Shift+Insert pastes on
/// Windows and Linux. Shift with an arrow stays out: it selects text in a
/// field, and programs in a terminal use it.
fn is_editing_key(key: &str) -> bool {
matches!(
key,
"insert" | "delete" | "home" | "end" | "pageup" | "pagedown"
)
}
/// The keys of a shortcut, one cap each, in the platform's order: ⌃⌥⇧⌘
@@ -1042,7 +1057,18 @@ mod tests {
assert_eq!(check(new_host, "j"), Err(Refusal::NeedsModifier));
assert_eq!(check(new_host, "shift-j"), Err(Refusal::NeedsModifier));
assert_eq!(check(new_host, "f5"), Ok("f5".into()));
assert_eq!(check(new_host, "f13"), Err(Refusal::NeedsModifier));
assert_eq!(check(new_host, "alt-j"), Ok("alt-j".into()));
// Shift with a key that types nothing, as Shift+Insert pastes; alone
// such a key has its own work, and Shift with an arrow selects.
let paste = shortcut("terminal-paste");
assert_eq!(check(paste, "shift-insert"), Ok("shift-insert".into()));
assert_eq!(
check(new_host, "shift-pagedown"),
Ok("shift-pagedown".into())
);
assert_eq!(check(paste, "insert"), Err(Refusal::NeedsModifier));
assert_eq!(check(paste, "shift-up"), Err(Refusal::NeedsModifier));
// Taken by another shortcut working anywhere.
let terminal = shortcut("new-local-terminal");
assert_eq!(
@@ -1051,6 +1077,11 @@ mod tests {
);
// A text field's key.
assert_eq!(check(new_host, &primary("z")), Err(Refusal::Reserved));
let fixed = [KeyBinding::new("shift-home", NoAction, Some("Input"))];
assert_eq!(
super::check(new_host, &keys("shift-home"), &overrides, &fixed),
Err(Refusal::Reserved)
);
// A terminal's key may also work anywhere, where it does something
// else; inside a terminal the terminal's wins.
let find = shortcut("terminal-find");
+26 -2
View File
@@ -740,7 +740,7 @@ mod tests {
}
#[test]
fn a_new_host_needs_a_name_and_an_address_and_takes_the_forms_defaults() {
fn a_new_host_needs_an_address_and_takes_the_forms_defaults() {
let (store, _, _) = store();
let plan = plan_host(
fields(serde_json::json!({ "name": " db ", "host": "10.0.0.2" })),
@@ -761,7 +761,11 @@ mod tests {
);
for (json, message) in [
(serde_json::json!({ "host": "10.0.0.2" }), "请输入名称"),
(serde_json::json!({}), "请输入地址"),
(
serde_json::json!({ "name": "", "host": " " }),
"请输入地址",
),
(serde_json::json!({ "name": "db" }), "请输入地址"),
(
serde_json::json!({ "name": "db", "host": "h", "port": 0 }),
@@ -793,6 +797,26 @@ mod tests {
}
}
#[test]
fn a_host_with_no_name_uses_its_address() {
let (store, web, _) = store();
for json in [
serde_json::json!({ "host": " 10.0.0.2 " }),
serde_json::json!({ "name": "", "host": " 10.0.0.2 " }),
serde_json::json!({ "name": " ", "host": " 10.0.0.2 " }),
] {
let plan = plan_host(fields(json), None, &store).unwrap();
assert_eq!(plan.draft.name.as_ref(), "10.0.0.2");
}
let plan = plan_host(
fields(serde_json::json!({ "name": " " })),
Some(web),
&store,
)
.unwrap();
assert_eq!(plan.draft.name, plan.draft.address);
}
#[test]
fn a_group_path_names_a_group_or_the_groups_to_make() {
let (mut store, web, _) = store();
+1 -1
View File
@@ -65,7 +65,7 @@ const AFTER_HELP: &str = concat!(
const HOST_FIELDS: &str = "\
Reads one JSON object from stdin. Every field may be left out; `update` changes only the fields given.
name Required to create.
name Default the address; an empty name is the address too.
host The address (IP or host name). Required to create. Also read as `address`.
port Default 22.
user Default root. A host using a credential logs in as the credential's user.
-6
View File
@@ -105,7 +105,6 @@ pub struct HostKeyChangedPrompt {
algorithm: String,
old_fingerprints: Vec<String>,
fingerprint: String,
known_hosts_path: std::path::PathBuf,
jump_host: Option<String>,
}
@@ -129,9 +128,6 @@ impl HostKeyChangedPrompt {
pub fn fingerprint(&self) -> &str {
&self.fingerprint
}
pub fn known_hosts_path(&self) -> &std::path::Path {
&self.known_hosts_path
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -175,7 +171,6 @@ impl ConnectionPromptKind {
algorithm: impl Into<String>,
old_fingerprints: Vec<String>,
fingerprint: impl Into<String>,
known_hosts_path: impl Into<std::path::PathBuf>,
) -> Self {
Self::HostKeyChanged(HostKeyChangedPrompt {
host: host.into(),
@@ -183,7 +178,6 @@ impl ConnectionPromptKind {
algorithm: algorithm.into(),
old_fingerprints,
fingerprint: fingerprint.into(),
known_hosts_path: known_hosts_path.into(),
jump_host: None,
})
}
+1 -7
View File
@@ -117,18 +117,12 @@ impl ExplorerPanel {
.expect("workspace checked host");
let endpoint = login.endpoint();
let home = local_provider.home().to_string_lossy().into_owned();
let places = local_provider
.places()
.into_iter()
.map(|(place, path)| (place, path.to_string_lossy().into_owned()))
.collect();
let local = cx.new(|cx| {
FilePane::new(
PaneSide::Local,
id,
host_id,
home.clone(),
places,
store.clone(),
dispatch.clone(),
window,
@@ -141,7 +135,6 @@ impl ExplorerPanel {
id,
host_id,
String::new(),
Vec::new(),
store.clone(),
dispatch.clone(),
window,
@@ -912,6 +905,7 @@ impl Panel for ExplorerPanel {
// The host's mark, as on the host's terminal tabs.
let mark = HostMark::new(("explorer-tab-os", id.0), name, os).small();
ClosableTabTitle::new(("explorer-tab", id.0), mark, self.tab_title(cx))
.connection_state(self.connection_state())
.closable(("close-explorer", id.0), Box::new(CloseExplorer(id)))
.context_menu(move |popup, _, cx| menu.build(popup, cx))
}
+50 -8
View File
@@ -199,6 +199,43 @@ impl FileListing {
}
}
/// Keep the widths the columns were dragged to: the table takes the
/// columns again on each `refresh`, every listing among them.
pub(super) fn set_widths(&mut self, widths: &[Pixels]) {
for (column, width) in self.columns.iter_mut().zip(widths) {
column.width = *width;
}
}
/// A click on a column's title sorts as one on its arrow does:
/// descending, then ascending, then back to by name.
fn cycle_sort(&mut self, col_ix: usize) {
let sort = match self.sort {
Some((ix, ColumnSort::Descending)) if ix == col_ix => ColumnSort::Ascending,
Some((ix, ColumnSort::Ascending)) if ix == col_ix => ColumnSort::Default,
_ => ColumnSort::Descending,
};
self.set_sort(col_ix, sort);
}
/// Sort by a column, marking it on the columns too, which the table
/// takes again on each `refresh`: otherwise the arrows would go back to
/// unsorted with the next listing while the rows stayed sorted.
fn set_sort(&mut self, col_ix: usize, sort: ColumnSort) {
self.sort = match sort {
ColumnSort::Default => None,
sort => Some((col_ix, sort)),
};
for (ix, column) in self.columns.iter_mut().enumerate() {
column.sort = Some(if ix == col_ix {
sort
} else {
ColumnSort::Default
});
}
self.apply_sort();
}
/// The rows shown, in display order.
pub fn rows(&self) -> &[FileEntry] {
&self.rows
@@ -406,13 +443,14 @@ impl TableDelegate for FileListing {
}
/// `Column::text_right` is not applied by `DataTable`, so the size
/// column aligns itself. A right-click on a title is recorded for the
/// list's menu, which offers the size formats on 大小's, as WinSCP does.
/// column aligns itself. A click on a title sorts by the column, as in
/// WinSCP, rather than only one on the arrow. A right-click is recorded
/// for the list's menu, which offers the size formats on 大小's.
fn render_th(
&mut self,
col_ix: usize,
_: &mut Window,
_: &mut Context<TableState<Self>>,
cx: &mut Context<TableState<Self>>,
) -> impl IntoElement {
let column = &self.columns[col_ix];
let key = column.key.clone();
@@ -428,6 +466,14 @@ impl TableDelegate for FileListing {
*menu_hit.borrow_mut() = Some(MenuHit::Column(key.clone()));
})
.on_any_mouse_down(move |_, _, _| geometry.pressed.set(Pressed::Title))
// The table keeps the arrows in its columns, which only a
// `refresh` gives it again.
.on_click(cx.listener(move |table, _, _, cx| {
cx.stop_propagation();
table.delegate_mut().cycle_sort(col_ix);
table.refresh(cx);
cx.notify();
}))
.child(column.name.clone())
}
@@ -593,11 +639,7 @@ impl TableDelegate for FileListing {
_: &mut Window,
cx: &mut Context<TableState<Self>>,
) {
self.sort = match sort {
ColumnSort::Default => None,
sort => Some((col_ix, sort)),
};
self.apply_sort();
self.set_sort(col_ix, sort);
cx.notify();
}
+30 -14
View File
@@ -28,7 +28,7 @@ use gpui_kit::component::{
searchable_list::{SearchableGroup, SearchableListItem, SearchableVec},
select::{Select, SelectEvent, SelectState},
separator::Separator,
table::{DataTable, TableDelegate as _, TableState},
table::{DataTable, TableDelegate as _, TableEvent, TableState},
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
@@ -132,7 +132,7 @@ pub struct PathChoice {
title: SharedString,
path: SharedString,
depth: usize,
home: bool,
place: Option<Place>,
side: PaneSide,
}
impl SearchableListItem for PathChoice {
@@ -144,10 +144,10 @@ impl SearchableListItem for PathChoice {
&self.path
}
fn render(&self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let icon = if self.home {
Icon::new(CatalogIcon::House)
} else {
Icon::new(IconName::Folder)
let icon = match self.place {
Some(Place::Home) => Icon::new(CatalogIcon::House),
Some(Place::Drive(..)) => Icon::new(IconName::HardDrive),
_ => Icon::new(IconName::Folder),
};
h_flex()
.gap_2()
@@ -182,7 +182,8 @@ pub struct FilePane {
host_id: HostId,
pub(super) path: String,
home: String,
/// Well-known local places for the 目录列表 select; empty for remote.
/// Well-known local places for the 目录列表 select, read with each
/// local listing; empty for remote.
places: Vec<(Place, String)>,
table: Entity<TableState<FileListing>>,
/// The path label part under the pointer, by the directory it opens.
@@ -236,7 +237,6 @@ impl FilePane {
explorer: ExplorerId,
host_id: HostId,
home: String,
places: Vec<(Place, String)>,
store: Entity<HostStore>,
dispatch: FocusHandle,
window: &mut Window,
@@ -272,6 +272,11 @@ impl FilePane {
}
},
),
cx.subscribe(&table, |_, table, event: &TableEvent, cx| {
if let TableEvent::ColumnWidthsChanged(widths) = event {
table.update(cx, |table, _| table.delegate_mut().set_widths(widths));
}
}),
cx.observe(&store, |_, _, cx| cx.notify()),
cx.observe_global::<ShowHiddenFiles>(|pane, cx| pane.sync_hidden_files(cx)),
// The column titles and the select's groups are kept by
@@ -290,7 +295,7 @@ impl FilePane {
host_id,
path: home.clone(),
home,
places,
places: Vec::new(),
table,
hovered_part: None,
label_width: Rc::default(),
@@ -496,6 +501,10 @@ impl FilePane {
pub fn is_busy(&self) -> bool {
self.busy
}
/// The places the 目录列表 select offers, as of the last local listing.
pub fn places(&self) -> &[(Place, String)] {
&self.places
}
/// Select this row once the next listing arrives.
pub fn select_after_load(&mut self, name: String) {
self.select_after_load = Some(name);
@@ -935,7 +944,7 @@ impl FilePane {
title: title.into(),
path: path.into(),
depth,
home: false,
place: None,
side,
}),
),
@@ -947,7 +956,7 @@ impl FilePane {
title: place.title(),
path: path.clone().into(),
depth: 0,
home: *place == Place::Home,
place: Some(*place),
side,
}),
),
@@ -969,14 +978,21 @@ impl FilePane {
) {
let id = self.begin_load(intent, cx);
self.load_task = Some(cx.spawn_in(window, async move |this, cx| {
let result = cx
let (result, places) = cx
.background_spawn(async move {
provider
let result = provider
.list(&PathBuf::from(path))
.map_err(|e| t!("explorer.list.read_failed", error = e).to_string())
.map_err(|e| t!("explorer.list.read_failed", error = e).to_string());
let places = provider
.places()
.into_iter()
.map(|(place, path)| (place, path.to_string_lossy().into_owned()))
.collect();
(result, places)
})
.await;
let _ = this.update_in(cx, |this, window, cx| {
this.places = places;
this.apply_listing(id, result, window, cx)
});
}));
+9 -12
View File
@@ -677,17 +677,13 @@ impl HostForm {
/// Validate and write to the store. Returns whether the dialog may close.
pub fn commit(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
let name = self.name.read(cx).value().trim().to_string();
let checked = if name.is_empty() {
Err(HostDraftError::Name.message())
} else {
self.endpoint(cx).and_then(|endpoint| {
Ok((
endpoint,
self.committed_login(cx)?,
self.committed_route(cx)?,
))
})
};
let checked = self.endpoint(cx).and_then(|endpoint| {
Ok((
endpoint,
self.committed_login(cx)?,
self.committed_route(cx)?,
))
});
let ((host, port), login, route) = match checked {
Ok(checked) => checked,
Err(error) => {
@@ -696,6 +692,8 @@ impl HostForm {
}
};
let name = if name.is_empty() { host.clone() } else { name };
let group = self
.group
.read(cx)
@@ -1167,7 +1165,6 @@ impl Render for HostForm {
.child(
Field::new()
.label(t!("host.dialog.name"))
.required(!self.temporary)
.col_span(4)
.child(Input::new(&self.name).id("host-name").small()),
)
+39 -7
View File
@@ -585,20 +585,21 @@ impl HostDraft {
SecretRef::password(self.user.as_ref(), self.address.as_ref(), self.port)
}
/// The draft with surrounding blanks removed and an empty user name
/// taken as [`super::DEFAULT_USER`], or the first thing wrong with it,
/// The draft with surrounding blanks removed, an empty name taken as
/// the address and an empty user name as [`super::DEFAULT_USER`], or the
/// first thing wrong with it,
/// in the order the host form checks. What the form cannot leave out
/// of a draft (a credential to pick, a proxy password without a user)
/// is the caller's to check.
pub fn validated(mut self) -> Result<Self, HostDraftError> {
self.name = self.name.trim().to_string().into();
if self.name.is_empty() {
return Err(HostDraftError::Name);
}
self.address = self.address.trim().to_string().into();
if self.address.is_empty() {
return Err(HostDraftError::Address);
}
if self.name.is_empty() {
self.name = self.address.clone();
}
if self.port == 0 {
return Err(HostDraftError::Port);
}
@@ -636,7 +637,6 @@ impl HostDraft {
/// shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HostDraftError {
Name,
Address,
Port,
NoJumpHosts,
@@ -648,7 +648,6 @@ pub enum HostDraftError {
impl HostDraftError {
pub fn message(self) -> SharedString {
match self {
HostDraftError::Name => t!("host.draft.name"),
HostDraftError::Address => t!("host.draft.address"),
HostDraftError::Port => t!("host.draft.port"),
HostDraftError::NoJumpHosts => t!("host.draft.no_jump_hosts"),
@@ -671,6 +670,39 @@ impl std::error::Error for HostDraftError {}
mod tests {
use super::*;
#[test]
fn a_blank_host_name_uses_the_trimmed_address() {
for (name, expected) in [
("", "example.com"),
(" \t ", "example.com"),
(" web-01 ", "web-01"),
] {
let draft = HostDraft::new(name, " example.com ", 2222, "", AuthKind::Password, None)
.validated()
.unwrap();
assert_eq!(draft.name.as_ref(), expected);
assert_eq!(draft.address.as_ref(), "example.com");
assert_eq!(draft.user.as_ref(), super::super::DEFAULT_USER);
assert_eq!(draft.port, 2222);
}
}
#[test]
fn a_host_still_requires_an_address_with_or_without_a_name() {
for name in ["", " ", "web-01"] {
for address in ["", " \t "] {
let error = HostDraft::new(name, address, 22, "root", AuthKind::Password, None)
.validated()
.unwrap_err();
assert_eq!(error, HostDraftError::Address);
}
}
let error = HostDraft::new("", "example.com", 0, "root", AuthKind::Password, None)
.validated()
.unwrap_err();
assert_eq!(error, HostDraftError::Port);
}
#[test]
fn the_distributions_are_linux_and_the_rest_are_not() {
let not_linux: Vec<_> = HostOs::ALL
+10 -3
View File
@@ -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;
+227
View File
@@ -0,0 +1,227 @@
//! 设置 › 终端 › 本地终端 › Shell: a text field for the program and its
//! arguments across the row, with a menu of the shells installed here at
//! its end and the system's file picker beside it. Every change goes to the settings at
//! once; a local terminal takes it when it opens or restarts.
use gpui_kit::component::{
ActiveTheme as _, Icon, IconName, Sizable as _,
button::{Button, ButtonVariants as _},
h_flex,
input::{Input, InputEvent, InputState},
menu::{DropdownMenu as _, PopupMenuItem},
setting::RenderOptions,
};
use gpui_kit::*;
use crate::app::CatalogIcon;
use crate::i18n::{UiLocale, t};
use crate::terminal::{default_shell_name, installed_shells, quote_word};
use super::SettingsStore;
/// The field's text, which keeps the caret while the settings change under
/// it, and what the menu offers.
pub struct LocalShellEditor {
store: Entity<SettingsStore>,
input: Entity<InputState>,
/// What 自动检测 runs here, and the shells installed; `None` until the
/// look in the background comes back.
found: Option<FoundShells>,
_subscriptions: [Subscription; 3],
_scan: Task<()>,
}
struct FoundShells {
default: String,
installed: Vec<String>,
}
impl LocalShellEditor {
pub fn new(store: Entity<SettingsStore>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let shell = stored(&store, cx).unwrap_or_default();
let input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t!("settings.local_terminal.automatic_unknown"))
.default_value(shell)
});
let subscriptions = [
cx.subscribe(&input, |this, input, event: &InputEvent, cx| {
if matches!(event, InputEvent::Change) {
let shell = input.read(cx).value().to_string();
this.write(Some(shell), cx);
}
}),
// The field's own edits come back the same, but for the blanks
// around them; anything else (重置, the settings file read
// anew) shows in it.
cx.observe_in(&store, window, |this, store, window, cx| {
let shell = stored(&store, cx).unwrap_or_default();
if this.input.read(cx).value().trim() != shell {
this.input
.update(cx, |input, cx| input.set_value(shell, window, cx));
}
}),
cx.observe_global_in::<UiLocale>(window, |this, window, cx| {
this.show_placeholder(window, cx)
}),
];
let scan = cx.spawn_in(window, async move |this, cx| {
let found = cx
.background_spawn(async {
FoundShells {
default: default_shell_name(),
installed: installed_shells(),
}
})
.await;
this.update_in(cx, |this, window, cx| {
this.found = Some(found);
this.show_placeholder(window, cx);
cx.notify();
})
.ok();
});
Self {
store,
input,
found: None,
_subscriptions: subscriptions,
_scan: scan,
}
}
/// 「自动检测(/bin/zsh)」, once the default shell is known.
fn automatic_label(&self) -> SharedString {
match &self.found {
Some(found) => t!(
"settings.local_terminal.automatic",
shell = found.default.as_str()
),
None => t!("settings.local_terminal.automatic_unknown"),
}
}
/// The empty field says what it runs.
fn show_placeholder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let placeholder = self.automatic_label();
self.input.update(cx, |input, cx| {
input.set_placeholder(placeholder, window, cx)
});
}
/// Run `shell` from now on, `None` for the default: from the menu, the
/// file picker or 重置.
pub(super) fn choose(
&mut self,
shell: Option<String>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.input.update(cx, |input, cx| {
input.set_value(shell.clone().unwrap_or_default(), window, cx)
});
self.write(shell, cx);
}
/// Ask for the program with the system's picker.
fn browse(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let receiver = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: Some(t!("settings.local_terminal.browse_prompt")),
});
cx.spawn_in(window, async move |this, cx| {
let Ok(Ok(Some(paths))) = receiver.await else {
return;
};
let Some(path) = paths.into_iter().next() else {
return;
};
this.update_in(cx, |this, window, cx| {
this.choose(Some(quote_word(&path.to_string_lossy())), window, cx)
})
.ok();
})
.detach();
}
fn write(&self, shell: Option<String>, cx: &mut Context<Self>) {
self.store.update(cx, |store, cx| {
store.update(|settings| settings.local_terminal.shell = shell, cx)
});
}
}
/// The shell the settings hold, `None` for the default.
fn stored(store: &Entity<SettingsStore>, cx: &App) -> Option<String> {
store.read(cx).settings().local_terminal.shell
}
/// The row's control, under its title: the field across the row, with the
/// installed shells in a menu at its end, and 选择文件… beside it. The menu
/// checks the shell in use.
pub(super) fn local_shell_field(
editor: &Entity<LocalShellEditor>,
options: &RenderOptions,
cx: &App,
) -> Div {
let this = editor.read(cx);
let current = stored(&this.store, cx);
let automatic = this.automatic_label();
let installed = this
.found
.as_ref()
.map(|found| found.installed.clone())
.unwrap_or_default();
let (menu_editor, browse_editor) = (editor.downgrade(), editor.downgrade());
let menu = Button::new("local-shell-menu")
.ghost()
.xsmall()
.icon(IconName::ChevronDown)
.tooltip(t!("settings.local_terminal.installed"))
.dropdown_menu_with_anchor(Anchor::TopRight, move |menu, _, _| {
let item = |label: SharedString, shell: Option<String>| {
let editor = menu_editor.clone();
PopupMenuItem::new(label)
.checked(current == shell)
.on_click(move |_, window, cx| {
editor
.update(cx, |editor, cx| editor.choose(shell.clone(), window, cx))
.ok();
})
};
let mut menu = menu.item(item(automatic.clone(), None));
if !installed.is_empty() {
menu = menu.separator();
}
installed.iter().fold(menu, |menu, shell| {
menu.item(item(shell.clone().into(), Some(shell.clone())))
})
});
h_flex()
.w_full()
.gap_2()
.child(
Input::new(&this.input)
.id("local-shell")
.aria_label(t!("settings.local_terminal.shell"))
.with_size(options.size())
.font_family(cx.theme().mono_font_family.clone())
.suffix(menu)
.flex_1()
.min_w_0(),
)
.child(
Button::new("browse-local-shell")
.outline()
.with_size(options.size())
.icon(Icon::new(CatalogIcon::FileSearch))
.label(t!("settings.local_terminal.browse"))
.on_click(move |_, window, cx| {
browse_editor
.update(cx, |editor, cx| editor.browse(window, cx))
.ok();
}),
)
}
+3 -2
View File
@@ -3,6 +3,7 @@
mod app_theme;
mod apply;
mod highlight_rules;
mod local_shell_editor;
mod model;
mod settings_panel;
mod shortcuts_editor;
@@ -12,8 +13,8 @@ mod terminal_themes;
pub use apply::apply;
pub use model::{
AnalyticsSettings, AppSettings, Appearance, Choice, ExternalCliSettings, InterfaceLanguage,
NotificationSettings, TerminalFontSettings, TerminalHighlightSettings, TerminalThemeSettings,
UpdateSettings, WindowSettings,
LocalTerminalSettings, NotificationSettings, TerminalFontSettings, TerminalHighlightSettings,
TerminalThemeSettings, UpdateSettings, WindowSettings,
};
pub use settings_panel::{SettingsPanel, SettingsPanelEvent};
pub use store::{SettingsProblem, SettingsStore, SettingsStoreEvent};
+71 -1
View File
@@ -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,10 @@ pub struct AppSettings {
/// 外观 → 终端主题.
pub terminal_theme: TerminalThemeSettings,
pub terminal_font: TerminalFontSettings,
/// 终端 → 终端交互.
pub terminal_interaction: TerminalInteraction,
/// 终端 → 本地终端.
pub local_terminal: LocalTerminalSettings,
pub external_cli: ExternalCliSettings,
pub update: UpdateSettings,
/// 关于 → 隐私.
@@ -167,6 +171,16 @@ fn readable_rules<'de, D: Deserializer<'de>>(
.collect())
}
/// 终端 → 本地终端.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct LocalTerminalSettings {
/// The program new and restarted local terminals run, a path or a
/// command with any arguments, as typed. `None` is the system's default
/// shell, found anew each time.
pub shell: Option<String>,
}
/// 终端 → 通知: what a terminal may tell the user about while they look
/// elsewhere. Both on until the user turns them off.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -245,12 +259,37 @@ 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
/// file nor a typed value can give the terminal a size of zero.
pub fn normalized(mut self) -> Self {
self.terminal_font = self.terminal_font.normalized();
self.local_terminal.shell = self
.local_terminal
.shell
.map(|shell| shell.trim().to_string())
.filter(|shell| !shell.is_empty());
self
}
}
@@ -435,6 +474,7 @@ mod tests {
}
check::<InterfaceLanguage>();
check::<Appearance>();
check::<RightClick>();
assert_eq!(Appearance::from_key("sepia"), None);
}
@@ -461,6 +501,25 @@ mod tests {
assert!(!settings.show_hidden.local);
}
#[test]
fn local_terminals_run_the_default_shell_unless_the_file_names_one() {
let settings: AppSettings = serde_json::from_str("{}").unwrap();
assert_eq!(settings.local_terminal.shell, None);
let settings: AppSettings =
serde_json::from_str(r#"{"local_terminal":{"shell":" /bin/zsh -l "}}"#).unwrap();
assert_eq!(
settings.normalized().local_terminal.shell.as_deref(),
Some("/bin/zsh -l")
);
let blank = AppSettings {
local_terminal: LocalTerminalSettings {
shell: Some(" ".into()),
},
..AppSettings::default()
};
assert_eq!(blank.normalized(), AppSettings::default());
}
#[test]
fn notifications_are_on_unless_the_file_says_otherwise() {
let settings: AppSettings = serde_json::from_str("{}").unwrap();
@@ -587,6 +646,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();
+71 -1
View File
@@ -35,6 +35,7 @@ use crate::terminal::{
use crate::update::{Phase, Tone, UpdateSnapshot, UpdateStep, Updater, build_info, platform};
use super::highlight_rules::HighlightRulesEditor;
use super::local_shell_editor::{LocalShellEditor, local_shell_field};
use super::shortcuts_editor::{ShortcutsEditor, shortcut_groups};
use super::terminal_themes::{app_follows_item, terminal_theme_item};
use super::{AppSettings, Choice, NotificationSettings, SettingsStore, WindowSettings};
@@ -63,6 +64,8 @@ pub struct SettingsPanel {
highlight_rules: Entity<HighlightRulesEditor>,
/// 键盘快捷键, which knows which shortcut is taking new keys.
shortcuts: Entity<ShortcutsEditor>,
/// 终端 › 本地终端's field, with the shells installed here.
local_shell: Entity<LocalShellEditor>,
/// The monospace families to choose the terminal font from; empty until
/// the scan in the background comes back.
font_families: &'static [SharedString],
@@ -72,7 +75,7 @@ pub struct SettingsPanel {
shown_category: Rc<Cell<usize>>,
focus_handle: FocusHandle,
tab_group: Option<WeakEntity<TabGroup>>,
_subscriptions: [Subscription; 4],
_subscriptions: [Subscription; 5],
_font_scan: Task<()>,
}
@@ -86,9 +89,11 @@ impl SettingsPanel {
) -> Self {
let highlight_rules = cx.new(|cx| HighlightRulesEditor::new(store.clone(), window, cx));
let shortcuts = cx.new(|cx| ShortcutsEditor::new(store.clone(), window, cx));
let local_shell = cx.new(|cx| LocalShellEditor::new(store.clone(), window, cx));
let subscriptions = [
cx.observe(&store, |_, _, cx| cx.notify()),
cx.observe(&shortcuts, |_, _, cx| cx.notify()),
cx.observe(&local_shell, |_, _, cx| cx.notify()),
cx.observe(&integration, |_, _, cx| cx.notify()),
cx.observe(&updater, |_, _, cx| cx.notify()),
];
@@ -110,6 +115,7 @@ impl SettingsPanel {
updater,
highlight_rules,
shortcuts,
local_shell,
font_families: &[],
shown_category: Rc::default(),
focus_handle: cx.focus_handle(),
@@ -385,10 +391,74 @@ fn terminal_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
})
.keywords(keywords(t!("settings.terminal.preview_keywords"))),
]),
interaction_group(store),
notification_group(store),
local_terminal_group(panel),
]
}
/// 终端 → 本地终端: the program a local terminal runs. The row stacks, so a
/// path with its arguments has the page's width.
fn local_terminal_group(panel: &SettingsPanel) -> SettingGroup {
let (editor, reset) = (panel.local_shell.clone(), panel.local_shell.clone());
let store = panel.store.clone();
SettingGroup::new()
.title(t!("settings.local_terminal.title"))
.items([SettingItem::new(
t!("settings.local_terminal.shell"),
SettingField::render(move |options, _, cx| local_shell_field(&editor, options, cx))
.on_reset(
move |cx| store.read(cx).settings().local_terminal.shell.is_some(),
move |window, cx| {
reset.update(cx, |editor, cx| editor.choose(None, window, cx));
},
),
)
.description(t!("settings.local_terminal.shell_description"))
.layout(Axis::Vertical)
.keywords(keywords(t!("settings.local_terminal.keywords")))])
}
/// 终端 → 终端交互: 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
View File
@@ -6,7 +6,7 @@ use anyhow::{Result, anyhow, bail};
use futures::StreamExt as _;
use russh_sftp::{
client::{RawSftpSession, error::Error},
protocol::{FileAttributes, OpenFlags, Packet, StatusCode},
protocol::{FileAttributes, OpenFlags, StatusCode},
};
use std::{
pin::Pin,
@@ -34,25 +34,29 @@ pub(crate) trait RemoteFs {
async fn open_read(&self, path: &RemotePath) -> Result<String>;
/// Up to `len` bytes at `offset`; may return fewer. `None` at end of file.
async fn read(&self, handle: &str, offset: u64, len: u32) -> Result<Option<Vec<u8>>>;
async fn sync(&self, handle: &str) -> Result<()>;
async fn write(&self, handle: &str, offset: u64, bytes: Vec<u8>) -> Result<()>;
async fn close(&self, handle: &str) -> Result<()>;
/// Set the modification time and permission bits of a closed file by its
/// path (`setstat`), as WinSCP does: a bastion host has been seen to
/// refuse `fsetstat` on a file still open.
async fn attributes(
&self,
handle: &str,
path: &RemotePath,
modified: Option<u32>,
permissions: Option<u32>,
) -> Result<()>;
async fn mkdir(&self, path: &RemotePath) -> Result<()>;
async fn symlink(&self, target: &str, path: &RemotePath) -> Result<()>;
async fn readlink(&self, path: &RemotePath) -> Result<String>;
async fn rename(&self, from: &RemotePath, to: &RemotePath, replace: bool) -> Result<()>;
/// A plain `rename`, which fails when `to` is there: replacing a file
/// moves the old one aside first, as WinSCP never sends
/// `posix-rename@openssh.com` either.
async fn rename(&self, from: &RemotePath, to: &RemotePath) -> Result<()>;
async fn remove(&self, path: &RemotePath) -> Result<()>;
/// Remove an empty directory.
async fn rmdir(&self, path: &RemotePath) -> Result<()>;
/// Set the permission bits of a path (`setstat`, which follows links).
async fn set_permissions(&self, path: &RemotePath, permissions: u32) -> Result<()>;
fn atomic_replace(&self) -> bool;
}
pub(crate) struct SftpClient {
@@ -61,8 +65,6 @@ pub(crate) struct SftpClient {
/// Turns true once the server side of the SFTP stream has ended.
closed: watch::Receiver<bool>,
fingerprint: String,
atomic_replace: bool,
fsync: bool,
}
impl SftpClient {
pub async fn connect(
@@ -92,21 +94,14 @@ impl SftpClient {
if version.version != 3 {
bail!(t!("sftp.client.no_v3"));
}
let atomic_replace = version
.extensions
.get("posix-rename@openssh.com")
.is_some_and(|v| v == "1");
let fsync = version
.extensions
.get("fsync@openssh.com")
.is_some_and(|v| v == "1");
// No extension is used, even when offered: a bastion host that offers
// `fsync@openssh.com` has been seen to end the whole session on it.
// WinSCP sends none of them for a transfer.
Ok(Self {
raw,
ssh,
closed,
fingerprint,
atomic_replace,
fsync,
})
}
/// Only the Unix tests have a local `sftp-server` to talk to.
@@ -281,12 +276,6 @@ impl RemoteFs for SftpClient {
Err(error) => Err(error.into()),
}
}
async fn sync(&self, handle: &str) -> Result<()> {
if self.fsync {
self.raw.fsync(handle).await?;
}
Ok(())
}
async fn write(&self, handle: &str, offset: u64, bytes: Vec<u8>) -> Result<()> {
self.raw.write(handle, offset, bytes).await?;
Ok(())
@@ -298,13 +287,13 @@ impl RemoteFs for SftpClient {
async fn attributes(
&self,
handle: &str,
path: &RemotePath,
modified: Option<u32>,
permissions: Option<u32>,
) -> Result<()> {
self.raw
.fsetstat(
handle,
.setstat(
path.as_str(),
FileAttributes {
mtime: modified,
atime: modified,
@@ -337,25 +326,8 @@ impl RemoteFs for SftpClient {
.filename,
)
}
async fn rename(&self, from: &RemotePath, to: &RemotePath, replace: bool) -> Result<()> {
if replace {
let mut payload = Vec::new();
for value in [from.as_str(), to.as_str()] {
payload.extend_from_slice(&(value.len() as u32).to_be_bytes());
payload.extend_from_slice(value.as_bytes());
}
match self
.raw
.extended("posix-rename@openssh.com", payload)
.await?
{
Packet::Status(status) if status.status_code == StatusCode::Ok => {}
Packet::Status(status) => return Err(Error::Status(status).into()),
_ => bail!(t!("sftp.client.bad_rename_reply")),
}
} else {
self.raw.rename(from.as_str(), to.as_str()).await?;
}
async fn rename(&self, from: &RemotePath, to: &RemotePath) -> Result<()> {
self.raw.rename(from.as_str(), to.as_str()).await?;
Ok(())
}
async fn remove(&self, path: &RemotePath) -> Result<()> {
@@ -381,9 +353,6 @@ impl RemoteFs for SftpClient {
.await?;
Ok(())
}
fn atomic_replace(&self) -> bool {
self.atomic_replace
}
}
fn metadata(attrs: &FileAttributes) -> FileMetadata {
// Compare the whole type field: russh-sftp's `is_dir()` tests one bit,
+1 -4
View File
@@ -381,10 +381,7 @@ pub(crate) async fn write_in_place<F: RemoteFs>(
let message = format!("{error:#}");
error.context(SaveFailure::Interrupted(message))
};
let written = match write_all(fs, &handle, bytes).await {
Ok(()) => fs.sync(&handle).await,
Err(error) => Err(error),
};
let written = write_all(fs, &handle, bytes).await;
let close = fs.close(&handle).await;
written.map_err(interrupted)?;
close.map_err(interrupted)?;
+2
View File
@@ -11,10 +11,12 @@ mod download;
mod journal;
mod meter;
mod operations;
mod put;
mod speed;
mod sync;
mod upload;
mod worker;
pub use put::put_bytes;
pub use worker::SshSftpTransportProvider;
#[cfg(all(test, unix))]
mod protocol_tests;
+61 -2
View File
@@ -693,6 +693,18 @@ pub enum Place {
Desktop,
Documents,
Downloads,
/// A Windows drive by its letter, as WinSCP lists them: `C: 本地磁盘`.
Drive(char, DriveKind),
}
/// What a Windows drive is, which its title says.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DriveKind {
Local,
Removable,
Network,
Optical,
Other,
}
impl Place {
@@ -702,6 +714,16 @@ impl Place {
Self::Desktop => t!("sftp.place.desktop"),
Self::Documents => t!("sftp.place.documents"),
Self::Downloads => t!("sftp.place.downloads"),
Self::Drive(letter, kind) => {
let drive = format!("{letter}:");
match kind {
DriveKind::Local => t!("sftp.place.drive_local", drive = drive),
DriveKind::Removable => t!("sftp.place.drive_removable", drive = drive),
DriveKind::Network => t!("sftp.place.drive_network", drive = drive),
DriveKind::Optical => t!("sftp.place.drive_optical", drive = drive),
DriveKind::Other => drive.into(),
}
}
}
}
}
@@ -710,8 +732,9 @@ impl Place {
pub trait LocalDirectoryProvider: Send + Sync + 'static {
fn home(&self) -> PathBuf;
fn list(&self, path: &Path) -> Result<DirectoryListing>;
/// Well-known folders for the 目录列表 select, home first. Looked up
/// from the platform, without touching the file system.
/// Well-known folders for the 目录列表 select, home first, then the
/// drives on Windows. Read again with each listing, on the same
/// background executor, so a drive plugged in since shows up.
fn places(&self) -> Vec<(Place, PathBuf)> {
Vec::new()
}
@@ -746,6 +769,41 @@ pub trait LocalDirectoryProvider: Send + Sync + 'static {
) -> Result<FileStamp>;
}
pub type SharedLocalDirectoryProvider = std::sync::Arc<dyn LocalDirectoryProvider>;
/// Every drive letter in use, `A:\` to `Z:\`: without them the local pane
/// could not leave the drive it started on.
#[cfg(windows)]
fn drives() -> Vec<(Place, PathBuf)> {
use windows_sys::Win32::{
Storage::FileSystem::{GetDriveTypeW, GetLogicalDrives},
System::WindowsProgramming::{
DRIVE_CDROM, DRIVE_FIXED, DRIVE_RAMDISK, DRIVE_REMOTE, DRIVE_REMOVABLE,
},
};
// SAFETY: takes nothing and returns a bit mask, bit 0 for `A:`.
let letters = unsafe { GetLogicalDrives() };
(b'A'..=b'Z')
.filter(|letter| letters & (1 << (letter - b'A')) != 0)
.map(|letter| {
let root = format!("{}:\\", char::from(letter));
let wide: Vec<u16> = root.encode_utf16().chain([0]).collect();
// SAFETY: `wide` is a null-terminated UTF-16 string that outlives
// the call.
let kind = match unsafe { GetDriveTypeW(wide.as_ptr()) } {
DRIVE_FIXED | DRIVE_RAMDISK => DriveKind::Local,
DRIVE_REMOVABLE => DriveKind::Removable,
DRIVE_REMOTE => DriveKind::Network,
DRIVE_CDROM => DriveKind::Optical,
_ => DriveKind::Other,
};
(Place::Drive(char::from(letter), kind), PathBuf::from(root))
})
.collect()
}
#[cfg(not(windows))]
fn drives() -> Vec<(Place, PathBuf)> {
Vec::new()
}
#[derive(Default)]
pub struct SystemLocalDirectoryProvider;
impl LocalDirectoryProvider for SystemLocalDirectoryProvider {
@@ -761,6 +819,7 @@ impl LocalDirectoryProvider for SystemLocalDirectoryProvider {
]
.into_iter()
.filter_map(|(place, path)| Some((place, path?)))
.chain(drives())
.collect()
}
fn list(&self, path: &Path) -> Result<DirectoryListing> {
+1 -1
View File
@@ -18,7 +18,7 @@ pub(crate) async fn run<F: RemoteFs>(fs: &F, operation: &RemoteOperation) -> Res
if fs.metadata(to).await?.is_some() {
bail!(t!("sftp.error.exists", name = to.file_name()));
}
fs.rename(from, to, false).await
fs.rename(from, to).await
}
RemoteOperation::CreateDirectory { path } => {
if fs.metadata(path).await?.is_some() {
+135
View File
@@ -29,6 +29,7 @@ struct TestServer {
interrupts: Arc<AtomicUsize>,
hangup: Arc<tokio::sync::Notify>,
unsupported: bool,
bastion: bool,
}
struct Handler {
directory: PathBuf,
@@ -39,6 +40,8 @@ struct Handler {
/// Ends an SFTP session while nothing is asked of it.
hangup: Arc<tokio::sync::Notify>,
unsupported: bool,
/// Ends the SFTP session on what a bastion host refuses.
bastion: bool,
channels: HashMap<ChannelId, russh::Channel<server::Msg>>,
}
impl server::Server for TestServer {
@@ -52,6 +55,7 @@ impl server::Server for TestServer {
interrupts: self.interrupts.clone(),
hangup: self.hangup.clone(),
unsupported: self.unsupported,
bastion: self.bastion,
channels: HashMap::new(),
}
}
@@ -120,6 +124,7 @@ impl server::Handler for Handler {
let directory = self.directory.clone();
let interrupts = self.interrupts.clone();
let hangup = self.hangup.clone();
let bastion = self.bastion;
tokio::spawn(async move {
let executable = if cfg!(target_os = "macos") {
"/usr/libexec/sftp-server"
@@ -149,6 +154,9 @@ impl server::Handler for Handler {
}
let mut bytes = vec![0; size as usize];
reader.read_exact(&mut bytes).await?;
if bastion && refused_by_bastion(&bytes) {
return Err::<(), _>(std::io::Error::other("refused by the bastion host"));
}
// SSH_FXP_WRITE (6) for uploads, SSH_FXP_READ (5) for downloads.
if matches!(bytes.first(), Some(&5) | Some(&6)) {
writes += 1;
@@ -192,7 +200,27 @@ impl Drop for Running {
self.task.abort();
}
}
/// What a bastion host in front of OpenSSH was seen to refuse, though it
/// passes on OpenSSH's offer of extensions: `SSH_FXP_FSETSTAT` (10), which it
/// answered with a failure, and `fsync@openssh.com`, on which it ended the
/// session. Here every `SSH_FXP_EXTENDED` (200) is refused: like WinSCP,
/// ShellRS uses none to transfer or save a file.
fn refused_by_bastion(packet: &[u8]) -> bool {
matches!(packet.first(), Some(10 | 200))
}
async fn server(directory: PathBuf, interrupts: usize, unsupported: bool) -> Option<Running> {
serve(directory, interrupts, unsupported, false).await
}
/// A server that ends the SFTP session on what a bastion host refuses.
async fn bastion_server(directory: PathBuf) -> Option<Running> {
serve(directory, 0, false, true).await
}
async fn serve(
directory: PathBuf,
interrupts: usize,
unsupported: bool,
bastion: bool,
) -> Option<Running> {
let listener = match tokio::net::TcpListener::bind(("127.0.0.1", 0)).await {
Ok(l) => l,
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
@@ -215,6 +243,7 @@ async fn server(directory: PathBuf, interrupts: usize, unsupported: bool) -> Opt
interrupts: interrupts.clone(),
hangup: hangup.clone(),
unsupported,
bastion,
};
let key = russh::keys::PrivateKey::random(
&mut russh::keys::key::safe_rng(),
@@ -696,3 +725,109 @@ fn scp_style_transfers_land_where_scp_would_put_them() {
assert!(!remote.join("missing").exists());
});
}
/// Behind a bastion host that refuses `fsetstat` and extensions, an upload,
/// an upload over that file and an editor's save all finish on the one
/// connection, and the upload keeps the local file's modification time.
#[test]
fn uploads_and_saves_get_past_a_bastion_host_that_refuses_fsetstat_and_extensions() {
runtime().block_on(async {
let temp = tempfile::tempdir().unwrap();
let remote = temp.path().join("remote");
std::fs::create_dir(&remote).unwrap();
let Some(server) = bastion_server(remote.clone()).await else {
return;
};
let worker = worker(server.port, temp.path());
let home = loop {
match next(&worker).await {
SftpEvent::Connected { home } => break home,
SftpEvent::Disconnected(e) => panic!("connect failed: {e}"),
_ => {}
}
};
let source = temp.path().join("README.md");
let modified = std::time::UNIX_EPOCH + Duration::from_secs(1_500_000_000);
let write_source = |bytes: &[u8]| {
std::fs::write(&source, bytes).unwrap();
std::fs::File::options()
.write(true)
.open(&source)
.unwrap()
.set_modified(modified)
.unwrap();
};
let upload =
|| SftpCommand::Upload(UploadRequest::new(vec![source.clone()], home.clone()).unwrap());
let uploaded = remote.join("README.md");
write_source(b"first");
let done = transfer(&worker, upload()).await;
assert_eq!(done.phase(), TransferPhase::Completed);
assert_eq!(std::fs::read(&uploaded).unwrap(), b"first");
assert_eq!(
std::fs::metadata(&uploaded).unwrap().modified().unwrap(),
modified
);
write_source(b"second");
worker.commands.send(upload()).await.unwrap();
loop {
match next(&worker).await {
SftpEvent::Question(question) => {
assert_eq!(question.kind(), TransferQuestionKind::Conflict);
worker
.commands
.send(SftpCommand::Answer {
request_id: question.id(),
answer: TransferAnswer::new(TransferChoice::Overwrite, false),
})
.await
.unwrap();
}
SftpEvent::Progress(p) if p.phase() == TransferPhase::Completed => {
assert_eq!(p.succeeded(), 1);
break;
}
SftpEvent::Progress(p)
if matches!(
p.phase(),
TransferPhase::Stopped | TransferPhase::Reconnecting
) =>
{
panic!("the upload over the file broke off: {p:?}")
}
_ => {}
}
}
assert_eq!(std::fs::read(&uploaded).unwrap(), b"second");
assert_eq!(
std::fs::metadata(&uploaded).unwrap().modified().unwrap(),
modified
);
worker
.commands
.send(SftpCommand::WriteFile {
request_id: 7,
path: home.join("README.md").unwrap(),
bytes: b"edited".to_vec(),
expected: None,
})
.await
.unwrap();
loop {
match next(&worker).await {
SftpEvent::FileWritten { request_id, result } => {
assert_eq!(request_id, 7);
result.unwrap();
break;
}
SftpEvent::Disconnected(e) => panic!("the save broke off: {e}"),
_ => {}
}
}
assert_eq!(std::fs::read(&uploaded).unwrap(), b"edited");
assert_eq!(server.connections.load(Ordering::SeqCst), 1);
});
}
+115
View File
@@ -0,0 +1,115 @@
//! Write one file on a host over SFTP, for a screenshot pasted into a
//! remote terminal. A connection of its own: the terminal's channel stays
//! the shell. Nobody is there to answer a question, so a host not trusted
//! yet or a password not saved fails at once.
use crate::connection::{ConnectionPromptKind, ConnectionPromptReply};
use crate::host::HostLogin;
use crate::i18n::t;
use gpui_kit::SharedString;
use super::{RemotePath, SftpCommand, SftpEvent, SharedSftpTransportProvider};
/// Put `bytes` at `path` on `login`'s host. Blocks, so it belongs on a
/// background thread.
pub fn put_bytes(
provider: &SharedSftpTransportProvider,
login: &HostLogin,
path: &RemotePath,
bytes: Vec<u8>,
) -> Result<(), SharedString> {
let transport = provider.create(login);
let (commands, command_receiver) = async_channel::unbounded();
let (event_sender, events) = async_channel::unbounded();
let worker = std::thread::Builder::new()
.name("shellrs-paste-sftp".into())
.spawn(move || transport.run(command_receiver, event_sender))
.map_err(|error| {
t!(
"terminal.paste.image_upload_failed",
error = error.to_string()
)
})?;
let result = write_once(&commands, &events, path, bytes);
let _ = commands.send_blocking(SftpCommand::Shutdown);
let _ = worker.join();
result
}
fn write_once(
commands: &async_channel::Sender<SftpCommand>,
events: &async_channel::Receiver<SftpEvent>,
path: &RemotePath,
bytes: Vec<u8>,
) -> Result<(), SharedString> {
loop {
match events.recv_blocking() {
Ok(SftpEvent::Connected { .. }) => break,
Ok(SftpEvent::Prompt(prompt)) => {
refuse(commands, &prompt);
return Err(prompt_error(&prompt));
}
Ok(SftpEvent::Disconnected(reason)) => {
return Err(t!("terminal.paste.image_upload_failed", error = reason));
}
Ok(_) => {}
Err(_) => {
return Err(t!(
"terminal.paste.image_upload_failed",
error = t!("cli.transfer.sftp_ended")
));
}
}
}
let _ = commands.send_blocking(SftpCommand::WriteFile {
request_id: 1,
path: path.clone(),
bytes,
expected: None,
});
loop {
match events.recv_blocking() {
Ok(SftpEvent::FileWritten { result, .. }) => {
return result.map(|_| ()).map_err(|error| {
t!(
"terminal.paste.image_upload_failed",
error = error.to_string()
)
});
}
Ok(SftpEvent::Prompt(prompt)) => {
refuse(commands, &prompt);
return Err(prompt_error(&prompt));
}
Ok(SftpEvent::Disconnected(reason)) => {
return Err(t!("terminal.paste.image_upload_failed", error = reason));
}
Ok(_) => {}
Err(_) => {
return Err(t!(
"terminal.paste.image_upload_failed",
error = t!("cli.transfer.sftp_ended")
));
}
}
}
}
fn refuse(
commands: &async_channel::Sender<SftpCommand>,
prompt: &crate::connection::ConnectionPrompt,
) {
let _ = commands.send_blocking(SftpCommand::PromptReply {
request_id: prompt.request_id(),
reply: ConnectionPromptReply::Cancel,
});
}
fn prompt_error(prompt: &crate::connection::ConnectionPrompt) -> SharedString {
match prompt.kind() {
ConnectionPromptKind::UnknownHost(_) => t!("cli.transfer.host_key_unknown"),
ConnectionPromptKind::HostKeyChanged(_) => t!("cli.transfer.host_key_changed"),
ConnectionPromptKind::Authentication(_) => t!("cli.transfer.missing_credential"),
}
}
+74 -52
View File
@@ -41,11 +41,10 @@ struct Remote {
reads: Cell<usize>,
short_reads: Cell<bool>,
renames: Cell<usize>,
atomic: bool,
denied: RefCell<Option<String>>,
}
impl Remote {
fn new(atomic: bool) -> Self {
fn new() -> Self {
Self {
nodes: RefCell::new(BTreeMap::new()),
fault: Cell::new(None),
@@ -55,7 +54,6 @@ impl Remote {
reads: Cell::new(0),
short_reads: Cell::new(false),
renames: Cell::new(0),
atomic,
denied: RefCell::new(None),
}
}
@@ -225,9 +223,6 @@ impl RemoteFs for Remote {
self.writes.set(self.writes.get() + 1);
self.fail(Fault::Write(self.writes.get()))
}
async fn sync(&self, _: &str) -> Result<()> {
Ok(())
}
async fn close(&self, _: &str) -> Result<()> {
if self.fault.get() == Some(Fault::ChangeTargetOnClose) {
self.fault.set(None);
@@ -241,12 +236,12 @@ impl RemoteFs for Remote {
}
async fn attributes(
&self,
handle: &str,
path: &RemotePath,
modified: Option<u32>,
permissions: Option<u32>,
) -> Result<()> {
let mut nodes = self.nodes.borrow_mut();
let n = nodes.get_mut(handle).unwrap();
let n = nodes.get_mut(path.as_str()).unwrap();
n.metadata = FileMetadata::new(
EntryKind::File,
n.data.len() as u64,
@@ -283,13 +278,13 @@ impl RemoteFs for Remote {
async fn readlink(&self, path: &RemotePath) -> Result<String> {
Ok(self.nodes.borrow()[path.as_str()].link.clone())
}
async fn rename(&self, from: &RemotePath, to: &RemotePath, replace: bool) -> Result<()> {
async fn rename(&self, from: &RemotePath, to: &RemotePath) -> Result<()> {
if self.fault.get() == Some(Fault::RejectRename(self.renames.get() + 1)) {
self.fault.set(None);
bail!("publish denied");
}
let mut nodes = self.nodes.borrow_mut();
if !replace && nodes.contains_key(to.as_str()) {
if nodes.contains_key(to.as_str()) {
bail!("target exists");
}
let node = nodes
@@ -331,9 +326,6 @@ impl RemoteFs for Remote {
);
Ok(())
}
fn atomic_replace(&self) -> bool {
self.atomic
}
}
impl Remote {
/// The path a link chain ends at, as a server's `open` follows it.
@@ -496,7 +488,7 @@ fn recursive_upload_deduplicates_sources_and_preserves_empty_directories_and_lin
)
.await
.unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
run(&mut batch, &remote, &answers.control).await.unwrap();
assert_eq!(remote.bytes("/dest/中文 目录/large.bin"), data);
assert_eq!(remote.bytes("/dest/中文 目录/零 字节"), Vec::<u8>::new());
@@ -540,7 +532,7 @@ fn winscp_style_upload_finishes_without_a_verification_phase() {
&answers.control,
)
.await;
let remote = Remote::new(true);
let remote = Remote::new();
run(&mut batch, &remote, &answers.control).await.unwrap();
tokio::task::yield_now().await;
@@ -583,7 +575,7 @@ fn resume_uses_remote_filepart_size_and_preserves_old_target() {
let data = vec![71; 150_000];
std::fs::write(&source, &data).unwrap();
let journal = Journal::new(tmp.path().join("journal"));
let remote = Remote::new(false);
let remote = Remote::new();
remote.file("/dest/file", b"old file intact");
remote.fault.set(Some(Fault::Write(2)));
let answers = Answers::new(vec![TransferChoice::Overwrite]);
@@ -633,7 +625,7 @@ fn filepart_larger_than_source_requires_explicit_restart() {
let source = tmp.path().join("file");
std::fs::write(&source, vec![1; 100_000]).unwrap();
let journal = Journal::new(tmp.path().join("journal"));
let remote = Remote::new(true);
let remote = Remote::new();
remote.fault.set(Some(Fault::Write(2)));
let answers = Answers::new(vec![]);
let mut first = batch(&source, journal.clone(), &answers.control).await;
@@ -663,7 +655,7 @@ fn winscp_style_resume_does_not_prevalidate_the_source_version() {
let data = vec![1; 100_000];
std::fs::write(&source, &data).unwrap();
let journal = Journal::new(tmp.path().join("journal"));
let remote = Remote::new(true);
let remote = Remote::new();
remote.fault.set(Some(Fault::Write(2)));
let answers = Answers::new(vec![]);
let mut first = batch(&source, journal.clone(), &answers.control).await;
@@ -700,7 +692,7 @@ fn winscp_style_resume_detects_filepart_without_a_local_record() {
let source = tmp.path().join("file");
let data = vec![3; 100_000];
std::fs::write(&source, &data).unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
remote.file("/dest/file.filepart", &data[..32_768]);
let answers = Answers::new(vec![TransferChoice::Resume]);
let mut upload = batch(
@@ -724,17 +716,12 @@ fn winscp_style_resume_detects_filepart_without_a_local_record() {
#[test]
fn lost_replies_at_backup_publish_and_close_are_reconciled() {
runtime().block_on(async {
for (atomic, fault) in [
(true, Fault::Rename(1)),
(false, Fault::Rename(1)),
(false, Fault::Rename(2)),
(false, Fault::Close),
] {
for fault in [Fault::Rename(1), Fault::Rename(2), Fault::Close] {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let source = tmp.path().join("file");
std::fs::write(&source, b"new contents").unwrap();
let journal = Journal::new(tmp.path().join("journal"));
let remote = Remote::new(atomic);
let remote = Remote::new();
remote.file("/dest/file", b"original");
remote.fault.set(Some(fault));
let answers = Answers::new(vec![TransferChoice::Overwrite]);
@@ -756,7 +743,7 @@ fn cancellation_retains_journal_and_skip_does_not_overwrite() {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let source = tmp.path().join("file");
std::fs::write(&source, b"new").unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
remote.file("/dest/file", b"old");
let answers = Answers::new(vec![TransferChoice::Skip]);
let journal = Journal::new(tmp.path().join("journal"));
@@ -783,7 +770,7 @@ fn directory_permission_error_is_reported_as_failure() {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let source = tmp.path().join("blocked");
std::fs::create_dir(&source).unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
*remote.denied.borrow_mut() = Some("/dest/blocked".into());
let answers = Answers::new(vec![TransferChoice::Skip]);
let mut batch = batch(
@@ -842,7 +829,7 @@ fn retrying_an_unreadable_folder_adds_only_its_files_to_the_upload() {
set_mode(0o755);
assert_eq!(batch.meter.progress.total_bytes(), 10_000);
let remote = Remote::new(true);
let remote = Remote::new();
run(&mut batch, &remote, &answers.control).await.unwrap();
assert_eq!(batch.meter.progress.failed(), 0);
assert_eq!(batch.meter.progress.total_bytes(), 10_300);
@@ -859,7 +846,7 @@ fn retrying_an_unreadable_folder_adds_only_its_files_to_the_download() {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let out = tmp.path().join("out");
std::fs::create_dir(&out).unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
remote.nodes.borrow_mut().insert(
"/srv/folder".into(),
Node {
@@ -939,6 +926,41 @@ fn paths_and_journals_reject_unsafe_identity() {
});
}
/// Drives are titled as WinSCP does: the letter, then what kind it is.
#[test]
fn drives_are_titled_by_letter_and_kind() {
assert_eq!(Place::Drive('C', DriveKind::Local).title(), "C: 本地磁盘");
assert_eq!(
Place::Drive('E', DriveKind::Removable).title(),
"E: 可移动磁盘"
);
assert_eq!(
Place::Drive('Z', DriveKind::Network).title(),
"Z: 网络驱动器"
);
assert_eq!(
Place::Drive('F', DriveKind::Optical).title(),
"F: CD 驱动器"
);
assert_eq!(Place::Drive('G', DriveKind::Other).title(), "G:");
}
/// Without the drives, the local pane could not leave the one it started on.
#[cfg(windows)]
#[test]
fn local_listing_places_include_the_system_drive() {
let system = std::env::var("SystemDrive").unwrap();
let letter = system.chars().next().unwrap().to_ascii_uppercase();
let places = SystemLocalDirectoryProvider.places();
assert!(
places.contains(&(
Place::Drive(letter, DriveKind::Local),
std::path::PathBuf::from(format!("{letter}:\\"))
)),
"{places:?}"
);
}
/// On Windows std canonicalizes to `\\?\C:\…`, which the pane showed as is.
#[test]
fn local_listing_reports_the_path_people_type() {
@@ -1066,7 +1088,7 @@ fn failed_compatibility_publish_restores_old_target_and_reports_failure() {
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("file");
std::fs::write(&source, b"new").unwrap();
let remote = Remote::new(false);
let remote = Remote::new();
remote.file("/dest/file", b"original");
remote.fault.set(Some(Fault::RejectRename(2)));
let answers = Answers::new(vec![TransferChoice::Overwrite, TransferChoice::Skip]);
@@ -1097,7 +1119,7 @@ fn target_changed_during_upload_requires_another_conflict_answer() {
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("file");
std::fs::write(&source, b"new").unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
remote.file("/dest/file", b"original");
remote.fault.set(Some(Fault::ChangeTargetOnClose));
let answers = Answers::new(vec![TransferChoice::Overwrite, TransferChoice::Skip]);
@@ -1140,7 +1162,7 @@ fn target_removed_during_upload_still_requires_confirmation() {
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("file");
std::fs::write(&source, b"new").unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
remote.file("/dest/file", b"old");
remote.fault.set(Some(Fault::RemoveTargetOnClose));
let answers = Answers::new(vec![TransferChoice::Overwrite, TransferChoice::Skip]);
@@ -1170,7 +1192,7 @@ fn remote_path(path: &str) -> RemotePath {
#[test]
fn deleting_a_tree_removes_links_but_never_what_they_point_to() {
runtime().block_on(async {
let remote = Remote::new(true);
let remote = Remote::new();
remote.dir("/keep", 0o755);
remote.file("/keep/important", b"data");
remote.dir("/gone", 0o755);
@@ -1198,7 +1220,7 @@ fn deleting_a_tree_removes_links_but_never_what_they_point_to() {
#[test]
fn recursive_permissions_skip_links_and_can_keep_directories_searchable() {
runtime().block_on(async {
let remote = Remote::new(true);
let remote = Remote::new();
remote.dir("/site", 0o700);
remote.dir("/site/css", 0o700);
remote.file("/site/css/a.css", b"a");
@@ -1231,7 +1253,7 @@ fn recursive_permissions_skip_links_and_can_keep_directories_searchable() {
#[test]
fn rename_and_create_refuse_existing_names() {
runtime().block_on(async {
let remote = Remote::new(true);
let remote = Remote::new();
remote.file("/a", b"a");
remote.file("/b", b"b");
let error = super::operations::run(
@@ -1329,7 +1351,7 @@ fn download_copies_trees_links_and_empty_directories_in_order() {
for short_reads in [false, true] {
let out = tmp.path().join(format!("out-{short_reads}"));
std::fs::create_dir(&out).unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
remote.short_reads.set(short_reads);
remote.dir("/srv/中文 目录", 0o755);
remote.file("/srv/中文 目录/large.bin", &data);
@@ -1385,7 +1407,7 @@ fn download_resumes_from_the_partial_file_after_a_lost_connection() {
runtime().block_on(async {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let data: Vec<u8> = (0..300_000).map(|i| (i % 253) as u8).collect();
let remote = Remote::new(true);
let remote = Remote::new();
remote.file("/big", &data);
remote.fault.set(Some(Fault::Read(5)));
let journal = DownloadJournal::new(tmp.path().join("journal"));
@@ -1440,7 +1462,7 @@ fn download_asks_before_overwriting_and_keeps_the_replaced_permissions() {
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
}
let remote = Remote::new(true);
let remote = Remote::new();
remote.file("/file", b"new contents");
let journal = DownloadJournal::new(tmp.path().join("journal"));
let answers = Answers::new(vec![TransferChoice::Skip]);
@@ -1484,7 +1506,7 @@ fn download_asks_before_overwriting_and_keeps_the_replaced_permissions() {
fn a_changed_remote_file_is_not_resumed_and_discard_cleans_up() {
runtime().block_on(async {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
remote.file("/file", &vec![7; 200_000]);
remote.fault.set(Some(Fault::Read(4)));
let journal = DownloadJournal::new(tmp.path().join("journal"));
@@ -1554,7 +1576,7 @@ fn a_changed_remote_file_is_not_resumed_and_discard_cleans_up() {
fn download_cancellation_keeps_the_partial_file() {
runtime().block_on(async {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
remote.file("/file", &vec![1; 100_000]);
let answers = Answers::new(vec![]);
let mut batch = download_batch(
@@ -1745,7 +1767,7 @@ fn an_scp_upload_takes_the_destination_name_and_keeps_the_execute_bits() {
let source = tmp.path().join("build.sh");
std::fs::write(&source, b"#!/bin/sh\n").unwrap();
std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o755)).unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
let request = UploadRequest::scp(source.clone(), remote_path("/dest/deploy.sh"))
.resolved(remote_path("/dest"), Some("deploy.sh".into()));
@@ -1797,7 +1819,7 @@ fn an_scp_download_takes_the_destination_name_and_keeps_the_execute_bits() {
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
let out = tmp.path().join("out");
std::fs::create_dir(&out).unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
remote.file("/srv/run.sh", b"#!/bin/sh\n");
remote
.nodes
@@ -1856,7 +1878,7 @@ fn an_scp_download_takes_the_destination_name_and_keeps_the_execute_bits() {
#[test]
fn a_text_file_is_read_whole_through_short_and_reordered_reads() {
runtime().block_on(async {
let remote = Remote::new(true);
let remote = Remote::new();
let text: String = (0..40_000).map(|i| format!("line {i}\n")).collect();
remote.file("/etc/app.conf", text.as_bytes());
remote.short_reads.set(true);
@@ -1875,7 +1897,7 @@ fn a_text_file_is_read_whole_through_short_and_reordered_reads() {
#[test]
fn a_link_is_followed_to_the_file_it_points_at() {
runtime().block_on(async {
let remote = Remote::new(true);
let remote = Remote::new();
remote.file("/srv/real.conf", b"old\n");
remote.link("/etc/app.conf", "/srv/real.conf");
let path = remote_path("/etc/app.conf");
@@ -1896,7 +1918,7 @@ fn a_link_is_followed_to_the_file_it_points_at() {
#[test]
fn directories_large_files_and_binary_files_are_refused() {
runtime().block_on(async {
let remote = Remote::new(true);
let remote = Remote::new();
remote.dir("/etc", 0o755);
remote.file("/var/big.log", &vec![b'x'; EDIT_LIMIT as usize + 1]);
let mut binary = vec![b'a'; 1_000_000];
@@ -1924,7 +1946,7 @@ fn directories_large_files_and_binary_files_are_refused() {
#[test]
fn a_save_writes_in_place_and_keeps_the_permissions() {
runtime().block_on(async {
let remote = Remote::new(true);
let remote = Remote::new();
let long: Vec<u8> = (0..100_000u32).map(|i| (i % 251) as u8 + 1).collect();
remote.file("/etc/app.conf", &long);
let path = remote_path("/etc/app.conf");
@@ -1952,7 +1974,7 @@ fn a_save_writes_in_place_and_keeps_the_permissions() {
#[test]
fn a_save_over_a_file_changed_or_removed_since_is_refused() {
runtime().block_on(async {
let remote = Remote::new(true);
let remote = Remote::new();
remote.file("/etc/app.conf", b"mine\n");
let path = remote_path("/etc/app.conf");
let file = read_text(&remote, &path).await.unwrap();
@@ -1980,7 +2002,7 @@ fn a_save_over_a_file_changed_or_removed_since_is_refused() {
#[test]
fn a_save_that_fails_after_truncating_is_interrupted() {
runtime().block_on(async {
let remote = Remote::new(true);
let remote = Remote::new();
remote.file("/etc/app.conf", &vec![b'a'; 200_000]);
remote.fault.set(Some(Fault::Write(2)));
let error = write_in_place(
@@ -2123,7 +2145,7 @@ fn local_files_are_edited_in_place_through_links() {
#[test]
fn a_binary_file_is_read_whole_for_a_preview_within_its_limit() {
runtime().block_on(async {
let remote = Remote::new(true);
let remote = Remote::new();
let image: Vec<u8> = (0..300_000u32).map(|i| (i % 256) as u8).collect();
remote.file("/srv/photo.png", &image);
remote.short_reads.set(true);
@@ -2169,7 +2191,7 @@ fn a_sync_copies_only_what_changed_since_the_last() {
.unwrap();
std::fs::write(dist.join("assets/logo.png"), b"png").unwrap();
std::os::unix::fs::symlink("index.html", dist.join("home.html")).unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
remote.dir("/srv", 0o755);
let answers = Answers::new(vec![]);
@@ -2217,7 +2239,7 @@ fn a_sync_with_delete_removes_what_is_not_here_and_nothing_it_cannot_see() {
std::fs::set_permissions(dist.join("locked"), std::fs::Permissions::from_mode(0o000))
.unwrap();
let remote = Remote::new(true);
let remote = Remote::new();
remote.dir("/srv/app", 0o755);
remote.file("/srv/app/keep.txt", b"k");
remote.file("/srv/app/extra.txt", b"x");
+29 -37
View File
@@ -532,25 +532,6 @@ impl UploadBatch {
uploaded += length;
self.meter.advance(length, uploaded, control);
}
control
.run(
fs.attributes(
&handle,
u32::try_from(source_metadata.modified_ns / 1_000_000_000).ok(),
record
.original
.as_ref()
.and_then(FileMetadata::permissions)
.or_else(|| {
self.preserve_mode
.then(|| item.metadata.permissions())
.flatten()
.map(|mode| mode & 0o777)
}),
),
)
.await?;
control.run(fs.sync(&handle)).await?;
Ok(())
}
.await;
@@ -559,7 +540,24 @@ impl UploadBatch {
let close = fs.close(&handle).await;
result?;
close?;
Ok(())
control
.run(
fs.attributes(
&record.temporary,
u32::try_from(source_metadata.modified_ns / 1_000_000_000).ok(),
record
.original
.as_ref()
.and_then(FileMetadata::permissions)
.or_else(|| {
self.preserve_mode
.then(|| item.metadata.permissions())
.flatten()
.map(|mode| mode & 0o777)
}),
),
)
.await
}
async fn matches_expected_item<F: RemoteFs>(
&self,
@@ -603,7 +601,7 @@ impl UploadBatch {
if fs.metadata(&record.target).await?.is_none()
&& fs.metadata(&record.backup).await?.is_some()
{
fs.rename(&record.backup, &record.target, false).await?;
fs.rename(&record.backup, &record.target).await?;
}
bail!(t!("sftp.upload.publish_unconfirmed"));
}
@@ -635,24 +633,22 @@ impl UploadBatch {
record.original = current;
self.journal.save(record).await?;
if let Some(source_metadata) = &record.source_metadata {
let handle = control.run(fs.open(&record.temporary, false)).await?;
let result = control
control
.run(fs.attributes(
&handle,
&record.temporary,
u32::try_from(source_metadata.modified_ns / 1_000_000_000).ok(),
record.original.as_ref().and_then(FileMetadata::permissions),
))
.await;
let closed = fs.close(&handle).await;
result?;
closed?;
.await?;
}
}
if backup.is_none() && record.original.is_some() && !fs.atomic_replace() {
// Over a file, the old one is moved aside first and removed once
// the new one is in its place: no `posix-rename@openssh.com`.
if backup.is_none() && record.original.is_some() {
record.phase = PublishPhase::BackingUp;
self.journal.save(record).await?;
control
.run(fs.rename(&record.target, &record.backup, false))
.run(fs.rename(&record.target, &record.backup))
.await?;
record.phase = PublishPhase::BackedUp;
self.journal.save(record).await?;
@@ -660,11 +656,7 @@ impl UploadBatch {
record.phase = PublishPhase::Publishing;
self.journal.save(record).await?;
let result = control
.run(fs.rename(
&record.temporary,
&record.target,
fs.atomic_replace() && record.original.is_some() && backup.is_none(),
))
.run(fs.rename(&record.temporary, &record.target))
.await;
if let Err(error) = result {
if !super::client::is_network_error(&error)
@@ -672,7 +664,7 @@ impl UploadBatch {
&& fs.metadata(&record.target).await?.is_none()
&& fs.metadata(&record.backup).await?.is_some()
{
fs.rename(&record.backup, &record.target, false).await?;
fs.rename(&record.backup, &record.target).await?;
record.phase = PublishPhase::Ready;
self.journal.save(record).await?;
}
@@ -701,7 +693,7 @@ impl UploadBatch {
)?;
if fs.metadata(&record.backup).await?.is_some() {
if fs.metadata(&record.target).await?.is_none() {
fs.rename(&record.backup, &record.target, false).await?;
fs.rename(&record.backup, &record.target).await?;
} else {
bail!(t!("sftp.upload.backup_and_target"));
}
+52 -1
View File
@@ -5,11 +5,13 @@ use gpui_kit::component::{
button::{Button, ButtonVariants as _},
h_flex,
menu::{ContextMenuExt as _, PopupMenu},
tooltip::Tooltip,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::app::ToggleHostPanel;
use crate::host::ConnectionState;
use crate::i18n::t;
type MenuBuilder = Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>;
@@ -37,6 +39,7 @@ pub struct ClosableTabTitle {
close: Option<(ElementId, Box<dyn Action>)>,
menu: Option<MenuBuilder>,
modified: bool,
connection_state: Option<ConnectionState>,
}
impl ClosableTabTitle {
@@ -54,9 +57,19 @@ impl ClosableTabTitle {
close: None,
menu: None,
modified: false,
connection_state: None,
}
}
/// Show this tab's own connection state, never its host's aggregate state.
/// Only a connection that is not up gets a dot, so a dot always means
/// something to look at. Tabs that do not own a remote connection leave
/// this unset.
pub fn connection_state(mut self, state: ConnectionState) -> Self {
self.connection_state = Some(state);
self
}
/// Mark the tab as holding changes not saved yet: a dot after the label,
/// as editors do.
pub fn modified(mut self, modified: bool) -> Self {
@@ -83,10 +96,48 @@ impl ClosableTabTitle {
impl RenderOnce for ClosableTabTitle {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
// The badge must not change the mark's layout box or baseline. Put
// its center at the circular mark's lower-right corner: the two
// circles remain separate without shifting the icon or title text.
let status = self.connection_state.and_then(|state| match state {
ConnectionState::Connected => None,
ConnectionState::Connecting => Some((state, cx.theme().warning)),
ConnectionState::Disconnected => Some((state, cx.theme().danger)),
});
let leading = match status {
Some((state, color)) => {
let label = state.label();
div()
.relative()
.flex_shrink_0()
.child(self.leading)
.child(
div()
.id("connection-status")
.test_support()
.role(Role::Status)
.aria_label(label.clone())
.absolute()
// Half the size_1p5 diameter, in the same rem
// units, centers the dot on the mark's corner.
.right(-rems(0.1875))
.bottom(-rems(0.1875))
.size_1p5()
.flex_shrink_0()
.rounded_full()
.bg(color)
.tooltip(move |window, cx| {
Tooltip::new(label.clone()).build(window, cx)
}),
)
.into_any_element()
}
None => self.leading,
};
let row = h_flex()
.id("title")
.gap_1()
.child(self.leading)
.child(leading)
.child(self.label.clone())
.when(self.modified, |row| {
row.child(
+188 -56
View File
@@ -604,11 +604,6 @@ impl SshPrompts {
self.ask(kind).await
}
fn emit(&self, kind: ConnectionPromptKind) {
let request_id = NEXT_PROMPT_ID.fetch_add(1, Ordering::Relaxed);
(self.events)(ConnectionPrompt::new(request_id, kind));
}
pub fn respond(&self, request_id: u64, reply: ConnectionPromptReply) {
if let Some(sender) = lock(&self.pending).remove(&request_id) {
let _ = sender.send(reply);
@@ -716,51 +711,57 @@ impl client::Handler for SshClientHandler {
if known.iter().any(|(_, saved)| saved == &key) {
return Ok(true);
}
if !known.is_empty() {
// A key never seen, or one other than the saved: a reinstalled
// server, or someone posing as it. The user decides, and saying yes
// to a changed key replaces the saved ones.
let question = if known.is_empty() {
ConnectionPromptKind::unknown_host(&self.host, self.port, algorithm, fingerprint)
} else {
let old = known
.iter()
.map(|(_, saved)| saved.fingerprint(HashAlg::Sha256).to_string())
.collect();
self.broker.emit(
ConnectionPromptKind::host_key_changed(
&self.host,
self.port,
algorithm,
old,
fingerprint,
&self.known_hosts_path,
)
.at_jump_host(self.jump_host.as_deref()),
);
return Ok(false);
}
ConnectionPromptKind::host_key_changed(
&self.host,
self.port,
algorithm,
old,
fingerprint,
)
};
let reply = self
.broker
.ask(
ConnectionPromptKind::unknown_host(&self.host, self.port, algorithm, fingerprint)
.at_jump_host(self.jump_host.as_deref()),
)
.ask(question.at_jump_host(self.jump_host.as_deref()))
.await?;
if !matches!(reply, ConnectionPromptReply::TrustAndSave) {
return Ok(false);
}
let _guard = lock(&self.known_hosts_lock);
let known = read_known_keys(&self.host, self.port, &self.known_hosts_path)?;
if known.iter().any(|(_, saved)| saved == &key) {
let now = read_known_keys(&self.host, self.port, &self.known_hosts_path)?;
if now.iter().any(|(_, saved)| saved == &key) {
return Ok(true);
}
if !known.is_empty() {
// The answer was about the keys saved then; another connection
// may have saved one since.
if !now
.iter()
.map(|(_, saved)| saved)
.eq(known.iter().map(|(_, saved)| saved))
{
bail!(t!("ssh.known_hosts.changed_while_saving"));
}
learn_known_hosts_path(&self.host, self.port, &key, &self.known_hosts_path).map_err(
|_| {
anyhow!(t!(
"ssh.known_hosts.write_failed",
path = self.known_hosts_path.display()
))
},
)?;
let write_failed = || {
anyhow!(t!(
"ssh.known_hosts.write_failed",
path = self.known_hosts_path.display()
))
};
if !known.is_empty() {
forget_known_host(&self.host, self.port, &self.known_hosts_path)
.map_err(|_| write_failed())?;
}
learn_known_hosts_path(&self.host, self.port, &key, &self.known_hosts_path)
.map_err(|_| write_failed())?;
Ok(true)
}
@@ -841,6 +842,34 @@ pub fn is_network_error(error: &anyhow::Error) -> bool {
})
}
/// Drop every key saved for `host` at `port`, as `ssh-keygen -R` does: a
/// reinstalled server has new keys of every kind. A line naming it with a
/// hashed host name, which only a hand could have written there, stays.
fn forget_known_host(host: &str, port: u16, path: &Path) -> std::io::Result<()> {
let name = if port == 22 {
host.to_string()
} else {
format!("[{host}]:{port}")
};
let contents = std::fs::read_to_string(path)?;
let kept: String = contents
.lines()
.filter(|line| {
let line = line.trim_start();
line.starts_with('#')
|| !line
.split_whitespace()
.next()
.is_some_and(|hosts| hosts.split(',').any(|entry| entry == name))
})
.map(|line| format!("{line}\n"))
.collect();
// In one step, so a crash halfway cannot lose the other hosts' keys.
let temporary = path.with_extension("tmp");
std::fs::write(&temporary, kept)?;
std::fs::rename(&temporary, path)
}
fn read_known_keys(host: &str, port: u16, path: &Path) -> Result<Vec<(usize, PublicKey)>> {
if path.exists() {
let contents = std::fs::read_to_string(path)
@@ -950,10 +979,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 +1034,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 +1056,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 +1287,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")))?;
}
@@ -1310,6 +1407,41 @@ mod tests {
assert!(error.to_string().contains("已损坏"));
}
#[test]
fn forgetting_a_host_drops_its_keys_and_keeps_the_rest() {
use super::forget_known_host;
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("known_hosts");
let key = "AAAAC3NzaC1lZDI1NTE5AAAAIKjlGLd6uUkTpO5kSb3hPwdVvVGrN1J0yW3sKa3BpB2c";
std::fs::write(
&path,
format!(
"# kept as it is\n\
192.168.7.1 ssh-ed25519 {key}\n\
[192.168.7.1]:2222 ssh-ed25519 {key}\n\
192.168.7.10 ssh-ed25519 {key}\n\
192.168.7.1 ssh-rsa {key}\n\
alias,192.168.7.1 ssh-ed25519 {key}\n"
),
)
.unwrap();
forget_known_host("192.168.7.1", 22, &path).unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
format!(
"# kept as it is\n\
[192.168.7.1]:2222 ssh-ed25519 {key}\n\
192.168.7.10 ssh-ed25519 {key}\n"
)
);
forget_known_host("192.168.7.1", 2222, &path).unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
format!("# kept as it is\n192.168.7.10 ssh-ed25519 {key}\n")
);
}
#[test]
fn only_a_failing_network_counts_as_a_network_error() {
use super::{MissingCredential, is_network_error};
+1 -1
View File
@@ -119,7 +119,7 @@ async fn run(
}
ConnectionPromptKind::HostKeyChanged(_) => {
lock(&seen).changed = true;
true
false
}
ConnectionPromptKind::Authentication(_) => false,
})
+3 -1
View File
@@ -122,9 +122,11 @@ impl SshConnectionTester {
});
true
}
// Declined at once, which fails the test and says why:
// replacing the saved key is left to a real connection.
ConnectionPromptKind::HostKeyChanged(_) => {
lock(&host_trust).key_changed = true;
true
false
}
// Credential questions never get here: the broker is
// non-interactive and fails with a `MissingCredential`.
+312 -57
View File
@@ -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();
@@ -1920,69 +1966,278 @@ mod tests {
}
#[test]
fn changed_host_key_is_reported_and_blocked() {
let Some(server) = start_server(TestAuth::Password) else {
eprintln!(
"loopback sockets are unavailable in this sandbox; skipping integration body"
);
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 mut rng = russh::keys::key::safe_rng();
let stale_key =
russh::keys::PrivateKey::random(&mut rng, russh::keys::Algorithm::Ed25519).unwrap();
learn_known_hosts_path(
"127.0.0.1",
server.port,
stale_key.public_key(),
&known_hosts,
)
.unwrap();
let host = password_host(server.port);
let provider = SshTerminalTransportProvider::with_connector(SshConnector::new(
&known_hosts,
Arc::new(NoSecretStore),
));
let factory = provider.factory_for(&HostLogin::of(&host, None));
let (_command_tx, command_rx) = mpsc::channel();
let (event_tx, event_rx) = async_channel::unbounded();
let (done_tx, done_rx) = mpsc::channel();
let worker = thread::spawn(move || {
done_tx
.send(
factory
.create()
.run(TerminalSize::DEFAULT, command_rx, event_tx),
)
.unwrap();
});
let deadline = std::time::Instant::now() + Duration::from_secs(3);
let changed = loop {
match event_rx.try_recv() {
Ok(TerminalTransportEvent::Prompt(prompt)) => match prompt.kind() {
ConnectionPromptKind::HostKeyChanged(changed) => break changed.clone(),
other => panic!("unexpected prompt: {other:?}"),
},
Ok(event) => panic!("unexpected event: {event:?}"),
Err(async_channel::TryRecvError::Empty) if std::time::Instant::now() < deadline => {
thread::sleep(Duration::from_millis(5));
}
Err(error) => panic!("host-key prompt was not emitted: {error}"),
}
let test = |request| {
test_login(
request,
&known_hosts,
Arc::new(InMemorySecretStore::default()),
true,
)
.0
};
assert_eq!(changed.host(), "127.0.0.1");
assert_eq!(changed.port(), server.port);
assert_eq!(changed.known_hosts_path(), known_hosts);
assert_eq!(changed.old_fingerprints().len(), 1);
assert_ne!(changed.old_fingerprints()[0], changed.fingerprint());
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);
}
/// A terminal connecting to a server whose key is not the one saved for
/// it, stopped at the question about the key.
struct ChangedKey {
server: RunningTestServer,
known_hosts: std::path::PathBuf,
stale: String,
prompt: crate::connection::ConnectionPrompt,
commands: mpsc::Sender<TerminalTransportCommand>,
events: async_channel::Receiver<TerminalTransportEvent>,
done: mpsc::Receiver<anyhow::Result<()>>,
worker: thread::JoinHandle<()>,
_directory: tempfile::TempDir,
}
impl ChangedKey {
fn connect() -> Option<Self> {
let Some(server) = start_server(TestAuth::Password) else {
eprintln!(
"loopback sockets are unavailable in this sandbox; skipping integration body"
);
return None;
};
let directory = tempfile::tempdir().unwrap();
let known_hosts = directory.path().join("known_hosts");
let mut rng = russh::keys::key::safe_rng();
let stale_key =
russh::keys::PrivateKey::random(&mut rng, russh::keys::Algorithm::Ed25519).unwrap();
learn_known_hosts_path(
"127.0.0.1",
server.port,
stale_key.public_key(),
&known_hosts,
)
.unwrap();
let stale = stale_key
.public_key()
.fingerprint(russh::keys::HashAlg::Sha256)
.to_string();
let host = password_host(server.port);
let provider = SshTerminalTransportProvider::with_connector(SshConnector::new(
&known_hosts,
Arc::new(NoSecretStore),
));
let factory = provider.factory_for(&HostLogin::of(&host, None));
let (commands, command_rx) = mpsc::channel();
let (event_tx, events) = async_channel::unbounded();
let (done_tx, done) = mpsc::channel();
let worker = thread::spawn(move || {
done_tx
.send(
factory
.create()
.run(TerminalSize::DEFAULT, command_rx, event_tx),
)
.unwrap();
});
let prompt = match events.recv_blocking().unwrap() {
TerminalTransportEvent::Prompt(prompt) => prompt,
event => panic!("unexpected event: {event:?}"),
};
Some(Self {
server,
known_hosts,
stale,
prompt,
commands,
events,
done,
worker,
_directory: directory,
})
}
fn answer(&self, reply: ConnectionPromptReply) {
self.commands
.send(TerminalTransportCommand::PromptReply {
request_id: self.prompt.request_id(),
reply,
})
.unwrap();
}
/// The keys saved for the server now, by fingerprint.
fn saved(&self) -> Vec<String> {
russh::keys::known_hosts::known_host_keys_path(
"127.0.0.1",
self.server.port,
&self.known_hosts,
)
.unwrap()
.into_iter()
.map(|(_, key)| key.fingerprint(russh::keys::HashAlg::Sha256).to_string())
.collect()
}
}
#[test]
fn a_changed_host_key_is_asked_about_and_declining_blocks_it() {
let Some(changed) = ChangedKey::connect() else {
return;
};
let ConnectionPromptKind::HostKeyChanged(question) = changed.prompt.kind().clone() else {
panic!("unexpected prompt: {:?}", changed.prompt.kind());
};
assert_eq!(question.host(), "127.0.0.1");
assert_eq!(question.port(), changed.server.port);
assert_eq!(
question.old_fingerprints(),
std::slice::from_ref(&changed.stale)
);
assert_ne!(question.fingerprint(), changed.stale);
changed.answer(ConnectionPromptReply::Cancel);
assert!(
done_rx
changed
.done
.recv_timeout(Duration::from_secs(3))
.expect("mismatched host connection did not stop")
.is_err()
);
worker.join().unwrap();
assert_eq!(changed.saved(), std::slice::from_ref(&changed.stale));
changed.worker.join().unwrap();
}
#[test]
fn updating_a_changed_host_key_replaces_the_saved_one_and_logs_in() {
let Some(changed) = ChangedKey::connect() else {
return;
};
let ConnectionPromptKind::HostKeyChanged(question) = changed.prompt.kind().clone() else {
panic!("unexpected prompt: {:?}", changed.prompt.kind());
};
changed.answer(ConnectionPromptReply::TrustAndSave);
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
assert!(
std::time::Instant::now() < deadline,
"the login did not go on"
);
match changed.events.recv_blocking().unwrap() {
TerminalTransportEvent::Prompt(prompt) => match prompt.kind() {
ConnectionPromptKind::Authentication(_) => changed
.commands
.send(TerminalTransportCommand::PromptReply {
request_id: prompt.request_id(),
reply: ConnectionPromptReply::Answers(vec![ConnectionSecret::new(
TEST_PASSWORD,
)]),
})
.unwrap(),
other => panic!("unexpected prompt: {other:?}"),
},
TerminalTransportEvent::Started => break,
TerminalTransportEvent::Failed(error) => panic!("SSH failed: {error}"),
_ => {}
}
}
// The stale key is gone, the server's is saved in its place.
assert_eq!(changed.saved(), [question.fingerprint().to_string()]);
changed
.commands
.send(TerminalTransportCommand::Shutdown)
.unwrap();
changed
.done
.recv_timeout(Duration::from_secs(3))
.expect("the terminal did not stop")
.unwrap();
changed.worker.join().unwrap();
}
}
+22
View File
@@ -103,6 +103,7 @@ pub struct TerminalEngine {
/// lines against them; swapped when the settings change them.
highlights: SharedHighlights,
_highlights_observer: gpui_kit::Subscription,
reconnect_on_enter: bool,
generation: u64,
size: TerminalSize,
event_sender: mpsc::Sender<TerminalUiEvent>,
@@ -142,6 +143,7 @@ impl TerminalEngine {
url_search: RefCell::new(url_search()),
highlights,
_highlights_observer: highlights_observer,
reconnect_on_enter: false,
generation,
size,
event_sender,
@@ -201,10 +203,12 @@ impl TerminalEngine {
TerminalUiEventKind::Title(title) => self.title = sanitize_title(&title),
TerminalUiEventKind::ResetTitle => self.title = None,
TerminalUiEventKind::Exited { code, signal } => {
self.append_reconnect_hint();
self.lifecycle = TerminalLifecycle::Exited { code, signal };
self.latency = None;
}
TerminalUiEventKind::Failed(error) => {
self.append_reconnect_hint();
self.lifecycle = TerminalLifecycle::Failed(error);
self.latency = None;
}
@@ -240,6 +244,16 @@ impl TerminalEngine {
None
}
pub(super) fn enable_reconnect_on_enter(&mut self) {
self.reconnect_on_enter = true;
}
fn append_reconnect_hint(&self) {
if self.reconnect_on_enter {
append_message(&self.runtime.term, &t!("terminal.notice.enter_reconnect"));
}
}
pub fn restart(&mut self, cx: &mut gpui_kit::Context<Self>) {
self.generation = self.generation.saturating_add(1);
self.runtime.shutdown();
@@ -282,6 +296,7 @@ impl TerminalEngine {
self.generation = self.generation.saturating_add(1);
self.runtime.shutdown();
append_message(&self.runtime.term, message);
self.append_reconnect_hint();
self.lifecycle = TerminalLifecycle::Exited {
code: 0,
signal: None,
@@ -686,6 +701,13 @@ pub enum TerminalEvent {
HostOsDetected(HostOs),
/// Something to show the user while they look elsewhere.
Notice(TerminalNotice),
/// A screenshot to put on a saved host, then paste as a path.
PasteImage {
bytes: Vec<u8>,
extension: &'static str,
},
/// Why a screenshot could not be pasted.
PasteProblem(SharedString),
}
struct TerminalRuntime {
+55 -20
View File
@@ -1,31 +1,46 @@
use std::ffi::OsString;
use std::io::{ErrorKind, Read, Write};
use std::sync::mpsc;
use std::sync::{Arc, mpsc};
use std::thread;
use std::time::Duration;
use anyhow::{Context as _, Result};
use anyhow::{Context as _, Result, anyhow};
use async_channel::Sender;
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use portable_pty::{PtySize, native_pty_system};
use crate::i18n::t;
use super::{
TerminalSize, TerminalTransport, TerminalTransportCommand, TerminalTransportEvent,
TerminalTransportFactory, send_event,
LocalShell, LocalTerminalTransportProvider, SharedTerminalTransportFactory, TerminalSize,
TerminalTransport, TerminalTransportCommand, TerminalTransportEvent, TerminalTransportFactory,
send_event,
};
/// Local terminals in a PTY of this computer.
#[derive(Default)]
pub struct LocalPtyTransportFactory;
pub struct LocalPtyTransportProvider;
impl LocalTerminalTransportProvider for LocalPtyTransportProvider {
fn factory_for(&self, shell: &LocalShell) -> SharedTerminalTransportFactory {
Arc::new(LocalPtyTransportFactory {
shell: shell.clone(),
})
}
}
struct LocalPtyTransportFactory {
shell: LocalShell,
}
impl TerminalTransportFactory for LocalPtyTransportFactory {
fn create(&self) -> Box<dyn TerminalTransport> {
Box::new(LocalPtyTransport { command: None })
Box::new(LocalPtyTransport {
shell: self.shell.clone(),
})
}
}
struct LocalPtyTransport {
command: Option<Vec<OsString>>,
shell: LocalShell,
}
impl TerminalTransport for LocalPtyTransport {
@@ -40,18 +55,23 @@ impl TerminalTransport for LocalPtyTransport {
.openpty(to_pty_size(initial_size))
.context(t!("terminal.pty.open_failed"))?;
let mut command = self
.command
.map(CommandBuilder::from_argv)
.unwrap_or_else(CommandBuilder::new_default_prog);
let mut command = self.shell.command_builder()?;
command.env("TERM", "xterm-256color");
command.env("COLORTERM", "truecolor");
command.env("TERM_PROGRAM", "ShellRS");
let mut child = pair
.slave
.spawn_command(command)
.context(t!("terminal.pty.shell_failed"))?;
let mut child =
pair.slave
.spawn_command(command)
.map_err(|error| match self.shell.command() {
// Why, too: the path or the arguments are the user's.
Some(command) => anyhow!(t!(
"terminal.pty.command_failed",
command = command,
error = format!("{error:#}")
)),
None => error.context(t!("terminal.pty.shell_failed")),
})?;
drop(pair.slave);
let mut reader = pair
@@ -159,10 +179,12 @@ mod tests {
use super::*;
/// `/bin/sh -c script`, through the command line the settings hold.
fn shell_transport(script: &str) -> Box<dyn TerminalTransport> {
Box::new(LocalPtyTransport {
command: Some(vec!["/bin/sh".into(), "-c".into(), script.into()]),
})
let command = format!("/bin/sh -c {}", crate::terminal::quote_word(script));
LocalPtyTransportProvider
.factory_for(&LocalShell::new(&command))
.create()
}
fn drain_until_exit(
@@ -226,6 +248,19 @@ mod tests {
worker.join().unwrap().unwrap();
}
#[test]
fn a_command_that_cannot_start_says_which() {
let (_commands, command_receiver) = mpsc::channel();
let (events, _event_receiver) = async_channel::unbounded();
let error = LocalPtyTransportProvider
.factory_for(&LocalShell::new("/no/such/shell -l"))
.create()
.run(TerminalSize::DEFAULT, command_receiver, events)
.unwrap_err()
.to_string();
assert!(error.contains("无法启动 /no/such/shell -l"), "{error}");
}
#[test]
fn shutdown_terminates_and_reaps_the_child() {
let (commands, command_receiver) = mpsc::channel();
+582
View File
@@ -0,0 +1,582 @@
//! What a local terminal runs: the system's default shell, or the program
//! 设置 › 终端 › 本地终端 names; and the shells installed here, to offer
//! for it.
use std::ffi::OsString;
#[cfg(any(unix, test))]
use std::path::Path;
use std::path::PathBuf;
use anyhow::{Result, anyhow};
use portable_pty::CommandBuilder;
use crate::i18n::t;
/// The program a local terminal runs, as the user wrote it: a path or a
/// command, with any arguments. Without one, the system's default shell.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct LocalShell {
command: Option<String>,
}
impl LocalShell {
/// `command` without the blanks around it; blank is the default shell.
pub fn new(command: &str) -> Self {
let command = command.trim();
Self {
command: (!command.is_empty()).then(|| command.to_string()),
}
}
pub fn command(&self) -> Option<&str> {
self.command.as_deref()
}
/// What to spawn. Blocking: it looks at the file system, so it runs on
/// the transport's thread.
///
/// A shell named on its own starts as a login shell, as the default one
/// does, so it reads the same profile (Homebrew's `PATH` is set up in
/// `~/.zprofile`): portable-pty runs `$SHELL` with a `-` before its
/// name. Anything else, or a shell with arguments, runs as written.
pub(super) fn command_builder(&self) -> Result<CommandBuilder> {
let Some(command) = &self.command else {
return Ok(default_command());
};
let argv = split_command(command, QUOTING)
.ok_or_else(|| anyhow!(t!("terminal.pty.unmatched_quote", command = command)))?;
#[cfg(unix)]
if let [program] = argv.as_slice()
&& is_shell_name(program)
&& let Some(path) = find_program(program)
{
let mut builder = CommandBuilder::new_default_prog();
builder.env("SHELL", path);
return Ok(builder);
}
Ok(CommandBuilder::from_argv(
argv.into_iter().map(OsString::from).collect(),
))
}
}
/// The system's default shell. On Unix the user's: `$SHELL`, or the one
/// the passwd database names. On Windows PowerShell, as Windows Terminal
/// and VS Code open: PowerShell 7 where it is installed, otherwise the
/// Windows PowerShell every Windows has; only without either the command
/// prompt (`%ComSpec%`), portable-pty's own default.
fn default_command() -> CommandBuilder {
#[cfg(windows)]
if let Some(program) = windows_default_shell() {
return CommandBuilder::new(program);
}
CommandBuilder::new_default_prog()
}
/// What the default shell is here, for 「自动检测(/bin/zsh)」: its path
/// on Unix, its file name on Windows, as the shells offered are written.
/// Blocking.
pub fn default_shell_name() -> String {
#[cfg(windows)]
{
let program = windows_default_shell()
.unwrap_or_else(|| PathBuf::from(CommandBuilder::new_default_prog().get_shell()));
program
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| program.to_string_lossy().into_owned())
}
#[cfg(not(windows))]
CommandBuilder::new_default_prog().get_shell()
}
/// The shells to offer, each as the field would hold it. On Unix the
/// common ones that are here (`COMMON_SHELLS`), one line per program; on
/// Windows PowerShell 7 where it is installed, Windows PowerShell and the
/// command prompt. Blocking.
pub fn installed_shells() -> Vec<String> {
#[cfg(unix)]
{
let listed = std::fs::read_to_string("/etc/shells")
.map(|text| listed_shells(&text))
.unwrap_or_default();
let mut folders: Vec<PathBuf> = std::env::var_os("PATH")
.map(|paths| {
std::env::split_paths(&paths)
.filter(|folder| folder.is_absolute())
.collect()
})
.unwrap_or_default();
folders.extend(SHELL_FOLDERS.iter().map(PathBuf::from));
common_shells(&listed, &folders, is_executable, program_key)
}
#[cfg(windows)]
{
let mut shells = Vec::new();
if find_on_path("pwsh.exe").is_some() {
shells.push("pwsh.exe".to_string());
} else if let Some(pwsh) = installed_pwsh() {
shells.push(quote_word(&pwsh.to_string_lossy()));
}
if windows_powershell().is_some() {
shells.push("powershell.exe".to_string());
}
shells.push("cmd.exe".to_string());
shells
}
}
/// How the words of a command are quoted.
// Each platform uses only its own; the tests use both everywhere.
#[cfg_attr(not(test), allow(dead_code))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Quoting {
/// As a POSIX shell reads them: single quotes, double quotes, and
/// backslashes that take the next character as it is.
Posix,
/// As Windows programs read their command line, near enough: double
/// quotes only; a backslash is part of the path.
Windows,
}
#[cfg(not(windows))]
const QUOTING: Quoting = Quoting::Posix;
#[cfg(windows)]
const QUOTING: Quoting = Quoting::Windows;
/// The words of `line`, split at blanks outside quotes. `None` when a
/// quote is left open.
fn split_command(line: &str, quoting: Quoting) -> Option<Vec<String>> {
let posix = quoting == Quoting::Posix;
let mut words = Vec::new();
let mut word = String::new();
// Whether a word has begun: `""` is a word, empty.
let mut in_word = false;
let mut chars = line.chars();
while let Some(c) = chars.next() {
match c {
c if c.is_whitespace() => {
if in_word {
words.push(std::mem::take(&mut word));
in_word = false;
}
}
'"' => {
in_word = true;
loop {
match chars.next()? {
'"' => break,
// Inside double quotes a backslash quotes only what
// would otherwise end or expand them.
'\\' if posix => match chars.next()? {
c @ ('"' | '\\' | '$' | '`') => word.push(c),
c => {
word.push('\\');
word.push(c);
}
},
c => word.push(c),
}
}
}
'\'' if posix => {
in_word = true;
loop {
match chars.next()? {
'\'' => break,
c => word.push(c),
}
}
}
'\\' if posix => {
in_word = true;
// A backslash at the very end stands for itself.
word.push(chars.next().unwrap_or('\\'));
}
c => {
in_word = true;
word.push(c);
}
}
}
if in_word {
words.push(word);
}
Some(words)
}
/// `word` as the field writes it, so `split_command` reads it back as one
/// word: a file chosen with the system's picker. On Windows it is wrapped
/// in double quotes when it has blanks; a path cannot have a double quote.
pub fn quote_word(word: &str) -> String {
quote_word_for(word, QUOTING)
}
fn quote_word_for(word: &str, quoting: Quoting) -> String {
match quoting {
Quoting::Posix => {
let plain = !word.is_empty()
&& word
.chars()
.all(|c| c.is_alphanumeric() || "/._-+@%:,=~".contains(c));
if plain {
word.to_string()
} else {
format!("'{}'", word.replace('\'', r"'\''"))
}
}
Quoting::Windows => {
if word.is_empty() || word.chars().any(char::is_whitespace) {
format!("\"{word}\"")
} else {
word.to_string()
}
}
}
}
/// Shells that know a `-` before their name means a login shell. Another
/// program named on its own is not given one: `tmux` would start `$SHELL`,
/// itself, in each of its windows.
#[cfg(any(unix, test))]
const SHELL_NAMES: &[&str] = &[
"ash", "bash", "csh", "dash", "elvish", "fish", "ksh", "mksh", "nu", "oksh", "pwsh", "sh",
"tcsh", "xonsh", "yash", "zsh",
];
#[cfg(any(unix, test))]
fn is_shell_name(program: &str) -> bool {
Path::new(program)
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| SHELL_NAMES.contains(&name))
}
/// The shells offered, in this order. The others `/etc/shells` lists (csh,
/// tcsh, ksh, dash…) are left to the file picker: few use them, and they
/// made the menu long.
#[cfg(unix)]
const COMMON_SHELLS: &[&str] = &["zsh", "bash", "sh", "fish", "nu", "pwsh"];
/// Where a shell is looked for besides `PATH`: the system's folders, and
/// Homebrew's, which a GUI app's `PATH` leaves out and `/etc/shells` lists
/// only when told to.
#[cfg(unix)]
const SHELL_FOLDERS: &[&str] = &["/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"];
/// Of `COMMON_SHELLS`, those `usable`: as `/etc/shells` lists them first,
/// then as found in `folders`; one line per program, by `key`.
#[cfg(unix)]
fn common_shells<K: Eq + std::hash::Hash>(
listed: &[String],
folders: &[PathBuf],
usable: impl Fn(&Path) -> bool,
key: impl Fn(&Path) -> K,
) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut shells = Vec::new();
for name in COMMON_SHELLS {
let named = listed
.iter()
.map(PathBuf::from)
.filter(|path| path.file_name() == Some(std::ffi::OsStr::new(name)));
for path in named.chain(folders.iter().map(|folder| folder.join(name))) {
if usable(&path) && seen.insert(key(&path)) {
shells.push(path.to_string_lossy().into_owned());
}
}
}
shells
}
/// The paths `/etc/shells` lists: one a line, without comments.
#[cfg(any(unix, test))]
fn listed_shells(text: &str) -> Vec<String> {
text.lines()
.map(|line| line.split('#').next().unwrap_or_default().trim())
.filter(|line| line.starts_with('/'))
.map(String::from)
.collect()
}
/// Where `program` is: itself when it is an absolute path, otherwise the
/// first of that name on `PATH`. `None` when it is not there, or not a
/// program; spawning it as written then says why.
#[cfg(unix)]
fn find_program(program: &str) -> Option<PathBuf> {
if program.contains('/') {
let path = PathBuf::from(program);
return (path.is_absolute() && is_executable(&path)).then_some(path);
}
let paths = std::env::var_os("PATH")?;
std::env::split_paths(&paths)
.map(|dir| dir.join(program))
.find(|candidate| is_executable(candidate))
}
#[cfg(unix)]
fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt as _;
path.metadata()
.is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
}
/// The same program wherever its folder is reached from: the folder with
/// its links resolved, and the name as it is, so `sh` stays apart from the
/// `dash` it may link to.
#[cfg(unix)]
fn program_key(path: &Path) -> (PathBuf, Option<OsString>) {
let folder = path.parent().unwrap_or(Path::new("/"));
(
std::fs::canonicalize(folder).unwrap_or_else(|_| folder.to_path_buf()),
path.file_name().map(OsString::from),
)
}
#[cfg(windows)]
fn windows_default_shell() -> Option<PathBuf> {
find_on_path("pwsh.exe")
.or_else(installed_pwsh)
.or_else(windows_powershell)
}
/// PowerShell 7 where its installer puts it, for when it is not on `PATH`.
#[cfg(windows)]
fn installed_pwsh() -> Option<PathBuf> {
let path = PathBuf::from(std::env::var_os("ProgramFiles")?).join(r"PowerShell\7\pwsh.exe");
path.is_file().then_some(path)
}
#[cfg(windows)]
fn windows_powershell() -> Option<PathBuf> {
let path = PathBuf::from(std::env::var_os("SystemRoot")?)
.join(r"System32\WindowsPowerShell\v1.0\powershell.exe");
path.is_file().then_some(path)
}
/// The first `name` on `PATH`. Not `is_file`: PowerShell 7 from the
/// Microsoft Store is an app execution alias, which cannot be followed.
#[cfg(windows)]
fn find_on_path(name: &str) -> Option<PathBuf> {
let paths = std::env::var_os("PATH")?;
std::env::split_paths(&paths)
.map(|dir| dir.join(name))
.find(|candidate| {
candidate
.symlink_metadata()
.is_ok_and(|metadata| !metadata.is_dir())
})
}
#[cfg(test)]
mod tests {
use super::*;
fn words(line: &str, quoting: Quoting) -> Vec<String> {
split_command(line, quoting).expect("quotes are closed")
}
#[test]
fn a_blank_command_is_the_default_shell() {
assert_eq!(LocalShell::new(" ").command(), None);
assert_eq!(LocalShell::new(""), LocalShell::default());
assert_eq!(
LocalShell::new(" /bin/zsh -l ").command(),
Some("/bin/zsh -l")
);
}
#[test]
fn posix_commands_split_as_a_shell_reads_them() {
let posix = Quoting::Posix;
assert_eq!(words("/bin/zsh", posix), ["/bin/zsh"]);
assert_eq!(
words(" /bin/bash --login -i ", posix),
["/bin/bash", "--login", "-i"]
);
assert_eq!(
words(r#"'/opt/My Shells/fish' -C "echo \"hi\" \$HOME \n""#, posix),
["/opt/My Shells/fish", "-C", r#"echo "hi" $HOME \n"#]
);
assert_eq!(words(r"/opt/My\ Shells/nu", posix), ["/opt/My Shells/nu"]);
assert_eq!(words(r#"sh -c '' """#, posix), ["sh", "-c", "", ""]);
assert_eq!(words(r"a'b'c\", posix), [r"abc\"]);
assert!(words("", posix).is_empty());
assert_eq!(split_command("sh -c 'exit", posix), None);
assert_eq!(split_command(r#"sh -c "exit\""#, posix), None);
}
#[test]
fn windows_commands_keep_their_backslashes() {
let windows = Quoting::Windows;
assert_eq!(
words(
r#""C:\Program Files\PowerShell\7\pwsh.exe" -NoLogo"#,
windows
),
[r"C:\Program Files\PowerShell\7\pwsh.exe", "-NoLogo"]
);
assert_eq!(
words(r"C:\Windows\System32\cmd.exe /k cd \d", windows),
[r"C:\Windows\System32\cmd.exe", "/k", "cd", r"\d"]
);
// A single quote is no quote to Windows.
assert_eq!(
words("cmd /c echo 'a b'", windows),
["cmd", "/c", "echo", "'a", "b'"]
);
assert_eq!(split_command(r#""C:\Program Files"#, windows), None);
}
#[test]
fn a_chosen_file_reads_back_as_one_word() {
for (quoting, path) in [
(Quoting::Posix, "/bin/zsh"),
(
Quoting::Posix,
"/Applications/My Shell.app/Contents/MacOS/sh",
),
(Quoting::Posix, "/opt/it's/fish"),
(Quoting::Posix, "/opt/$HOME/\"x\"\\y"),
(Quoting::Windows, r"C:\Windows\System32\cmd.exe"),
(Quoting::Windows, r"C:\Program Files\PowerShell\7\pwsh.exe"),
] {
let quoted = quote_word_for(path, quoting);
assert_eq!(words(&quoted, quoting), [path], "{quoted}");
}
assert_eq!(quote_word_for("/bin/zsh", Quoting::Posix), "/bin/zsh");
assert_eq!(
quote_word_for(r"C:\Program Files\Git\bin\bash.exe", Quoting::Windows),
r#""C:\Program Files\Git\bin\bash.exe""#
);
}
#[test]
fn only_shells_named_on_their_own_start_as_login_shells() {
assert!(is_shell_name("zsh"));
assert!(is_shell_name("/opt/homebrew/bin/fish"));
assert!(!is_shell_name("tmux"));
assert!(!is_shell_name("/usr/bin/zsh5"));
}
#[test]
fn etc_shells_lists_paths_without_its_comments() {
let text = "# List of acceptable shells for chpass(1).\n\n/bin/bash\n /bin/zsh # default\n/usr/bin/tmux\nnot-a-path\n";
assert_eq!(
listed_shells(text),
["/bin/bash", "/bin/zsh", "/usr/bin/tmux"]
);
}
#[cfg(unix)]
#[test]
fn a_shell_on_its_own_is_a_login_shell_and_anything_else_runs_as_written() {
let builder = LocalShell::new("sh").command_builder().unwrap();
assert!(builder.is_default_prog());
let shell = builder.get_env("SHELL").unwrap().to_str().unwrap();
assert!(shell.starts_with('/') && shell.ends_with("/sh"), "{shell}");
let builder = LocalShell::new("/bin/sh -l").command_builder().unwrap();
assert!(!builder.is_default_prog());
assert_eq!(builder.get_argv(), &["/bin/sh", "-l"]);
// Not a shell, or not there: spawning it as written says why.
for command in ["/usr/bin/env", "/no/such/zsh"] {
let builder = LocalShell::new(command).command_builder().unwrap();
assert!(!builder.is_default_prog());
assert_eq!(builder.get_argv(), &[command]);
}
assert!(
LocalShell::default()
.command_builder()
.unwrap()
.is_default_prog()
);
let error = LocalShell::new("sh -c 'exit")
.command_builder()
.unwrap_err()
.to_string();
assert!(error.contains("sh -c 'exit"), "{error}");
}
#[cfg(unix)]
#[test]
fn the_installed_shells_are_common_programs_here_and_include_sh() {
let shells = installed_shells();
assert!(shells.iter().all(|shell| is_executable(Path::new(shell))));
assert!(
shells.iter().all(|shell| COMMON_SHELLS
.iter()
.any(|name| shell.ends_with(&format!("/{name}")))),
"{shells:?}"
);
assert!(
shells.iter().any(|shell| shell.ends_with("/sh")),
"{shells:?}"
);
assert!(default_shell_name().starts_with('/'));
}
#[cfg(unix)]
#[test]
fn only_common_shells_are_offered_each_program_once() {
let listed = [
"/bin/bash",
"/bin/csh",
"/bin/dash",
"/bin/zsh",
"/usr/bin/bash",
"/bin/sh",
]
.map(String::from);
let folders = ["/usr/bin", "/opt/homebrew/bin"].map(PathBuf::from);
let here = [
"/bin/bash",
"/bin/csh",
"/bin/dash",
"/bin/zsh",
"/usr/bin/bash",
"/usr/bin/zsh",
"/bin/sh",
"/opt/homebrew/bin/bash",
"/opt/homebrew/bin/fish",
];
let usable = |path: &Path| here.iter().any(|here| Path::new(here) == path);
// `/bin` is `/usr/bin` here, as on a merged `/usr`.
let key = |path: &Path| path.to_string_lossy().replace("/usr/bin/", "/bin/");
assert_eq!(
common_shells(&listed, &folders, usable, key),
[
"/bin/zsh",
"/bin/bash",
"/opt/homebrew/bin/bash",
"/bin/sh",
"/opt/homebrew/bin/fish",
]
);
}
#[cfg(windows)]
#[test]
fn windows_starts_powershell_before_the_command_prompt() {
let shells = installed_shells();
assert_eq!(shells.last().map(String::as_str), Some("cmd.exe"));
assert!(
shells.iter().any(|shell| shell == "powershell.exe"),
"{shells:?}"
);
let default = default_shell_name();
assert!(
matches!(default.as_str(), "pwsh.exe" | "powershell.exe"),
"{default}"
);
let builder = LocalShell::default().command_builder().unwrap();
assert!(!builder.is_default_prog());
let program = PathBuf::from(&builder.get_argv()[0]);
assert_eq!(
program.file_name().unwrap().to_str(),
Some(default.as_str())
);
}
}
+79 -14
View File
@@ -1,3 +1,6 @@
use std::borrow::Cow;
use std::sync::LazyLock;
use gpui_kit::component::{
ActiveTheme as _, Icon, Sizable as _,
button::Button,
@@ -8,6 +11,7 @@ use gpui_kit::component::{
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use regex::Regex;
use crate::app::{CatalogIcon, CenterTab, CloseLocalTerminal, RestartLocalTerminal};
use crate::i18n::t;
@@ -24,6 +28,7 @@ pub enum LocalTerminalPanelEvent {
Closed(LocalTerminalId),
StatusChanged(LocalTerminalId),
Notice(LocalTerminalId, TerminalNotice),
PasteProblem(SharedString),
}
/// A local login-shell Dock tab backed by the shared terminal view.
@@ -42,24 +47,36 @@ impl LocalTerminalPanel {
cx: &mut Context<Self>,
) -> Self {
let terminal = cx.new(|cx| {
TerminalView::new(
let mut terminal = TerminalView::new(
("local-terminal", id.0),
move |_| default_title(id),
factory,
window,
cx,
)
);
terminal.set_image_paste(super::ImagePaste::Local);
terminal
});
let subscriptions = vec![
cx.observe(&terminal, move |_, _, cx| {
cx.emit(LocalTerminalPanelEvent::StatusChanged(id));
cx.notify();
}),
cx.subscribe(&terminal, move |_, _, event: &TerminalEvent, cx| {
if let TerminalEvent::Notice(notice) = event {
cx.emit(LocalTerminalPanelEvent::Notice(id, notice.clone()));
}
}),
cx.subscribe(
&terminal,
move |_, _, event: &TerminalEvent, cx| match event {
TerminalEvent::Notice(notice) => {
cx.emit(LocalTerminalPanelEvent::Notice(id, notice.clone()));
}
TerminalEvent::PasteProblem(error) => {
cx.emit(LocalTerminalPanelEvent::PasteProblem(error.clone()));
}
TerminalEvent::Started
| TerminalEvent::PromptRequested(_)
| TerminalEvent::HostOsDetected(_)
| TerminalEvent::PasteImage { .. } => {}
},
),
];
Self {
@@ -88,18 +105,26 @@ impl LocalTerminalPanel {
self.terminal.read(cx).status(cx)
}
pub fn restart(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.terminal
.update(cx, |terminal, cx| terminal.restart(cx));
/// Start anew with `factory`, which runs the shell the settings name
/// now.
pub fn restart(
&mut self,
factory: SharedTerminalTransportFactory,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.terminal.update(cx, |terminal, cx| {
terminal.restart_with_factory(factory, cx)
});
let focus = self.terminal.read(cx).focus_handle();
window.focus(&focus, cx);
}
fn display_title(&self, cx: &App) -> SharedString {
self.terminal
.read(cx)
.title(cx)
.map_or_else(|| self.default_title(), SharedString::from)
self.terminal.read(cx).title(cx).map_or_else(
|| self.default_title(),
|title| short_title(&title).into_owned().into(),
)
}
}
@@ -224,6 +249,18 @@ impl Render for LocalTerminalPanel {
}
}
/// The shell's title without the folders of programs. The Windows console
/// titles a window with the whole path of what runs in it
/// (`C:\Windows\system32\cmd.exe - ping localhost`), which leaves a tab
/// little else; each such path shows as the program's name, `cmd`.
fn short_title(title: &str) -> Cow<'_, str> {
static PROGRAM_PATH: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)\b[a-z]:\\(?:[^\\/:*?"<>|\r\n]+\\)*([^\\/:*?"<>|\r\n]+?)\.exe\b"#)
.expect("pattern")
});
PROGRAM_PATH.replace_all(title, "$1")
}
/// 「本地终端 1」, in the interface language.
fn default_title(id: LocalTerminalId) -> SharedString {
t!("terminal.local.title", number = id.0)
@@ -247,3 +284,31 @@ fn tab_menu(
.separator();
close_tab_items(menu, CenterTab::LocalTerminal(id), group, panel, cx)
}
#[cfg(test)]
mod tests {
use super::short_title;
#[test]
fn a_programs_path_in_the_title_shows_as_its_name() {
for (title, shown) in [
(
r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
"powershell",
),
(r"C:\Windows\system32\cmd.exe", "cmd"),
(
r"C:\WINDOWS\system32\cmd.exe - ping localhost",
"cmd - ping localhost",
),
(r"C:\Program Files\PowerShell\7\pwsh.exe", "pwsh"),
(r"管理员: C:\Windows\system32\cmd.exe", "管理员: cmd"),
// What is not a program's path stays.
("xuz@mac: ~/src", "xuz@mac: ~/src"),
(r"D:\work", r"D:\work"),
("vim notes.exe.txt", "vim notes.exe.txt"),
] {
assert_eq!(short_title(title), shown, "{title}");
}
}
}
+13 -3
View File
@@ -6,10 +6,12 @@ mod font;
mod highlight;
mod links;
mod local_pty;
mod local_shell;
mod local_terminal_panel;
mod model;
mod mouse;
mod notices;
mod paste_image;
mod run;
mod scrollbar;
mod search;
@@ -27,12 +29,18 @@ pub use font::{
pub use highlight::{
HighlightColor, HighlightRule, HighlightSet, PatternError, TerminalHighlights, default_rules,
};
pub use local_pty::LocalPtyTransportFactory;
pub use local_pty::LocalPtyTransportProvider;
pub use local_shell::{LocalShell, default_shell_name, installed_shells, quote_word};
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 paste_image::{
ClipboardImage, ClipboardPaste, ImagePaste, MAX_BYTES, clipboard_paste, paste_file_name,
save_local,
};
pub use run::{
BusyReason, RunContext, RunEvent, RunFailure, RunHandle, RunProgress, RunShell, RunTiming,
TerminalRun,
@@ -46,7 +54,9 @@ pub use terminal_view::{
pub use theme::{TerminalColors, TerminalTheme};
pub(crate) use transport::send_event;
pub use transport::{
ExecRequest, ExecResult, FixedRemoteTerminalTransportProvider, RemoteTerminalTransportProvider,
ExecRequest, ExecResult, FixedLocalTerminalTransportProvider,
FixedRemoteTerminalTransportProvider, LocalTerminalTransportProvider,
RemoteTerminalTransportProvider, SharedLocalTerminalTransportProvider,
SharedRemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalTransport,
TerminalTransportCommand, TerminalTransportEvent, TerminalTransportFactory,
};
+37 -1
View File
@@ -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,
}
+228
View File
@@ -0,0 +1,228 @@
//! What a paste does when the clipboard holds a screenshot: write a file
//! the agent can read, and put that path on the input line.
//!
//! A local terminal writes into the system temp folder. A saved remote
//! host is written over SFTP by the workspace. An external connection does
//! not take images: another channel would end the bastion session.
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use gpui_kit::{ClipboardEntry, ClipboardItem, SharedString};
use crate::i18n::t;
/// Largest screenshot ShellRS will write. Bigger than that is refused.
pub const MAX_BYTES: usize = 8 * 1024 * 1024;
/// How many paste files to keep in the local folder.
const KEEP: usize = 20;
/// How a terminal handles a screenshot on the clipboard.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ImagePaste {
/// Write a file on this computer and paste its path.
Local,
/// Ask the owner to put the file on the host, then paste that path.
Remote,
/// Leave the image alone (an external connection, or a host not saved).
#[default]
Off,
}
/// Bytes to write, and the extension the file should have.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ClipboardImage {
pub bytes: Vec<u8>,
pub extension: &'static str,
}
/// What is on the clipboard for a terminal paste.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ClipboardPaste {
Text(String),
Image(ClipboardImage),
Empty,
}
/// Text wins when the clipboard has both: copying a path or a sentence
/// should still paste that, even if an app also put a thumbnail there.
pub fn clipboard_paste(item: &ClipboardItem) -> ClipboardPaste {
if let Some(text) = item.text().filter(|text| !text.is_empty()) {
return ClipboardPaste::Text(text);
}
for entry in item.entries() {
if let ClipboardEntry::Image(image) = entry {
if image.bytes.is_empty() {
continue;
}
return ClipboardPaste::Image(ClipboardImage {
bytes: image.bytes.clone(),
extension: image.format.extension(),
});
}
}
ClipboardPaste::Empty
}
/// The folder local pastes go to: the system temp directory, so they do
/// not sit next to the database.
pub fn pastes_dir() -> PathBuf {
std::env::temp_dir().join("shellrs-pastes")
}
/// A unique name such as `shellrs-paste-….png`.
pub fn paste_file_name(extension: &str) -> String {
let nanos = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
format!("shellrs-paste-{nanos:x}.{extension}")
}
/// Write `bytes` into `dir` under a new name. Creates the directory.
pub fn save_in(dir: &Path, bytes: &[u8], extension: &str) -> Result<PathBuf, SharedString> {
if bytes.is_empty() {
return Err(t!("terminal.paste.image_empty"));
}
if bytes.len() > MAX_BYTES {
return Err(t!("terminal.paste.image_too_large"));
}
create_private_dir(dir).map_err(|error| {
t!(
"terminal.paste.image_save_failed",
error = error.to_string()
)
})?;
prune(dir);
let path = dir.join(paste_file_name(extension));
write_private_file(&path, bytes).map_err(|error| {
t!(
"terminal.paste.image_save_failed",
error = error.to_string()
)
})?;
Ok(path)
}
/// Write a screenshot for a local terminal.
pub fn save_local(bytes: &[u8], extension: &str) -> Result<PathBuf, SharedString> {
save_in(&pastes_dir(), bytes, extension)
}
fn prune(dir: &Path) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
let mut files: Vec<(SystemTime, PathBuf)> = entries
.flatten()
.filter_map(|entry| {
let path = entry.path();
let meta = entry.metadata().ok()?;
if !meta.is_file() {
return None;
}
Some((meta.modified().ok()?, path))
})
.collect();
files.sort_by_key(|(modified, _)| std::cmp::Reverse(*modified));
let week = Duration::from_secs(7 * 24 * 3600);
let now = SystemTime::now();
for (index, (modified, path)) in files.into_iter().enumerate() {
let old = now.duration_since(modified).is_ok_and(|age| age > week);
if index >= KEEP || old {
let _ = fs::remove_file(path);
}
}
}
#[cfg(unix)]
fn create_private_dir(dir: &Path) -> std::io::Result<()> {
use std::os::unix::fs::DirBuilderExt as _;
fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)
}
#[cfg(not(unix))]
fn create_private_dir(dir: &Path) -> std::io::Result<()> {
fs::create_dir_all(dir)
}
#[cfg(unix)]
fn write_private_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::os::unix::fs::OpenOptionsExt as _;
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)?;
file.write_all(bytes)?;
file.sync_all()
}
#[cfg(not(unix))]
fn write_private_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)?;
file.write_all(bytes)?;
file.sync_all()
}
#[cfg(test)]
mod tests {
use super::*;
use gpui_kit::Image;
#[test]
fn text_on_the_clipboard_is_pasted_as_text() {
let item = ClipboardItem::new_string("hello".into());
assert_eq!(clipboard_paste(&item), ClipboardPaste::Text("hello".into()));
}
#[test]
fn an_image_without_text_is_pasted_as_a_file() {
let item = ClipboardItem::new_image(&Image {
format: gpui_kit::ImageFormat::Png,
bytes: b"png".to_vec(),
id: 1,
});
match clipboard_paste(&item) {
ClipboardPaste::Image(image) => {
assert_eq!(image.bytes, b"png");
assert_eq!(image.extension, "png");
}
other => panic!("{other:?}"),
}
}
#[test]
fn a_screenshot_is_written_where_a_local_agent_can_read_it() {
let dir = tempfile::tempdir().unwrap();
let path = save_in(dir.path(), b"png-bytes", "png").unwrap();
assert_eq!(fs::read(&path).unwrap(), b"png-bytes");
assert!(
path.file_name()
.unwrap()
.to_str()
.unwrap()
.starts_with("shellrs-paste-")
);
assert!(path.extension().is_some_and(|ext| ext == "png"));
}
#[test]
fn a_huge_image_is_refused() {
let dir = tempfile::tempdir().unwrap();
let bytes = vec![0; MAX_BYTES + 1];
assert_eq!(
save_in(dir.path(), &bytes, "png").unwrap_err(),
t!("terminal.paste.image_too_large")
);
}
}
+33 -3
View File
@@ -13,7 +13,7 @@ use crate::app::{
EditHost, OpenExplorer, ReconnectTerminal, RenameTerminal,
};
use crate::connection::{ConnectionPrompt, ConnectionPromptReply};
use crate::host::{HostId, HostOs, HostStore};
use crate::host::{ConnectionState, HostId, HostOs, HostStore};
use crate::i18n::t;
use crate::shared::{ClosableTabTitle, HostMark, LatencyLabel, RenamableTab, close_tab_items};
@@ -32,6 +32,12 @@ pub enum TerminalPanelEvent {
PromptRequested(RemoteTerminalId, HostId, ConnectionPrompt),
HostOsDetected(HostId, HostOs),
Notice(RemoteTerminalId, TerminalNotice),
/// A screenshot to write on the host, then paste as a path.
PasteImage {
bytes: Vec<u8>,
extension: &'static str,
},
PasteProblem(SharedString),
}
/// A remote-host Dock panel backed by the shared terminal engine. The
@@ -77,6 +83,12 @@ impl TerminalPanel {
cx,
);
terminal.set_menu_items(connection_menu_items(id, host_id));
terminal.set_reconnect_action(ReconnectTerminal(id), cx);
// Saved hosts can take another SFTP channel. External and
// quick connections cannot, or were not asked for yet.
if !store.read(cx).is_temporary(host_id) {
terminal.set_image_paste(super::ImagePaste::Remote);
}
terminal
});
let subscriptions = vec![
@@ -104,6 +116,15 @@ impl TerminalPanel {
TerminalEvent::Notice(notice) => {
cx.emit(TerminalPanelEvent::Notice(this.id, notice.clone()));
}
TerminalEvent::PasteImage { bytes, extension } => {
cx.emit(TerminalPanelEvent::PasteImage {
bytes: bytes.clone(),
extension,
});
}
TerminalEvent::PasteProblem(error) => {
cx.emit(TerminalPanelEvent::PasteProblem(error.clone()));
}
},
),
];
@@ -246,7 +267,15 @@ impl Panel for TerminalPanel {
.and_then(|host| host.os);
let mark = HostMark::new(("terminal-tab-os", id.0), self.default_title(cx), os).small();
let tab_menu = self.tab_menu(cx);
let state = match self.lifecycle(cx) {
TerminalLifecycle::Starting => ConnectionState::Connecting,
TerminalLifecycle::Running => ConnectionState::Connected,
TerminalLifecycle::Exited { .. }
| TerminalLifecycle::Failed(_)
| TerminalLifecycle::Closing => ConnectionState::Disconnected,
};
ClosableTabTitle::new(("terminal-tab", id.0), mark, self.tab_title(cx))
.connection_state(state)
.closable(("close-terminal", id.0), Box::new(CloseTerminal(id)))
.context_menu(move |menu, _, cx| tab_menu.build(menu, cx))
}
@@ -262,9 +291,10 @@ impl Panel for TerminalPanel {
Some(LatencyLabel::new(("terminal-latency", self.id.0), latency))
}
fn toolbar_buttons(&mut self, _: &mut Window, _: &mut Context<Self>) -> Option<Vec<Button>> {
fn toolbar_buttons(&mut self, _: &mut Window, cx: &mut Context<Self>) -> Option<Vec<Button>> {
let host_id = self.host_id;
let terminal_id = self.id;
let reconnect_dispatch = self.focus_handle(cx);
Some(vec![
Button::new(("sftp", terminal_id.0))
.icon(Icon::new(CatalogIcon::FolderTree))
@@ -277,7 +307,7 @@ impl Panel for TerminalPanel {
.icon(Icon::new(CatalogIcon::RefreshCw))
.tooltip(t!("terminal.remote.reconnect"))
.on_click(move |_, window, cx| {
window.dispatch_action(Box::new(ReconnectTerminal(terminal_id)), cx)
reconnect_dispatch.dispatch_action(&ReconnectTerminal(terminal_id), window, cx)
}),
])
}
+142 -16
View File
@@ -30,11 +30,13 @@ use crate::connection::{ConnectionPromptReply, Latency};
use crate::i18n::{UiLocale, t};
use super::mouse::{self, MouseReport, ReportButton, ReportKind};
use super::paste_image::{ClipboardPaste, ImagePaste, clipboard_paste, save_local};
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";
@@ -65,6 +67,17 @@ fn opens_links(modifiers: &Modifiers) -> bool {
}
}
fn reconnect_keystroke(event: &KeyDownEvent, lifecycle: &TerminalLifecycle) -> bool {
event.keystroke.key == "enter"
&& event.keystroke.modifiers == Modifiers::default()
&& !event.is_held
&& !event.prefer_character_input
&& matches!(
lifecycle,
TerminalLifecycle::Exited { .. } | TerminalLifecycle::Failed(_)
)
}
/// What a terminal is called for screen readers. Asked for each time it is
/// drawn, so it follows the interface language.
type TerminalLabel = Rc<dyn Fn(&App) -> SharedString>;
@@ -214,6 +227,8 @@ pub struct TerminalView {
context_menu: Option<(Entity<PopupMenu>, gpui_kit::Point<Pixels>)>,
context_menu_subscription: Option<Subscription>,
menu_items: Option<TerminalMenuItems>,
reconnect_action: Option<Box<dyn Action>>,
reconnect_pending: bool,
find: Option<FindBar>,
cursor_visible: bool,
focused: bool,
@@ -225,6 +240,8 @@ pub struct TerminalView {
reported_button: Option<ReportButton>,
/// The cell the program last heard of: motion is reported once a cell.
reported_cell: Option<(usize, usize)>,
/// What a screenshot on the clipboard does in this terminal.
image_paste: ImagePaste,
_subscriptions: Vec<Subscription>,
_blink_task: Task<()>,
}
@@ -304,6 +321,8 @@ impl TerminalView {
context_menu: None,
context_menu_subscription: None,
menu_items: None,
reconnect_action: None,
reconnect_pending: false,
find: None,
cursor_visible: true,
focused: false,
@@ -312,6 +331,7 @@ impl TerminalView {
link_tooltip_task: None,
reported_button: None,
reported_cell: None,
image_paste: ImagePaste::Off,
_subscriptions: subscriptions,
_blink_task: blink_task,
}
@@ -325,6 +345,13 @@ impl TerminalView {
self.focus_handle.clone()
}
/// Only remote panels opt in, with their own terminal-scoped action.
pub(super) fn set_reconnect_action(&mut self, action: impl Action, cx: &mut Context<Self>) {
self.reconnect_action = Some(Box::new(action));
self.engine
.update(cx, |engine, _| engine.enable_reconnect_on_enter());
}
pub fn restart(&mut self, cx: &mut Context<Self>) {
self.reset_interaction();
self.engine.update(cx, |engine, cx| engine.restart(cx));
@@ -347,6 +374,7 @@ impl TerminalView {
/// Forget everything in progress on the screen that is going away: IME
/// composition, a selection drag, the context menu and the find bar.
fn reset_interaction(&mut self) {
self.reconnect_pending = false;
self.marked_text.clear();
self.marked_selection = 0..0;
self.ime_cursor_bounds = None;
@@ -425,19 +453,52 @@ impl TerminalView {
}
pub fn paste_clipboard(&mut self, cx: &mut Context<Self>) {
let Some(item) = cx.read_from_clipboard() else {
return;
};
self.paste_item(item, cx);
}
/// What a screenshot on the clipboard does in this terminal.
pub fn set_image_paste(&mut self, image_paste: ImagePaste) {
self.image_paste = image_paste;
}
fn paste_item(&mut self, item: ClipboardItem, cx: &mut Context<Self>) {
if !self.engine.read(cx).lifecycle().accepts_input() {
return;
}
let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else {
return;
};
self.engine.read(cx).paste(&text);
match clipboard_paste(&item) {
ClipboardPaste::Text(text) => self.paste_text(&text, cx),
ClipboardPaste::Image(image) => self.paste_image(image, cx),
ClipboardPaste::Empty => {}
}
}
pub fn paste_text(&mut self, text: &str, cx: &mut Context<Self>) {
self.engine.read(cx).paste(text);
self.selecting = false;
self.scroll_accumulator.reset();
self.cursor_visible = true;
cx.notify();
}
fn paste_image(&mut self, image: super::ClipboardImage, cx: &mut Context<Self>) {
match self.image_paste {
ImagePaste::Local => match save_local(&image.bytes, image.extension) {
Ok(path) => self.paste_text(&path.to_string_lossy(), cx),
Err(error) => cx.emit(TerminalEvent::PasteProblem(error)),
},
ImagePaste::Remote => cx.emit(TerminalEvent::PasteImage {
bytes: image.bytes,
extension: image.extension,
}),
ImagePaste::Off => cx.emit(TerminalEvent::PasteProblem(t!(
"terminal.paste.image_unsupported"
))),
}
}
/// Put `command` on the shell's input line, with `replace` in place of
/// what is typed there, and with `run` run it; see
/// [`TerminalEngine::enter_command`].
@@ -563,7 +624,19 @@ impl TerminalView {
.update(cx, |engine, cx| engine.set_search_query(&query, cx));
}
fn on_key_down(&mut self, event: &KeyDownEvent, _: &mut Window, cx: &mut Context<Self>) {
fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
if self.focus_handle.is_focused(window)
&& !self.reconnect_pending
&& reconnect_keystroke(event, &self.lifecycle(cx))
&& let Some(action) = &self.reconnect_action
{
// Dispatch is deferred. Latch immediately so queued key events cannot
// restart this connection twice before its lifecycle becomes Starting.
self.reconnect_pending = true;
window.dispatch_action(action.boxed_clone(), cx);
cx.stop_propagation();
return;
}
let mode = self.engine.read(cx).mode();
if let Some(bytes) = encode_key(&event.keystroke, mode) {
self.send_user_input(bytes, cx);
@@ -641,6 +714,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 +818,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,
@@ -850,12 +944,7 @@ impl EntityInputHandler for TerminalView {
}
fn paste(&mut self, item: ClipboardItem, _: &mut Window, cx: &mut Context<Self>) {
if let Some(text) = item.text() {
self.engine.read(cx).paste(&text);
self.selecting = false;
self.scroll_accumulator.reset();
cx.notify();
}
self.paste_item(item, cx);
}
fn replace_text_in_range(
@@ -951,7 +1040,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 +1680,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 +1693,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);
});
});
@@ -2074,6 +2164,42 @@ mod tests {
Keystroke::parse(source).unwrap()
}
#[test]
fn reconnect_enter_requires_a_fresh_plain_key_and_stopped_lifecycle() {
let stopped = super::TerminalLifecycle::Exited {
code: 0,
signal: None,
};
let event = gpui_kit::KeyDownEvent {
keystroke: key("enter"),
is_held: false,
prefer_character_input: false,
};
assert!(super::reconnect_keystroke(&event, &stopped));
assert!(super::reconnect_keystroke(
&event,
&super::TerminalLifecycle::Failed("offline".into())
));
for state in [
super::TerminalLifecycle::Starting,
super::TerminalLifecycle::Running,
super::TerminalLifecycle::Closing,
] {
assert!(!super::reconnect_keystroke(&event, &state));
}
for name in ["shift-enter", "ctrl-enter", "alt-enter", "cmd-enter", "a"] {
let mut modified = event.clone();
modified.keystroke = key(name);
assert!(!super::reconnect_keystroke(&modified, &stopped));
}
let mut held = event.clone();
held.is_held = true;
assert!(!super::reconnect_keystroke(&held, &stopped));
let mut character_input = event;
character_input.prefer_character_input = true;
assert!(!super::reconnect_keystroke(&character_input, &stopped));
}
#[test]
fn encodes_control_and_cursor_keys() {
assert_eq!(
+46 -2
View File
@@ -1,9 +1,9 @@
use std::sync::{Arc, mpsc};
use std::sync::{Arc, Mutex, mpsc};
use anyhow::Result;
use async_channel::Sender;
use super::TerminalSize;
use super::{LocalShell, TerminalSize};
use crate::connection::Latency;
use crate::connection::{ConnectionPrompt, ConnectionPromptReply};
use crate::host::{HostLogin, HostOs};
@@ -100,6 +100,50 @@ pub trait RemoteTerminalTransportProvider: Send + Sync + 'static {
pub type SharedRemoteTerminalTransportProvider = Arc<dyn RemoteTerminalTransportProvider>;
/// Creates the factory of a local terminal for the shell the settings name,
/// asked when it opens and when it restarts, so a changed setting reaches
/// the next one. Like the remote provider, the workspace owns this boundary
/// and UI tests inject a fake.
pub trait LocalTerminalTransportProvider: Send + Sync + 'static {
fn factory_for(&self, shell: &LocalShell) -> SharedTerminalTransportFactory;
}
pub type SharedLocalTerminalTransportProvider = Arc<dyn LocalTerminalTransportProvider>;
/// For tests: every local terminal gets the same factory, and the shells
/// asked for are kept.
pub struct FixedLocalTerminalTransportProvider {
factory: SharedTerminalTransportFactory,
shells: Mutex<Vec<LocalShell>>,
}
impl FixedLocalTerminalTransportProvider {
pub fn new(factory: SharedTerminalTransportFactory) -> Self {
Self {
factory,
shells: Default::default(),
}
}
/// The shell of each local terminal started, in order.
pub fn shells(&self) -> Vec<LocalShell> {
self.shells
.lock()
.unwrap_or_else(|error| error.into_inner())
.clone()
}
}
impl LocalTerminalTransportProvider for FixedLocalTerminalTransportProvider {
fn factory_for(&self, shell: &LocalShell) -> SharedTerminalTransportFactory {
self.shells
.lock()
.unwrap_or_else(|error| error.into_inner())
.push(shell.clone());
self.factory.clone()
}
}
/// Provider useful for tests and embedders that want every host to use the
/// same factory.
pub struct FixedRemoteTerminalTransportProvider {
+12 -3
View File
@@ -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::*;
+22 -7
View File
@@ -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"
]
);
}
}
+43 -7
View File
@@ -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"]);
}
}
+4 -1
View File
@@ -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)]
+4 -5
View File
@@ -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 {
+1 -1
View File
@@ -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
View File
@@ -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!(
+147 -22
View File
@@ -62,15 +62,16 @@ use crate::settings::{
WindowSettings,
};
use crate::sftp::{
SharedLocalDirectoryProvider, SharedSftpTransportProvider, SshSftpTransportProvider,
SystemLocalDirectoryProvider, TransferDirection,
RemotePath, SharedLocalDirectoryProvider, SharedSftpTransportProvider,
SshSftpTransportProvider, SystemLocalDirectoryProvider, TransferDirection, put_bytes,
};
use crate::shared::{commit_footer, confirm_danger, open_rename_tab_dialog};
use crate::terminal::{
DEFAULT_FONT_SIZE, LocalPtyTransportFactory, LocalTerminalId, LocalTerminalPanel,
LocalTerminalPanelEvent, RemoteTerminalId, SearchDirection,
SharedRemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalLifecycle,
TerminalPanel, TerminalPanelEvent, TerminalView,
DEFAULT_FONT_SIZE, LocalPtyTransportProvider, LocalShell, LocalTerminalId, LocalTerminalPanel,
LocalTerminalPanelEvent, MAX_BYTES, RemoteTerminalId, SearchDirection,
SharedLocalTerminalTransportProvider, SharedRemoteTerminalTransportProvider,
SharedTerminalTransportFactory, TerminalLifecycle, TerminalPanel, TerminalPanelEvent,
TerminalView, paste_file_name,
};
use crate::update::{UpdateServices, Updater, UpdaterEvent};
@@ -179,7 +180,7 @@ pub struct Workspace {
/// 匿名使用统计. Production only, like the CLI server: UI tests hand in
/// one that only records.
pub(super) analytics: Option<super::analytics::Reporting>,
local_terminal_factory: SharedTerminalTransportFactory,
local_terminal_provider: SharedLocalTerminalTransportProvider,
remote_terminal_provider: SharedRemoteTerminalTransportProvider,
sftp_provider: SharedSftpTransportProvider,
pub(super) local_directory_provider: SharedLocalDirectoryProvider,
@@ -245,7 +246,7 @@ impl Workspace {
store,
settings,
remote,
Arc::new(LocalPtyTransportFactory),
Arc::new(LocalPtyTransportProvider),
sftp.clone(),
Arc::new(SystemLocalDirectoryProvider),
tester,
@@ -306,7 +307,7 @@ impl Workspace {
store: Entity<HostStore>,
settings: Entity<SettingsStore>,
remote_terminal_provider: SharedRemoteTerminalTransportProvider,
local_terminal_factory: SharedTerminalTransportFactory,
local_terminal_provider: SharedLocalTerminalTransportProvider,
sftp_provider: SharedSftpTransportProvider,
local_directory_provider: SharedLocalDirectoryProvider,
connection_tester: SharedConnectionTester,
@@ -540,7 +541,7 @@ impl Workspace {
cli_server: None,
updater,
analytics: None,
local_terminal_factory,
local_terminal_provider,
remote_terminal_provider,
sftp_provider,
local_directory_provider,
@@ -818,7 +819,6 @@ impl Workspace {
}
ConnectionPromptKind::HostKeyChanged(prompt) => {
let old = prompt.old_fingerprints().join(&t!("common.list.separator"));
let path = prompt.known_hosts_path().display().to_string();
let description = introduced(
match prompt.jump_host() {
Some(jump_host) => t!(
@@ -828,8 +828,7 @@ impl Workspace {
port = prompt.port(),
algorithm = prompt.algorithm(),
old = old,
fingerprint = prompt.fingerprint(),
path = path
fingerprint = prompt.fingerprint()
),
None => t!(
"workspace.host_key.changed",
@@ -837,22 +836,32 @@ impl Workspace {
port = prompt.port(),
algorithm = prompt.algorithm(),
old = old,
fingerprint = prompt.fingerprint(),
path = path
fingerprint = prompt.fingerprint()
),
}
.to_string(),
);
// Updating is the risky answer: it trusts a key that may be
// someone posing as the server.
window.open_alert_dialog(cx, move |alert, _, _| {
alert
.title(t!("workspace.host_key.changed_title"))
.description(description.clone())
.button_props(
DialogButtonProps::default()
.ok_text(t!("common.close"))
.ok_variant(ButtonVariant::Danger),
.ok_text(t!("workspace.host_key.update"))
.ok_variant(ButtonVariant::Danger)
.cancel_text(t!("common.cancel")),
)
.show_cancel(true)
.on_ok({
let answer = answer.clone();
move |_, _, cx| {
answer(ConnectionPromptReply::TrustAndSave, cx);
true
}
})
.on_cancel({
let answer = answer.clone();
move |_, _, cx| {
answer(ConnectionPromptReply::Cancel, cx);
@@ -1164,8 +1173,8 @@ impl Workspace {
) {
let id = LocalTerminalId(self.next_local_terminal_id);
self.next_local_terminal_id += 1;
let (panel, subscription) =
new_local_terminal_panel(id, self.local_terminal_factory.clone(), window, cx);
let factory = self.local_terminal_factory(cx);
let (panel, subscription) = new_local_terminal_panel(id, factory, window, cx);
self._subscriptions.push(subscription);
let focus = panel.read(cx).focus_handle(cx);
self._subscriptions.push(follow_focus(
@@ -1736,11 +1745,20 @@ impl Workspace {
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(panel) = self.local_terminals.get(&action.0) {
panel.update(cx, |panel, cx| panel.restart(window, cx));
if let Some(panel) = self.local_terminals.get(&action.0).cloned() {
let factory = self.local_terminal_factory(cx);
panel.update(cx, |panel, cx| panel.restart(factory, window, cx));
}
}
/// A transport for a local terminal starting now: the shell
/// 设置 › 终端 › 本地终端 names at this moment.
fn local_terminal_factory(&self, cx: &App) -> SharedTerminalTransportFactory {
let settings = self.settings.read(cx).settings();
let shell = LocalShell::new(settings.local_terminal.shell.as_deref().unwrap_or_default());
self.local_terminal_provider.factory_for(&shell)
}
/// The terminal of the center tab shown most recently, remote or local.
fn active_terminal(&self, cx: &App) -> Option<Entity<TerminalView>> {
match self.active_tab? {
@@ -1764,6 +1782,89 @@ impl Workspace {
}
}
/// Write a screenshot onto a saved host over SFTP, then paste the path.
fn paste_image_to_saved_host(
&mut self,
terminal_id: RemoteTerminalId,
host_id: HostId,
bytes: Vec<u8>,
extension: &'static str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let fail = |window: &mut Window, cx: &mut Context<Self>, error: SharedString| {
window.push_notification(
Notification::error(error).title(t!("terminal.paste.image_title")),
cx,
);
};
if bytes.len() > MAX_BYTES {
fail(window, cx, t!("terminal.paste.image_too_large"));
return;
}
if self.store.read(cx).saved_host(host_id).is_none() {
fail(window, cx, t!("terminal.paste.image_unsupported"));
return;
}
let Some(login) = self.store.read(cx).login(host_id) else {
fail(window, cx, t!("terminal.paste.image_unsupported"));
return;
};
let Some(terminal) = self
.terminals
.get(&terminal_id)
.map(|panel| panel.read(cx).terminal().clone())
else {
return;
};
if !terminal.read(cx).lifecycle(cx).accepts_input() {
fail(window, cx, t!("terminal.command.not_connected"));
return;
}
let name = paste_file_name(extension);
let path = match RemotePath::new(format!("/tmp/{name}")) {
Ok(path) => path,
Err(error) => {
fail(
window,
cx,
t!(
"terminal.paste.image_upload_failed",
error = error.to_string()
),
);
return;
}
};
let pasted = path.to_string();
let provider = self.sftp_provider.clone();
cx.spawn_in(window, async move |this, cx| {
let result = cx
.background_spawn(async move { put_bytes(&provider, &login, &path, bytes) })
.await;
let _ = this.update_in(cx, |this, window, cx| match result {
Ok(()) => {
if let Some(terminal) = this
.terminals
.get(&terminal_id)
.map(|panel| panel.read(cx).terminal().clone())
{
terminal.update(cx, |terminal, cx| {
terminal.paste_text(&pasted, cx);
});
}
}
Err(error) => {
window.push_notification(
Notification::error(error).title(t!("terminal.paste.image_title")),
cx,
);
}
});
})
.detach();
}
fn on_find_in_terminal(
&mut self,
_: &FindInTerminal,
@@ -2260,7 +2361,7 @@ fn new_terminal_panel(
let subscription = cx.subscribe_in(
&panel,
window,
|this, _, event: &TerminalPanelEvent, window, cx| match event {
|this, panel, event: &TerminalPanelEvent, window, cx| match event {
TerminalPanelEvent::Activated(terminal_id, _) => {
this.set_active_tab(Some(CenterTab::Terminal(*terminal_id)), window, cx)
}
@@ -2300,6 +2401,24 @@ fn new_terminal_panel(
TerminalPanelEvent::Notice(terminal_id, notice) => {
this.on_terminal_notice(CenterTab::Terminal(*terminal_id), notice, window, cx)
}
TerminalPanelEvent::PasteImage { bytes, extension } => {
let host_id = panel.read(cx).host_id();
let terminal_id = panel.read(cx).id();
this.paste_image_to_saved_host(
terminal_id,
host_id,
bytes.clone(),
extension,
window,
cx,
);
}
TerminalPanelEvent::PasteProblem(error) => {
window.push_notification(
Notification::error(error.clone()).title(t!("terminal.paste.image_title")),
cx,
);
}
},
);
(panel, subscription)
@@ -2462,6 +2581,12 @@ fn new_local_terminal_panel(
LocalTerminalPanelEvent::Notice(id, notice) => {
this.on_terminal_notice(CenterTab::LocalTerminal(*id), notice, window, cx)
}
LocalTerminalPanelEvent::PasteProblem(error) => {
window.push_notification(
Notification::error(error.clone()).title(t!("terminal.paste.image_title")),
cx,
);
}
},
);
(panel, subscription)
+23 -9
View File
@@ -98,10 +98,21 @@ fn a_host_from_the_cli_lands_in_the_tree_with_its_group_and_password(cx: &mut Te
host: Some("10.0.9.2".into()),
..HostFields::default()
};
let error = apply(cx, handle, &workspace, CliChange::CreateHost(nameless)).unwrap_err();
let Ok(Reply::Host(created)) = apply(cx, handle, &workspace, CliChange::CreateHost(nameless))
else {
panic!("host without a name not created");
};
assert_eq!(created.name, "10.0.9.2");
let error = apply(
cx,
handle,
&workspace,
CliChange::CreateHost(HostFields::default()),
)
.unwrap_err();
assert_eq!(
(error.code, error.message.as_str()),
(ErrorCode::BadRequest, "请输入名称")
(ErrorCode::BadRequest, "请输入地址")
);
}
@@ -455,25 +466,28 @@ async fn a_cli_run_a_terminal_cannot_take_says_why(cx: &mut TestAppContext) {
// Another run has the terminal: no ^C to end it.
let web = host_id(&workspace, 1, cx);
let (first, first_events) = TerminalRun::new("sleep 9".into());
let before = factory.writes.lock().unwrap().len();
assert_eq!(start_run(&workspace, &web, &first, cx), Ok(()));
let before = factory.written_text().len();
let (second, _) = TerminalRun::new("true".into());
assert_eq!(
start_run(&workspace, &web, &second, cx),
Err(RunFailure::Busy(BusyReason::AnotherRun))
);
assert_eq!(factory.written_text().len(), before);
// A shell it cannot type into: only the probe was typed.
assert_eq!(first_events.recv().unwrap(), RunEvent::Accepted);
assert_eq!(
first.drive(&first_events, QUICK, &mut |_| Ok(())),
Err(RunFailure::UnsupportedShell("zsh".into()))
);
assert_eq!(
factory.written_text()[before..].matches(" sh -c '").count(),
1
);
// The probe reply follows the writes in the fake transport's FIFO.
// Checking now includes the first run's legitimate Ctrl-C and still
// rejects any extra write from the refused second run.
let writes = factory.writes.lock().unwrap()[before..].to_vec();
assert_eq!(writes.len(), 2);
assert_eq!(writes[0].as_slice(), b"\x03");
let probe = std::str::from_utf8(&writes[1]).unwrap();
assert_eq!(probe.matches(" sh -c '").count(), 1);
assert!(probe.contains("6973;P;"));
// The terminal disconnected.
web_terminal(&workspace, cx).update(cx, |view, cx| view.stop("closed", cx));
+448 -5
View File
@@ -7,6 +7,7 @@ use crate::support::*;
enum PromptBehavior {
Authentication,
UnknownHost,
HostKeyChanged,
}
struct PromptTerminalFactory {
@@ -61,6 +62,13 @@ impl TerminalTransport for PromptTerminalTransport {
"ssh-ed25519",
"SHA256:test-fingerprint",
),
PromptBehavior::HostKeyChanged => ConnectionPromptKind::host_key_changed(
"example.test",
22,
"ssh-ed25519",
vec!["SHA256:old-fingerprint".into()],
"SHA256:new-fingerprint",
),
};
events.send_blocking(TerminalTransportEvent::Prompt(ConnectionPrompt::new(
request_id, kind,
@@ -72,7 +80,10 @@ impl TerminalTransport for PromptTerminalTransport {
reply,
} if reply_id == request_id => match reply {
ConnectionPromptReply::TrustAndSave
if matches!(self.behavior, PromptBehavior::UnknownHost) =>
if matches!(
self.behavior,
PromptBehavior::UnknownHost | PromptBehavior::HostKeyChanged
) =>
{
self.accepted.fetch_add(1, Ordering::SeqCst);
break;
@@ -142,6 +153,13 @@ async fn authentication_prompt_is_masked_and_drives_connected_state(cx: &mut Tes
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(
window
.within(("terminal-tab", 1u64))
.find("connection-status")
.label(),
Some("连接中")
);
let password = window.find(("ssh-auth-answer", 0usize));
assert_eq!(
password.value(),
@@ -156,6 +174,10 @@ async fn authentication_prompt_is_masked_and_drives_connected_state(cx: &mut Tes
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.find("status-connection").label() == Some("已连接 prompt-host")
&& window
.within(("terminal-tab", 1u64))
.try_find("connection-status")
.is_none()
})
.await;
@@ -171,6 +193,41 @@ async fn authentication_prompt_is_masked_and_drives_connected_state(cx: &mut Tes
}));
}
#[gpui_kit::test]
async fn a_changed_host_key_is_updated_from_its_question(cx: &mut TestAppContext) {
let (store, id) = one_host_store(AuthKind::Password);
let factory = Arc::new(PromptTerminalFactory::new(PromptBehavior::HostKeyChanged));
let (handle, _) = open_workspace_with_remote_factory(cx, store, factory.clone());
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window
.within("host-tree")
.double_click(("host-row", id.0), cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.try_find("ok").is_some()
})
.await;
// Two ways out: keep the saved key, or update it and go on.
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(window.find("ok").label(), Some("更新并连接"));
assert!(window.try_find("cancel").is_some());
window.click("ok", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.find("status-connection").label() == Some("已连接 prompt-host")
})
.await;
assert_eq!(factory.accepted.load(Ordering::SeqCst), 1);
assert_eq!(factory.canceled.load(Ordering::SeqCst), 0);
}
#[gpui_kit::test]
async fn canceling_unknown_host_prompt_keeps_host_disconnected(cx: &mut TestAppContext) {
let (store, id) = one_host_store(AuthKind::Password);
@@ -213,6 +270,11 @@ async fn canceling_unknown_host_prompt_keeps_host_disconnected(cx: &mut TestAppC
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.find("status-connection").label() == Some("未连接 prompt-host")
&& window
.within(("terminal-tab", 1u64))
.find("connection-status")
.label()
== Some("未连接")
&& factory.canceled.load(Ordering::SeqCst) == 1
})
.await;
@@ -448,6 +510,15 @@ async fn disconnecting_one_tab_leaves_the_other_tabs_of_its_host(cx: &mut TestAp
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
// Connected tabs carry no dot.
for id in [INITIAL_WEB_TERMINAL, second.0] {
assert!(
window
.within(("terminal-tab", id))
.try_find("connection-status")
.is_none()
);
}
window.dispatch_action(Box::new(DisconnectTerminal(second)), cx);
})
.unwrap();
@@ -477,11 +548,70 @@ async fn disconnecting_one_tab_leaves_the_other_tabs_of_its_host(cx: &mut TestAp
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.dispatch_action(Box::new(ReconnectTerminal(second)), cx);
assert!(
window
.within(("terminal-tab", INITIAL_WEB_TERMINAL))
.try_find("connection-status")
.is_none()
);
let tab = window.within(("terminal-tab", second.0));
assert_eq!(tab.find("connection-status").label(), Some("未连接"));
let mark = tab.find(("terminal-tab-os", second.0)).bounds();
let dot = tab.find("connection-status").bounds();
let title = window.find(("terminal-tab", second.0)).bounds();
let close = window.find(("close-terminal", second.0)).bounds();
assert_eq!(mark.center().y, close.center().y);
let centers = dot.center() - mark.center();
let distance = (f32::from(centers.x).powi(2) + f32::from(centers.y).powi(2)).sqrt();
assert!(distance > f32::from(mark.size.width + dot.size.width) / 2.);
assert!(dot.right() < close.left());
assert_eq!(mark.center().y, title.center().y);
window.click(("terminal", second.0), cx);
window.press("enter", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| {
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
remote_lifecycle(&workspace, second, cx) == TerminalLifecycle::Running
&& window
.within(("terminal-tab", second.0))
.try_find("connection-status")
.is_none()
})
.await;
}
#[gpui_kit::test]
async fn failed_connection_tab_reports_disconnected_then_recovers(cx: &mut TestAppContext) {
let (store, host_id) = one_host_store(AuthKind::Password);
let factory = Arc::new(FakeTerminalFactory::fail_first());
let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory);
let terminal_id = RemoteTerminalId(1);
in_frame(cx, handle, |window, cx| {
window.dispatch_action(Box::new(ConnectHost(host_id)), cx);
});
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
matches!(
remote_lifecycle(&workspace, terminal_id, cx),
TerminalLifecycle::Failed(_)
) && window
.within(("terminal-tab", terminal_id.0))
.find("connection-status")
.label()
== Some("未连接")
})
.await;
in_frame(cx, handle, |window, cx| {
window.dispatch_action(Box::new(ReconnectTerminal(terminal_id)), cx);
});
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
remote_lifecycle(&workspace, terminal_id, cx) == TerminalLifecycle::Running
&& window
.within(("terminal-tab", terminal_id.0))
.try_find("connection-status")
.is_none()
})
.await;
}
@@ -801,10 +931,10 @@ async fn a_temporary_connection_has_every_feature_and_saves_nothing(cx: &mut Tes
window.click("host-address", cx);
window.input("10.0.0.9", cx);
window.click("host-port", cx);
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("2222", cx);
window.click("host-user", cx);
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("deploy", cx);
window.click("host-password", cx);
window.input("p@ss", cx);
@@ -912,3 +1042,316 @@ async fn a_link_that_cannot_be_read_says_why_and_opens_nothing(cx: &mut TestAppC
assert!(workspace.store().read(cx).host(LINK_HOST).is_none());
});
}
/// Initial errors and later exits both retain their tab and retry through
/// that tab's regular reconnect action, without leaking Enter to the shell.
async fn enter_reconnects_stopped_remote(
cx: &mut TestAppContext,
factory: Arc<FakeTerminalFactory>,
) {
let (store, host) = one_host_store(AuthKind::Password);
let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone());
let terminal = RemoteTerminalId(1);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window
.within("host-tree")
.double_click(("host-row", host.0), cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| {
matches!(
remote_lifecycle(&workspace, terminal, cx),
TerminalLifecycle::Failed(_) | TerminalLifecycle::Exited { .. }
)
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
let screen = workspace
.read(cx)
.remote_terminal(terminal)
.unwrap()
.read(cx)
.terminal()
.read(cx)
.screen_text(cx);
assert!(screen.contains("按 Enter 重新连接"), "{screen}");
window.click(("terminal", terminal.0), cx);
for key in ["shift-enter", "ctrl-enter", "alt-enter"] {
window.press(key, cx);
}
cx.write_to_clipboard(ClipboardItem::new_string("\n".into()));
window.dispatch_action(Box::new(shellrs::app::PasteTerminal), cx);
window.dispatch_action(Box::new(FindInTerminal), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("terminal-find", cx);
window.press("enter", cx);
})
.unwrap();
cx.run_until_parked();
assert_eq!(
factory.starts(),
1,
"modifiers, paste and find must not reconnect"
);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click(("terminal", terminal.0), cx);
window.press("enter", cx);
window.press("enter", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| {
remote_lifecycle(&workspace, terminal, cx) == TerminalLifecycle::Running
})
.await;
assert_eq!(factory.starts(), 2);
assert_eq!(
factory.written_text(),
"",
"reconnect Enter must not reach the new shell"
);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(window.find(("terminal", terminal.0)).focused(), Some(true));
window.press("enter", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |_, _| {
factory.written_text() == "\r"
})
.await;
assert_eq!(factory.starts(), 2, "connected Enter is ordinary input");
}
#[gpui_kit::test]
async fn enter_reconnects_failed_remote(cx: &mut TestAppContext) {
enter_reconnects_stopped_remote(cx, Arc::new(FakeTerminalFactory::fail_first())).await;
}
#[gpui_kit::test]
async fn enter_reconnects_exited_remote(cx: &mut TestAppContext) {
enter_reconnects_stopped_remote(cx, Arc::new(FakeTerminalFactory::exit_first())).await;
}
#[gpui_kit::test]
async fn enter_retries_canceled_authentication_in_the_same_tab(cx: &mut TestAppContext) {
let (store, host) = one_host_store(AuthKind::Password);
let factory = Arc::new(PromptTerminalFactory::new(PromptBehavior::Authentication));
let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone());
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window
.within("host-tree")
.double_click(("host-row", host.0), cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.try_find("ssh-auth-submit").is_some()
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.press("escape", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
matches!(
remote_lifecycle(&workspace, RemoteTerminalId(1), cx),
TerminalLifecycle::Exited { .. }
) && window.try_find("ssh-auth-submit").is_none()
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click(("terminal", 1_u64), cx);
window.press("enter", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.try_find("ssh-auth-submit").is_some()
})
.await;
cx.update(|cx| {
assert_eq!(
remote_lifecycle(&workspace, RemoteTerminalId(1), cx),
TerminalLifecycle::Starting
);
assert!(
workspace
.read(cx)
.remote_terminal(RemoteTerminalId(2))
.is_none()
);
});
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.press("escape", cx);
})
.unwrap();
cx.run_until_parked();
}
/// Exercise the production title in the same TabBar used by the dock, with
/// and without its badge. Geometry must be invariant under state/selection.
struct TabStatusLayout {
state: ConnectionState,
selected: usize,
rem: f32,
}
impl gpui_kit::Render for TabStatusLayout {
fn render(
&mut self,
window: &mut gpui_kit::Window,
_: &mut gpui_kit::Context<Self>,
) -> impl gpui_kit::IntoElement {
use gpui_kit::component::{
Sizable as _,
tab::{Tab, TabBar},
};
use gpui_kit::{InteractiveElement as _, ParentElement as _};
use shellrs::shared::{ClosableTabTitle, HostMark};
window.set_rem_size(px(self.rem));
let title = |id| {
ClosableTabTitle::new(
("layout-title", id),
HostMark::new(("layout-mark", id), "host", Some(HostOs::Ubuntu)).small(),
"host",
)
.closable(("layout-close", id), Box::new(shellrs::app::CloseActiveTab))
};
TabBar::new("layout-tabs")
.selected_index(self.selected)
.child(
Tab::new()
.debug_selector(|| "plain-tab".into())
.child(title(0u64)),
)
.child(
Tab::new()
.debug_selector(|| "status-tab".into())
.child(title(1u64).connection_state(self.state)),
)
}
}
#[gpui_kit::test]
fn connection_badge_preserves_icon_alignment_and_fits_the_tab(cx: &mut TestAppContext) {
init_app(cx);
let (view, cx) = cx.add_window_view(|_, _| TabStatusLayout {
state: ConnectionState::Connected,
selected: 0,
rem: 16.,
});
// The headless platform fixes device scaling at 2x; vary rem sizing too.
for rem in [12., 14., 16., 18.] {
for selected in [0, 1] {
for state in [
ConnectionState::Connected,
ConnectionState::Connecting,
ConnectionState::Disconnected,
] {
view.update(cx, |view, cx| {
view.rem = rem;
view.selected = selected;
view.state = state;
cx.notify();
});
cx.run_until_parked();
cx.update(|window, cx| window.render_frame(cx));
let clipped_tab = cx.debug_bounds("status-tab").unwrap();
cx.update(|window, _| {
let plain = window.find(("layout-title", 0u64)).bounds();
let title = window.find(("layout-title", 1u64)).bounds();
let plain_mark = window.find(("layout-mark", 0u64)).bounds();
let mark = window.find(("layout-mark", 1u64)).bounds();
let close = window.find(("layout-close", 1u64)).bounds();
assert_eq!(title.size, plain.size, "badge must not affect title layout");
assert_eq!(mark.size, plain_mark.size, "badge must not shrink the icon");
assert_eq!(
mark.center() - title.origin,
plain_mark.center() - plain.origin
);
assert_eq!(mark.center().y, close.center().y);
assert_eq!(mark.center().y, title.center().y);
// A connection that is up is the normal state: no dot.
let dot = window
.within(("layout-title", 1u64))
.try_find("connection-status");
assert_eq!(dot.is_none(), state == ConnectionState::Connected);
let Some(dot) = dot else {
return;
};
let dot = dot.bounds();
let centers = dot.center() - mark.center();
let distance =
(f32::from(centers.x).powi(2) + f32::from(centers.y).powi(2)).sqrt();
assert!(
distance > f32::from(mark.size.width + dot.size.width) / 2.,
"circles must not overlap"
);
assert!(
dot.bottom() <= clipped_tab.bottom() - px(1.),
"badge must fit the dock tab"
);
assert!(
dot.right() < mark.right() + px(rem / 4.),
"badge must stop before the label"
);
assert!(dot.right() < close.left());
});
}
}
}
}
#[gpui_kit::test]
async fn reconnect_toolbar_works_without_window_focus(cx: &mut TestAppContext) {
let (store, host) = one_host_store(AuthKind::Password);
let factory = Arc::new(FakeTerminalFactory::default());
let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone());
in_frame(cx, handle, |window, cx| {
window
.within("host-tree")
.double_click(("host-row", host.0), cx);
});
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
workspace
.read(cx)
.remote_terminal(RemoteTerminalId(1))
.unwrap()
.read(cx)
.lifecycle(cx)
== TerminalLifecycle::Running
})
.await;
let starts = factory.starts();
in_frame(cx, handle, |window, cx| window.blur(cx));
in_frame(cx, handle, |window, cx| {
assert!(window.focused(cx).is_none());
window.click(("reconnect", 1u64), cx);
});
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
factory.starts() > starts
&& workspace
.read(cx)
.remote_terminal(RemoteTerminalId(1))
.unwrap()
.read(cx)
.lifecycle(cx)
== TerminalLifecycle::Running
})
.await;
assert_eq!(factory.starts(), starts + 1);
}
+8 -10
View File
@@ -82,7 +82,7 @@ async fn a_new_password_credential_keeps_its_password_in_the_keychain(cx: &mut T
assert!(window.find("credential-password").visible());
window.input("运维", cx);
window.click("credential-user", cx);
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("deploy", cx);
window.click("credential-password", cx);
window.input("hunter2", cx);
@@ -234,8 +234,6 @@ async fn a_host_can_use_a_credential_instead_of_typing_a_login(cx: &mut TestAppC
);
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
in_frame(cx, handle, |window, cx| {
window.click("host-name", cx);
window.input("web-01", cx);
window.click("host-address", cx);
window.input("10.0.1.12", cx);
window.within("host-auth-source").click(1usize, cx);
@@ -274,8 +272,8 @@ async fn a_host_can_use_a_credential_instead_of_typing_a_login(cx: &mut TestAppC
let created = store
.hosts()
.iter()
.find(|host| host.name == "web-01")
.expect("web-01 inserted");
.find(|host| host.name == "10.0.1.12")
.expect("host named after its address inserted");
assert_eq!(created.credential, Some(credential));
assert_eq!(created.user.as_ref(), "deploy");
assert_eq!(created.auth, AuthKind::Password);
@@ -391,7 +389,7 @@ async fn editing_a_credentials_user_reconnects_the_hosts_using_it(cx: &mut TestA
)
);
window.click("credential-name", cx);
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("生产运维", cx);
window.click("commit", cx);
});
@@ -405,7 +403,7 @@ async fn editing_a_credentials_user_reconnects_the_hosts_using_it(cx: &mut TestA
});
in_frame(cx, handle, |window, cx| {
window.click("credential-user", cx);
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("admin", cx);
window.click("commit", cx);
});
@@ -622,7 +620,7 @@ async fn a_pasted_key_is_checked_then_kept_by_shellrs(cx: &mut TestAppContext) {
in_frame(cx, handle, |window, cx| {
assert!(window.try_find("credential-key-path").is_none());
window.click("credential-key-text", cx);
window.press("cmd-v", cx);
window.press("secondary-v", cx);
});
in_frame(cx, handle, |window, cx| {
assert!(window.try_find("credential-public-key").is_none());
@@ -638,8 +636,8 @@ async fn a_pasted_key_is_checked_then_kept_by_shellrs(cx: &mut TestAppContext) {
cx.update(|cx| cx.write_to_clipboard(ClipboardItem::new_string(text.clone())));
in_frame(cx, handle, |window, cx| {
window.click("credential-key-text", cx);
window.press("cmd-a", cx);
window.press("cmd-v", cx);
window.press("secondary-a", cx);
window.press("secondary-v", cx);
});
in_frame(cx, handle, |window, cx| {
assert_eq!(
+10 -10
View File
@@ -204,7 +204,7 @@ async fn saving_writes_the_file_back_and_clears_the_mark(cx: &mut TestAppContext
);
});
in_frame(cx, handle, |window, cx| window.press("cmd-s", cx));
in_frame(cx, handle, |window, cx| window.press("secondary-s", cx));
wait_for_writes(cx, &provider, 1).await;
cx.run_until_parked();
@@ -227,7 +227,7 @@ async fn saving_writes_the_file_back_and_clears_the_mark(cx: &mut TestAppContext
// Undoing back to the saved text is no change either; the next save
// expects the file as saved.
type_text(cx, handle, "x");
in_frame(cx, handle, |window, cx| window.press("cmd-s", cx));
in_frame(cx, handle, |window, cx| window.press("secondary-s", cx));
wait_for_writes(cx, &provider, 2).await;
let expected = provider.writes.lock().unwrap()[1].2;
assert_eq!(expected, Some(fake_stamp(b"# hello world\n", 101)));
@@ -249,7 +249,7 @@ async fn crlf_and_a_byte_order_mark_are_written_back_as_they_were(cx: &mut TestA
});
type_text(cx, handle, "c=3\n");
in_frame(cx, handle, |window, cx| window.press("cmd-s", cx));
in_frame(cx, handle, |window, cx| window.press("secondary-s", cx));
wait_for_writes(cx, &provider, 1).await;
assert_eq!(
provider.writes.lock().unwrap()[0].1,
@@ -265,7 +265,7 @@ async fn a_file_changed_on_the_server_is_overwritten_only_when_asked(cx: &mut Te
type_text(cx, handle, "1");
provider.change_file(REMOTE_FILE, b"theirs\n");
in_frame(cx, handle, |window, cx| window.press("cmd-s", cx));
in_frame(cx, handle, |window, cx| window.press("secondary-s", cx));
wait_for(cx, handle, "ok").await;
// Not written: the file is theirs, and the editor keeps its changes.
assert_eq!(provider.file(REMOTE_FILE), b"theirs\n");
@@ -279,7 +279,7 @@ async fn a_file_changed_on_the_server_is_overwritten_only_when_asked(cx: &mut Te
);
});
in_frame(cx, handle, |window, cx| window.press("cmd-s", cx));
in_frame(cx, handle, |window, cx| window.press("secondary-s", cx));
wait_for(cx, handle, "ok").await;
in_frame(cx, handle, |window, cx| window.click("ok", cx));
wait_for_writes(cx, &provider, 3).await;
@@ -391,7 +391,7 @@ async fn a_local_file_is_edited_on_this_machine(cx: &mut TestAppContext) {
});
type_text(cx, handle, "edited ");
in_frame(cx, handle, |window, cx| window.press("cmd-s", cx));
in_frame(cx, handle, |window, cx| window.press("secondary-s", cx));
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.try_find(("editor-state", FIRST.0)).is_none()
@@ -449,7 +449,7 @@ async fn saving_while_disconnected_offers_to_reconnect(cx: &mut TestAppContext)
})
.await;
in_frame(cx, handle, |window, cx| window.press("cmd-s", cx));
in_frame(cx, handle, |window, cx| window.press("secondary-s", cx));
wait_for(cx, handle, "ok").await;
assert!(provider.writes.lock().unwrap().is_empty());
assert!(cx.update(|cx| {
@@ -797,15 +797,15 @@ async fn a_previewed_image_zooms_and_scrolls(cx: &mut TestAppContext) {
// The buttons and the keys step the zoom.
in_frame(cx, handle, |window, cx| window.click("preview-zoom-in", cx));
wait_for_zoom(cx, handle, "125%").await;
in_frame(cx, handle, |window, cx| window.press("cmd--", cx));
in_frame(cx, handle, |window, cx| window.press("secondary--", cx));
wait_for_zoom(cx, handle, "100%").await;
in_frame(cx, handle, |window, cx| window.press("cmd-9", cx));
in_frame(cx, handle, |window, cx| window.press("secondary-9", cx));
wait_for_zoom(cx, handle, "28%").await;
in_frame(cx, handle, |window, cx| {
window.click("preview-zoom-out", cx)
});
wait_for_zoom(cx, handle, "25%").await;
in_frame(cx, handle, |window, cx| window.press("cmd-0", cx));
in_frame(cx, handle, |window, cx| window.press("secondary-0", cx));
wait_for_zoom(cx, handle, "100%").await;
in_frame(cx, handle, |window, cx| window.click("preview-fit", cx));
wait_for_zoom(cx, handle, "28%").await;
+1 -1
View File
@@ -286,7 +286,7 @@ async fn a_remote_forward_swaps_the_machines_in_the_picture_and_the_labels(
assert!(window.try_find("forward-notes").is_none());
// Listening beyond the server's loopback needs the server's consent.
window.click("forward-bind-host", cx);
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("0.0.0.0", cx);
});
in_frame(cx, handle, |window, cx| {
+66 -7
View File
@@ -270,7 +270,7 @@ async fn a_hosts_notes_take_several_lines_and_come_back_when_edited(cx: &mut Tes
});
in_frame(cx, handle, |window, cx| {
window.click("host-notes", cx);
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("已下线", cx);
window.click("commit", cx);
});
@@ -479,6 +479,65 @@ async fn a_form_error_sits_above_the_dialog_and_a_click_puts_it_away(cx: &mut Te
});
}
#[gpui_kit::test]
async fn a_host_without_an_address_is_not_saved(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace_with_store(cx, HostStore::empty());
for (name, address) in [("", ""), ("", " "), ("web-01", "")] {
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
in_frame(cx, handle, |window, cx| {
window.click("host-name", cx);
window.input(name, cx);
window.click("host-address", cx);
window.input(address, cx);
window.click("commit", cx);
});
in_frame(cx, handle, |window, _| {
assert!(window.find("commit").visible());
assert_eq!(window.find("form-error").label(), Some("请输入地址"));
});
cx.update(|cx| assert!(workspace.read(cx).store().read(cx).hosts().is_empty()));
in_frame(cx, handle, |window, cx| window.press("escape", cx));
wait_for_dialog_to_close(cx, handle).await;
}
}
#[gpui_kit::test]
async fn a_blank_host_name_uses_the_address_and_is_saved(cx: &mut TestAppContext) {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("shellrs.db");
let store = HostStore::load(HostDatabase::open(&path).unwrap()).unwrap();
let (handle, workspace) = open_workspace_with_store(cx, store);
for (name, address) in [("", "example.com"), (" ", "10.0.0.2")] {
in_frame(cx, handle, |window, cx| {
// Saving closes the dialog and leaves focus outside the host panel.
window.click("host-search", cx);
window.click("new-host-panel", cx);
});
in_frame(cx, handle, |window, cx| {
window.click("host-name", cx);
window.input(name, cx);
window.click("host-address", cx);
window.input(&format!(" {address} "), cx);
window.click("commit", cx);
});
wait_for_dialog_to_close(cx, handle).await;
cx.update(|cx| {
let store = workspace.read(cx).store().read(cx);
let host = store
.hosts()
.iter()
.find(|host| host.address == address)
.unwrap();
assert_eq!(host.name.as_ref(), address);
});
}
let saved = HostDatabase::open(&path).unwrap().load().unwrap();
assert_eq!(saved.hosts.len(), 2);
for host in saved.hosts {
assert_eq!(host.name, host.address);
}
}
#[gpui_kit::test]
async fn new_host_dialog_validates_then_inserts(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);
@@ -527,7 +586,7 @@ async fn new_host_dialog_validates_then_inserts(cx: &mut TestAppContext) {
// The error pops up as a notification over the dialog, not as a line
// at the bottom of the form.
assert_eq!(window.notifications(cx).len(), 1);
assert_eq!(window.find("form-error").label(), Some("请输入名称"));
assert_eq!(window.find("form-error").label(), Some("请输入地址"));
window.click("commit", cx);
})
.unwrap();
@@ -589,10 +648,10 @@ async fn testing_a_connection_logs_in_with_what_the_form_shows(cx: &mut TestAppC
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("host-password", cx);
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("wrong-password", cx);
window.click("host-port", cx);
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("2222", cx);
window.click("test-connection", cx);
})
@@ -665,7 +724,7 @@ async fn a_connection_test_needs_a_host_and_a_user_first(cx: &mut TestAppContext
window.input("10.0.3.7", cx);
window.click("host-user", cx);
#[cfg(target_os = "macos")]
window.press("cmd-a", cx);
window.press("secondary-a", cx);
#[cfg(not(target_os = "macos"))]
window.press("ctrl-a", cx);
window.press("backspace", cx);
@@ -834,7 +893,7 @@ async fn editing_the_host_moves_the_saved_password_with_it(cx: &mut TestAppConte
window.render_frame(cx);
assert!(window.find("host-password").visible());
window.click("host-address", cx);
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("10.9.9.9", cx);
window.click("commit", cx);
})
@@ -882,7 +941,7 @@ async fn clearing_the_password_field_forgets_the_saved_password(cx: &mut TestApp
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("host-password", cx);
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.press("backspace", cx);
window.click("commit", cx);
})
+1 -1
View File
@@ -476,7 +476,7 @@ async fn renaming_a_group_keeps_the_hosts_under_it(cx: &mut TestAppContext) {
window.render_frame(cx);
assert!(window.find("commit").visible());
window.click("group-name", cx);
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("生产环境", cx);
window.click("commit", cx);
})
+17 -13
View File
@@ -159,7 +159,7 @@ async fn the_search_narrows_the_list(cx: &mut TestAppContext) {
// A PID finds its process.
in_frame(cx, handle, |window, cx| {
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("913756", cx);
});
in_frame(cx, handle, |window, _| {
@@ -172,7 +172,7 @@ async fn the_search_narrows_the_list(cx: &mut TestAppContext) {
});
in_frame(cx, handle, |window, cx| {
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("mysql", cx);
});
in_frame(cx, handle, |window, _| {
@@ -238,17 +238,21 @@ async fn the_connections_are_read_each_time_they_come_on_screen_and_only_then(
cx.run_until_parked();
assert_eq!(factory.exec_count(), 1);
// Back, the list it had shows at once while it is read again.
in_frame(cx, handle, |window, cx| {
window.click("tool-connections", cx)
});
in_frame(cx, handle, |window, _| {
assert!(
window
.try_find("netstat-socket:tcp 0.0.0.0:80 0.0.0.0:*")
.is_some()
);
});
// Keep the fake refresh pending while checking the cached list; an
// immediate reply could otherwise replace it before the next frame.
{
let _pending_refresh = factory.exec_outputs.lock().unwrap();
in_frame(cx, handle, |window, cx| {
window.click("tool-connections", cx)
});
in_frame(cx, handle, |window, _| {
assert!(
window
.try_find("netstat-socket:tcp 0.0.0.0:80 0.0.0.0:*")
.is_some()
);
});
}
wait_for_label(
cx,
handle,
+2 -2
View File
@@ -199,7 +199,7 @@ async fn the_search_finds_processes_by_name_pid_or_user(cx: &mut TestAppContext)
});
in_frame(cx, handle, |window, cx| {
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("2202", cx);
});
in_frame(cx, handle, |window, _| {
@@ -207,7 +207,7 @@ async fn the_search_finds_processes_by_name_pid_or_user(cx: &mut TestAppContext)
});
in_frame(cx, handle, |window, cx| {
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("nginx", cx);
});
in_frame(cx, handle, |window, _| {
+1 -1
View File
@@ -190,7 +190,7 @@ async fn the_tabs_and_the_search_narrow_the_list(cx: &mut TestAppContext) {
});
in_frame(cx, handle, |window, cx| {
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("nothing-like-it", cx);
});
in_frame(cx, handle, |window, _| {
+4 -4
View File
@@ -112,8 +112,8 @@ fn settings_open_from_the_host_list_as_one_tab(cx: &mut TestAppContext) {
window.render_frame(cx);
assert!(window.find("settings").visible());
assert_eq!(window.find("settings").focused(), Some(true));
// ⌘W closes it like any other tab.
window.press("cmd-w", cx);
// The platform close shortcut closes it like any other tab.
window.press("secondary-w", cx);
})
.unwrap();
cx.run_until_parked();
@@ -579,7 +579,7 @@ async fn highlight_rules_are_edited_in_place_on_their_page(cx: &mut TestAppConte
window.find(("highlight-rule-error", 3usize)).label(),
Some("正则表达式写法有误")
);
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input(r"OutOfMemory\w*", cx);
});
in_frame(cx, handle, |window, cx| {
@@ -587,7 +587,7 @@ async fn highlight_rules_are_edited_in_place_on_their_page(cx: &mut TestAppConte
window.click(("highlight-rule-note", 3usize), cx);
window.input("内存溢出", cx);
window.click(("highlight-rule-color", 3usize), cx);
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("#D946EF", cx);
window.click(("highlight-rule-notify", 3usize), cx);
window.click(("highlight-rule-enabled", 0usize), cx);
+210 -6
View File
@@ -832,7 +832,7 @@ async fn sftp_bookmark_dialog_adds_orders_removes_and_opens(cx: &mut TestAppCont
assert_eq!(window.find("bookmark:/home/tester").selected(), Some(true));
assert!(!enabled(&window.find("bookmark-add")), "already bookmarked");
window.click("open-directory-path", cx);
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("/etc/", cx);
})
.unwrap();
@@ -1198,7 +1198,17 @@ async fn sftp_tab_reconnects_from_its_tab_bar_and_reports_at_the_bottom_left(
let status_is = |expected: &'static str| {
move |window: &mut gpui_kit::Window, cx: &mut App| {
window.render_frame(cx);
window.find("status-connection").label() == Some(expected)
window.find("status-connection").label() == Some(expected) && {
// Only a connection that is not up has a dot.
let dot = window
.within(("explorer-tab", SFTP_TAB))
.try_find("connection-status");
if expected.starts_with("已连接") {
dot.is_none()
} else {
dot.is_some_and(|dot| dot.label() == Some("未连接"))
}
}
}
};
let table = cx
@@ -1208,6 +1218,12 @@ async fn sftp_tab_reconnects_from_its_tab_bar_and_reports_at_the_bottom_left(
window.find("status-connection").label(),
Some("已连接 db-01")
);
assert!(
window
.within(("explorer-tab", SFTP_TAB))
.try_find("connection-status")
.is_none()
);
window
.within(("remote-pane", SFTP_TAB))
.find("table")
@@ -1228,6 +1244,17 @@ async fn sftp_tab_reconnects_from_its_tab_bar_and_reports_at_the_bottom_left(
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
let tab = window.within(("explorer-tab", SFTP_TAB));
let mark = tab.find(("explorer-tab-os", SFTP_TAB)).bounds();
let dot = tab.find("connection-status").bounds();
let title = window.find(("explorer-tab", SFTP_TAB)).bounds();
let close = window.find(("close-explorer", SFTP_TAB)).bounds();
assert_eq!(mark.center().y, close.center().y);
assert_eq!(mark.center().y, title.center().y);
let centers = dot.center() - mark.center();
let distance = (f32::from(centers.x).powi(2) + f32::from(centers.y).powi(2)).sqrt();
assert!(distance > f32::from(mark.size.width + dot.size.width) / 2.);
assert!(dot.right() < close.left());
let remote = window.within(("remote-pane", SFTP_TAB));
assert_eq!(remote.find("table").bounds(), table);
// Still there to click: it answers with the offer to reconnect.
@@ -1271,6 +1298,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};
@@ -1337,7 +1408,7 @@ async fn sftp_file_commands_confirm_validate_and_send_one_operation(cx: &mut Tes
window.render_frame(cx);
if let Some(typed) = typed {
window.click("entry-name", cx);
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.press("backspace", cx);
window.input(typed, cx);
}
@@ -1356,7 +1427,7 @@ async fn sftp_file_commands_confirm_validate_and_send_one_operation(cx: &mut Tes
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("entry-name", cx);
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("新名字.txt", cx);
window.click("commit", cx);
})
@@ -1514,6 +1585,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,
@@ -1674,7 +1878,7 @@ async fn the_sftp_tab_can_be_renamed_and_follow_the_host_again(cx: &mut TestAppC
window.render_frame(cx);
assert_eq!(window.find("tab-name").value(), Some(default.as_str()));
window.click("tab-name", cx);
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("生产库文件", cx);
window.click("commit", cx);
})
@@ -1702,7 +1906,7 @@ async fn the_sftp_tab_can_be_renamed_and_follow_the_host_again(cx: &mut TestAppC
window.render_frame(cx);
assert_eq!(window.find("tab-name").value(), Some("生产库文件"));
window.click("tab-name", cx);
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.press("backspace", cx);
window.click("commit", cx);
})
+3 -3
View File
@@ -241,7 +241,7 @@ async fn a_snippet_is_made_edited_and_deleted(cx: &mut TestAppContext) {
// Edited in the same dialog, where a click is told only to type it.
dispatch_from_panel(cx, handle, EditSnippet(SnippetId(1)));
in_frame(cx, handle, |window, cx| {
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("监听端口", cx);
window.click("snippet-run-on-click", cx);
window.click("commit", cx);
@@ -282,7 +282,7 @@ async fn a_category_is_made_renamed_and_deleted_with_its_snippets(cx: &mut TestA
.await;
in_frame(cx, handle, |window, cx| {
window.click("snippet-category-name", cx);
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("部署", cx);
window.click("commit", cx);
});
@@ -311,7 +311,7 @@ async fn a_category_is_made_renamed_and_deleted_with_its_snippets(cx: &mut TestA
dispatch_from_panel(cx, handle, RenameSnippetCategory(SnippetCategoryId(LOGS)));
in_frame(cx, handle, |window, cx| {
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("日志查看", cx);
window.click("commit", cx);
});
+1 -1
View File
@@ -25,7 +25,7 @@ pub fn open_workspace_with_tester(
store,
cx.new(|_| settings_store()),
remote,
Arc::new(FakeTerminalFactory::default()),
fake_local_terminals(),
Arc::new(FakeSftpProvider::default()),
Arc::new(FakeLocalDirectory::default()),
tester.clone(),
+1 -1
View File
@@ -21,7 +21,7 @@ pub fn open_workspace_with_credentials(
store,
cx.new(|_| settings_store()),
remote,
Arc::new(FakeTerminalFactory::default()),
fake_local_terminals(),
Arc::new(FakeSftpProvider::default()),
Arc::new(FakeLocalDirectory::default()),
tester,
+1 -1
View File
@@ -157,7 +157,7 @@ pub fn open_sized_workspace_with_forwards(
store,
cx.new(|_| settings_store()),
remote,
Arc::new(FakeTerminalFactory::default()),
fake_local_terminals(),
Arc::new(FakeSftpProvider::default()),
Arc::new(FakeLocalDirectory::default()),
Arc::new(FakeConnectionTester::default()),
+4 -4
View File
@@ -42,10 +42,10 @@ pub use shellrs::sftp::{
SftpTransport, SftpTransportProvider, TextFile, UploadRequest,
};
pub use shellrs::terminal::{
FixedRemoteTerminalTransportProvider, LocalTerminalId, RemoteTerminalId,
RemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalFont,
TerminalLifecycle, TerminalSize, TerminalTransport, TerminalTransportCommand,
TerminalTransportEvent, TerminalTransportFactory,
FixedLocalTerminalTransportProvider, FixedRemoteTerminalTransportProvider, LocalShell,
LocalTerminalId, RemoteTerminalId, RemoteTerminalTransportProvider,
SharedTerminalTransportFactory, TerminalFont, TerminalLifecycle, TerminalSize,
TerminalTransport, TerminalTransportCommand, TerminalTransportEvent, TerminalTransportFactory,
};
pub use shellrs::update::{
Channel, InstallKind, Installer, Relaunch, Release, Staged, TrustedKeys, Unsupported,
+6 -1
View File
@@ -271,6 +271,8 @@ pub struct FakeLocalDirectory {
pub calls: Arc<Mutex<Vec<String>>>,
/// The files the editor reads and writes, in memory.
pub files: Arc<Mutex<std::collections::BTreeMap<std::path::PathBuf, Vec<u8>>>>,
/// The places it offers, such as a drive plugged in.
pub places: Arc<Mutex<Vec<(shellrs::sftp::Place, std::path::PathBuf)>>>,
}
impl FakeLocalDirectory {
@@ -287,6 +289,9 @@ impl LocalDirectoryProvider for FakeLocalDirectory {
fn list(&self, path: &std::path::Path) -> anyhow::Result<DirectoryListing> {
Ok(fake_listing(path.to_str().unwrap()))
}
fn places(&self) -> Vec<(shellrs::sftp::Place, std::path::PathBuf)> {
self.places.lock().unwrap().clone()
}
fn trash(&self, paths: &[std::path::PathBuf]) -> anyhow::Result<()> {
self.record(format!("trash {paths:?}"))
}
@@ -450,7 +455,7 @@ pub fn open_workspace_with_services(
store,
cx.new(|_| settings_store()),
remote,
Arc::new(FakeTerminalFactory::default()),
fake_local_terminals(),
provider,
Arc::new(local),
Arc::new(FakeConnectionTester::default()),
+8 -1
View File
@@ -239,6 +239,13 @@ fn shell_answer(behavior: FakeBehavior, typed: &[u8]) -> Option<Vec<u8>> {
Some(answer.into_bytes())
}
/// Local terminals that start, and never print anything.
pub fn fake_local_terminals() -> Arc<FixedLocalTerminalTransportProvider> {
Arc::new(FixedLocalTerminalTransportProvider::new(Arc::new(
FakeTerminalFactory::default(),
)))
}
pub fn open_workspace_with_remote_factory(
cx: &mut TestAppContext,
store: HostStore,
@@ -256,7 +263,7 @@ pub fn open_workspace_with_remote_factory(
store,
cx.new(|_| settings_store()),
remote,
Arc::new(FakeTerminalFactory::default()),
fake_local_terminals(),
Arc::new(FakeSftpProvider::default()),
Arc::new(FakeLocalDirectory::default()),
Arc::new(FakeConnectionTester::default()),
+301 -2
View File
@@ -2,16 +2,30 @@
//! 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,
factory: Arc<FakeTerminalFactory>,
) -> (WindowHandle<Root>, Entity<Workspace>) {
open_workspace_with_local_terminals(
cx,
Arc::new(FixedLocalTerminalTransportProvider::new(factory)),
size(px(1280.), px(800.)),
)
}
/// With `local` asked for each local terminal's start.
fn open_workspace_with_local_terminals(
cx: &mut TestAppContext,
local: Arc<FixedLocalTerminalTransportProvider>,
window_size: gpui_kit::Size<gpui_kit::Pixels>,
) -> (WindowHandle<Root>, Entity<Workspace>) {
init_app(cx);
// Dialogs still, as in every fixture: see `open_workspace_with_tester`.
cx.update(|cx| cx.set_reduce_motion(true));
let mut workspace = None;
let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| {
let handle = cx.open_window(window_size, |window, cx| {
let store = cx.new(|_| HostStore::seed());
let remote = Arc::new(FixedRemoteTerminalTransportProvider::new(Arc::new(
FakeTerminalFactory::default(),
@@ -21,7 +35,7 @@ fn open_workspace_with_factory(
store,
cx.new(|_| settings_store()),
remote,
factory.clone(),
local.clone(),
Arc::new(FakeSftpProvider::default()),
Arc::new(FakeLocalDirectory::default()),
Arc::new(FakeConnectionTester::default()),
@@ -60,6 +74,12 @@ async fn creates_independent_local_terminals_from_button_and_shortcut(cx: &mut T
window.render_frame(cx);
assert!(window.find(("local-terminal", 1_u64)).visible());
assert_eq!(window.find(("local-terminal", 1_u64)).focused(), Some(true));
assert!(
window
.within(("local-terminal-tab", 1_u64))
.try_find("connection-status")
.is_none()
);
#[cfg(target_os = "macos")]
window.press("cmd-t", cx);
#[cfg(not(target_os = "macos"))]
@@ -141,6 +161,80 @@ async fn tab_keys_are_sent_to_the_focused_terminal(cx: &mut TestAppContext) {
.unwrap();
}
#[gpui_kit::test]
async fn local_terminals_run_the_shell_the_settings_name(cx: &mut TestAppContext) {
let local = fake_local_terminals();
// Tall enough for the whole of 终端, whose groups the page's list
// draws only near the view.
let (handle, workspace) =
open_workspace_with_local_terminals(cx, local.clone(), size(px(1280.), px(1600.)));
let settings = cx.update(|cx| workspace.read(cx).settings().clone());
// Until one is named, the system's default shell.
in_frame(cx, handle, |window, cx| {
window.click("new-local-terminal", cx)
});
assert_eq!(local.shells(), [LocalShell::default()]);
// 终端 › 本地终端, the page's last group: the field is empty, and what
// is typed in it is the setting at once.
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| {
// The field runs across the row under its title, 选择文件… at its
// end, inside the page.
let field = window.find("local-shell").bounds();
let choose = window.find("browse-local-shell").bounds();
let page = window.find("settings").bounds();
assert!(
field.size.width > page.size.width / 2.,
"{field:?} in {page:?}"
);
assert!(field.right() <= choose.left() && choose.right() <= page.right());
assert_eq!(window.find("local-shell").value(), Some(""));
window.click("local-shell", cx);
window.input("/bin/bash -l ", cx);
});
let shell =
|cx: &mut TestAppContext| cx.update(|cx| settings.read(cx).settings().local_terminal.shell);
assert_eq!(shell(cx).as_deref(), Some("/bin/bash -l"));
in_frame(cx, handle, |window, _| {
// The blank typed last stays in the field, ready for what follows.
assert_eq!(window.find("local-shell").value(), Some("/bin/bash -l "));
});
// The open terminal takes it when it restarts, a new one at once.
in_frame(cx, handle, |window, cx| {
window.click(("local-terminal-tab", 1_u64), cx)
});
in_frame(cx, handle, |window, cx| {
window.click(("restart-local-terminal-toolbar", 1_u64), cx)
});
in_frame(cx, handle, |window, cx| {
window.click("new-local-terminal", cx)
});
let bash = LocalShell::new("/bin/bash -l");
assert_eq!(
local.shells(),
[LocalShell::default(), bash.clone(), bash.clone()]
);
// Set back elsewhere (重置, the file read anew), the field follows.
settings.update(cx, |store, cx| {
store.update(|settings| settings.local_terminal.shell = None, cx)
});
in_frame(cx, handle, |window, cx| window.click("open-settings", cx));
in_frame(cx, handle, |window, _| {
assert_eq!(window.find("local-shell").value(), Some(""));
});
in_frame(cx, handle, |window, cx| {
window.click("new-local-terminal", cx)
});
assert_eq!(local.shells().last(), Some(&LocalShell::default()));
}
#[gpui_kit::test]
async fn exited_local_terminal_keeps_its_tab_and_restarts_fresh(cx: &mut TestAppContext) {
let factory = Arc::new(FakeTerminalFactory::exit_first());
@@ -161,6 +255,14 @@ async fn exited_local_terminal_keeps_its_tab_and_restarts_fresh(cx: &mut TestApp
window.render_frame(cx);
assert!(window.find(("local-terminal", 1_u64)).visible());
assert!(window.find(("local-terminal-exit", 1_u64)).visible());
window.click(("local-terminal", 1_u64), cx);
window.press("enter", cx);
assert_eq!(
factory.starts(),
1,
"local shells do not opt into SSH reconnect"
);
assert!(!local_screen(&workspace, cx).contains("按 Enter 重新连接"));
cx.write_to_clipboard(ClipboardItem::new_string("退出后不可粘贴".into()));
#[cfg(target_os = "macos")]
window.press("cmd-v", cx);
@@ -363,6 +465,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());
@@ -1169,3 +1358,113 @@ async fn a_matching_line_notifies_from_a_terminal_out_of_sight(cx: &mut TestAppC
let shown = system_notifications(cx, 2).await;
assert_eq!(shown[1].body.as_ref(), "12:05 ERROR later");
}
/// A 1×1 PNG.
const PASTE_PNG: &[u8] = b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\x0d\x49\x44\x41\x54\x78\x9c\x63\xf8\xcf\xc0\xf0\x1f\x00\x05\x00\x01\xff\x89\x99\x3d\x1d\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82";
fn screenshot_on_clipboard(cx: &mut TestAppContext) {
cx.update(|cx| {
cx.write_to_clipboard(ClipboardItem::new_image(&gpui_kit::Image {
format: gpui_kit::ImageFormat::Png,
bytes: PASTE_PNG.to_vec(),
id: 1,
}));
});
}
#[gpui_kit::test]
async fn pasting_a_screenshot_into_a_local_terminal_writes_a_file_and_its_path(
cx: &mut TestAppContext,
) {
let factory = Arc::new(FakeTerminalFactory::default());
let (_handle, workspace) = open_running_local_terminal(cx, factory.clone()).await;
screenshot_on_clipboard(cx);
cx.update(|cx| {
let terminal = workspace
.read(cx)
.local_terminal(LocalTerminalId(1))
.expect("the terminal is open")
.read(cx)
.terminal()
.clone();
terminal.update(cx, |terminal, cx| terminal.paste_clipboard(cx));
});
let written = factory.written_text();
let path = written
.lines()
.find(|line| line.contains("shellrs-paste-") && line.ends_with(".png"))
.unwrap_or_else(|| panic!("expected a paste path, got {written:?}"));
let path = path.trim();
assert_eq!(std::fs::read(path).expect(path), PASTE_PNG);
let _ = std::fs::remove_file(path);
}
#[gpui_kit::test]
async fn pasting_a_screenshot_onto_a_saved_host_writes_it_over_sftp(cx: &mut TestAppContext) {
init_app(cx);
cx.update(|cx| cx.set_reduce_motion(true));
let remote = Arc::new(FakeTerminalFactory::default());
let sftp = Arc::new(FakeSftpProvider::default());
let mut workspace = None;
let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| {
let store = cx.new(|_| HostStore::seed());
let view = cx.new(|cx| {
Workspace::new_with_services(
store,
cx.new(|_| settings_store()),
Arc::new(FixedRemoteTerminalTransportProvider::new(remote.clone())),
Arc::new(FixedLocalTerminalTransportProvider::new(Arc::new(
FakeTerminalFactory::default(),
))),
sftp.clone(),
Arc::new(FakeLocalDirectory::default()),
Arc::new(FakeConnectionTester::default()),
Arc::new(FakeForwardProvider::default()),
window,
cx,
)
});
workspace = Some(view.clone());
Root::new(view, window, cx)
});
let workspace = workspace.expect("workspace created");
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
window.try_find("status-connection").is_some_and(|element| {
element
.label()
.is_some_and(|label| label.contains("已连接"))
})
})
.await;
screenshot_on_clipboard(cx);
cx.update(|cx| {
let terminal = workspace
.read(cx)
.remote_terminal(RemoteTerminalId(INITIAL_WEB_TERMINAL))
.expect("web-01 is open")
.read(cx)
.terminal()
.clone();
terminal.update(cx, |terminal, cx| terminal.paste_clipboard(cx));
});
cx.wait_for(handle.into(), Duration::from_secs(2), |_, _| {
!sftp.writes.lock().unwrap().is_empty()
})
.await;
cx.run_until_parked();
let writes = sftp.writes.lock().unwrap().clone();
assert_eq!(writes.len(), 1);
let (path, bytes, _) = &writes[0];
assert!(
path.starts_with("/tmp/shellrs-paste-") && path.ends_with(".png"),
"{path}"
);
assert_eq!(bytes, PASTE_PNG);
assert!(
remote.written_text().contains(path),
"{}",
remote.written_text()
);
}
+2 -2
View File
@@ -50,7 +50,7 @@ async fn sftp_multi_selection_keyboard_upload_freezes_paths_and_cancel_resumes(
}
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.press("cmd-a", cx);
window.press("secondary-a", cx);
})
.unwrap();
cx.run_until_parked();
@@ -101,7 +101,7 @@ async fn sftp_multi_selection_keyboard_upload_freezes_paths_and_cancel_resumes(
Some("链接目录")
);
window.click("upload-target", cx);
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("/固定目标", cx);
window.click("upload-confirm", cx);
})
+1 -1
View File
@@ -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.
+8 -8
View File
@@ -340,7 +340,7 @@ fn close_shortcut_closes_the_displayed_tab_down_to_none(cx: &mut TestAppContext)
window.render_frame(cx);
// The new tab is displayed, with its input focused.
assert!(window.find(("terminal", FIRST_NEW_TERMINAL)).visible());
window.press("cmd-w", cx);
window.press("secondary-w", cx);
})
.unwrap();
cx.run_until_parked();
@@ -350,14 +350,14 @@ fn close_shortcut_closes_the_displayed_tab_down_to_none(cx: &mut TestAppContext)
assert!(window.try_find(("terminal", FIRST_NEW_TERMINAL)).is_none());
assert!(window.try_find("recent-hosts").is_none());
// Keep closing whichever tab the dock displays next.
window.press("cmd-w", cx);
window.press("secondary-w", cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.press("cmd-w", cx);
window.press("secondary-w", cx);
})
.unwrap();
cx.run_until_parked();
@@ -376,7 +376,7 @@ fn close_shortcut_closes_the_displayed_tab_down_to_none(cx: &mut TestAppContext)
);
assert!(window.find("recent-hosts").visible());
// With nothing open the shortcut does nothing.
window.press("cmd-w", cx);
window.press("secondary-w", cx);
})
.unwrap();
cx.run_until_parked();
@@ -510,7 +510,7 @@ async fn a_terminal_tab_can_be_renamed_and_follow_the_host_again(cx: &mut TestAp
window.render_frame(cx);
assert_eq!(window.find("tab-name").value(), Some("web-01"));
window.click("tab-name", cx);
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.input("日志排查", cx);
window.click("commit", cx);
})
@@ -556,7 +556,7 @@ async fn a_terminal_tab_can_be_renamed_and_follow_the_host_again(cx: &mut TestAp
window.render_frame(cx);
assert_eq!(window.find("tab-name").value(), Some("日志排查"));
window.click("tab-name", cx);
window.press("cmd-a", cx);
window.press("secondary-a", cx);
window.press("backspace", cx);
window.click("commit", cx);
})
@@ -749,7 +749,7 @@ async fn a_dialog_says_what_is_wrong_in_a_notification_that_goes_with_it(cx: &mu
cx.run_until_parked();
for (open, field, error) in [
("new-host-panel", "host-name", "请输入名称"),
("new-host-panel", "host-address", "请输入地址"),
("temporary-connection", "host-address", "请输入地址"),
("new-group", "group-name", "请输入分组名称"),
("new-forward", "forward-name", "请选择端口转发经由的主机"),
@@ -812,7 +812,7 @@ fn a_problem_found_while_the_window_is_built_is_shown_once_it_is_open(cx: &mut T
store,
cx.new(|_| settings_store()),
remote,
Arc::new(FakeTerminalFactory::default()),
fake_local_terminals(),
Arc::new(FakeSftpProvider::default()),
Arc::new(FakeLocalDirectory::default()),
Arc::new(FakeConnectionTester::default()),