Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81aeb37aee | ||
|
|
fd4d37946b | ||
|
|
743bd9d5e5 | ||
|
|
8642ab5611 | ||
|
|
6a94958cd9 | ||
|
|
31a509167c | ||
|
|
78fd216fb6 | ||
|
|
0dfa3fdf77 | ||
|
|
ff1efaf151 | ||
|
|
bd7b8c1e3a | ||
|
|
959e0b1d9d | ||
|
|
e3397dabc7 | ||
|
|
3b06f6f516 | ||
|
|
1f542b692d | ||
|
|
69e641c2bd | ||
|
|
f69518950e | ||
|
|
cf80884221 | ||
|
|
da00974786 | ||
|
|
4acba3a0f8 | ||
|
|
cd489a5fc2 | ||
|
|
9799f88ade | ||
|
|
dc5adddd06 | ||
|
|
db0a0faf05 | ||
|
|
026e0d44d8 | ||
|
|
e91bab0d09 | ||
|
|
5ca6e53366 | ||
|
|
0aa1d4452d | ||
|
|
5fdfce239a |
No files matched your search
@@ -1,7 +1,7 @@
|
||||
# A release: push a tag `v<version>` matching Cargo.toml's version.
|
||||
#
|
||||
# verify the tag, the CHANGELOG section, the update keys
|
||||
# macos universal app: signed, notarized, stapled; update zip and DMG
|
||||
# macos per-architecture apps: signed, notarized, stapled; update zip and DMG
|
||||
# windows per-user Inno Setup installer (unsigned for now)
|
||||
# linux AppImage
|
||||
# publish (environment `release`, approved by hand) uploads to R2 behind
|
||||
@@ -83,8 +83,13 @@ jobs:
|
||||
macos:
|
||||
needs: verify
|
||||
runs-on: macos-14
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [aarch64, x86_64]
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
ARCH: ${{ matrix.arch }}
|
||||
VERSION: ${{ needs.verify.outputs.version }}
|
||||
SHELLRS_UPDATE_CHANNEL: ${{ needs.verify.outputs.channel }}
|
||||
SHELLRS_COMMIT: ${{ github.sha }}
|
||||
@@ -92,18 +97,17 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: rustup toolchain install
|
||||
- run: rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||
- run: rustup target add "$ARCH-apple-darwin"
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build both halves and join them
|
||||
with:
|
||||
key: macos-${{ matrix.arch }}
|
||||
- name: Build the architecture-specific app
|
||||
run: |
|
||||
export SHELLRS_BUILD_DATE=$(date -u +%Y-%m-%d)
|
||||
cargo build --release --locked --bin shellrs --target aarch64-apple-darwin
|
||||
cargo build --release --locked --bin shellrs --target x86_64-apple-darwin
|
||||
cargo build --release --locked --bin shellrs --target "$ARCH-apple-darwin"
|
||||
mkdir -p dist build
|
||||
lipo -create -output build/shellrs \
|
||||
target/aarch64-apple-darwin/release/shellrs \
|
||||
target/x86_64-apple-darwin/release/shellrs
|
||||
packaging/macos/bundle.sh build/shellrs "$VERSION" "$GITHUB_RUN_NUMBER" build
|
||||
lipo "target/$ARCH-apple-darwin/release/shellrs" -verify_arch "${ARCH/aarch64/arm64}"
|
||||
packaging/macos/bundle.sh "target/$ARCH-apple-darwin/release/shellrs" "$VERSION" "$GITHUB_RUN_NUMBER" build
|
||||
- name: Sign
|
||||
env:
|
||||
CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE_P12 }}
|
||||
@@ -147,12 +151,12 @@ jobs:
|
||||
xcrun stapler staple build/ShellRS.app
|
||||
# The update package: the stapled bundle, zipped the way the
|
||||
# updater unzips it (ditto keeps the signature intact).
|
||||
ditto -c -k --keepParent build/ShellRS.app "dist/ShellRS-$VERSION-macos-universal.app.zip"
|
||||
ditto -c -k --keepParent build/ShellRS.app "dist/ShellRS-$VERSION-macos-$ARCH.app.zip"
|
||||
# The first-install disk image, with the Applications shortcut.
|
||||
mkdir -p "$RUNNER_TEMP/dmg"
|
||||
cp -R build/ShellRS.app "$RUNNER_TEMP/dmg/"
|
||||
ln -s /Applications "$RUNNER_TEMP/dmg/Applications"
|
||||
dmg="dist/ShellRS-$VERSION-macos-universal.dmg"
|
||||
dmg="dist/ShellRS-$VERSION-macos-$ARCH.dmg"
|
||||
# hdiutil's own estimate of the image size once came out too small
|
||||
# for the app ("No space left on device"), so the size is given,
|
||||
# with room to spare; the compressed image is about half the app.
|
||||
@@ -171,7 +175,7 @@ jobs:
|
||||
spctl -a -vv -t install "$dmg"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos
|
||||
name: macos-${{ matrix.arch }}
|
||||
path: dist/*
|
||||
|
||||
windows:
|
||||
@@ -265,7 +269,7 @@ jobs:
|
||||
- name: Collect
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp artifacts/macos/* artifacts/windows/* artifacts/linux/* dist/
|
||||
cp artifacts/macos-aarch64/* artifacts/macos-x86_64/* artifacts/windows/* artifacts/linux/* dist/
|
||||
cp artifacts/notes/notes.md notes.md
|
||||
ls -l dist
|
||||
- name: Signing key
|
||||
|
||||
@@ -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
|
||||
@@ -2,6 +2,96 @@
|
||||
|
||||
每个版本一节,标题写成 `## [版本号]`,可以在后面加日期。发布流程(`.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]
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -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`。
|
||||
@@ -352,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
@@ -7738,7 +7738,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shellrs"
|
||||
version = "0.1.7-beta.2"
|
||||
version = "0.1.10-beta.2"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "shellrs"
|
||||
version = "0.1.7-beta.2"
|
||||
version = "0.1.10-beta.2"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
description = "SSH client and host manager: terminal, SFTP, port forwarding"
|
||||
|
||||
@@ -119,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) |
|
||||
|
||||
|
||||
+2
-1
@@ -119,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 及更新的版本) |
|
||||
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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}");
|
||||
}
|
||||
|
||||
+7
-3
@@ -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
|
||||
|
||||
|
||||
+11
-5
@@ -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`。剪贴板里有文字时仍粘文字。外部连接和临时连接不支持。
|
||||
|
||||
## 右侧栏
|
||||
|
||||
|
||||
+3
-1
@@ -5,7 +5,7 @@
|
||||
在 `CHANGELOG.md` 里写好 `## [x.y.z]` 一节,把 `Cargo.toml` 的版本改成 `x.y.z`,提交后推送 tag `vx.y.z`。`.github/workflows/release.yml` 依次:
|
||||
|
||||
1. 核对 tag 与 `Cargo.toml` 一致,截出更新说明,确认代码里有发布公钥;
|
||||
2. 构建并打包:macOS 合成 universal,签名、公证、staple,产出升级用的 `.app.zip` 和首次安装用的 `.dmg`;Windows 用 Inno Setup 打安装程序;Linux 打 AppImage;
|
||||
2. 构建并打包:macOS 按 `aarch64`(Apple Silicon)和 `x86_64`(Intel)分别构建、签名、公证、staple,各自产出升级用的 `.app.zip` 和首次安装用的 `.dmg`;Windows 用 Inno Setup 打安装程序;Linux 打 AppImage;
|
||||
3. 在 `release` 环境里人工批准后:给每个包签名,上传到 R2(`dl.shellrs.com/releases/<版本>/`),建 GitHub Release,**最后**才替换更新清单 `dl.shellrs.com/update/v1/<通道>.json` 并清 CDN 缓存,然后调用官网的部署挂钩重新构建 shellrs.com(官网的下载页和更新日志在构建时读这份清单)。清单里每个包有两个下载地址:先 R2,下载失败时客户端接着试 GitHub Release(仓库是公开的,下载不用登录)。
|
||||
|
||||
版本号带 `-` 的(如 `0.3.0-beta.1`)只发到 beta 通道;正式版同时成为 beta 通道的最新版。在 Actions 里手动运行这个 workflow 只构建打包、不发布,用来演练。客户端只接受比自己新的版本,所以发错的版本撤不回来:把清单改回上一版能阻止更多人升级,修复要发新的补丁版。
|
||||
@@ -19,3 +19,5 @@
|
||||
- **GitHub secrets**:仓库级的 `APPLE_CERTIFICATE_P12`(base64)、`APPLE_CERTIFICATE_PASSWORD`、`APPLE_SIGNING_IDENTITY`、`APPLE_TEAM_ID`、`APPLE_API_KEY_P8`(base64)、`APPLE_API_KEY_ID`、`APPLE_API_ISSUER`;`release` 环境(设为需要审批)里的 `MINISIGN_SECRET_KEY`、`MINISIGN_PASSWORD`、`R2_ACCOUNT_ID`、`R2_ACCESS_KEY_ID`、`R2_SECRET_ACCESS_KEY`、`R2_BUCKET`、`CF_ZONE_ID`、`CF_API_TOKEN`、`SITE_DEPLOY_HOOK`(官网部署挂钩的地址,拿到它的人能触发官网重新构建,泄露了就删掉重建)。
|
||||
|
||||
清单格式见 `packaging/manifest.example.json`,由 `packaging/make-manifest.sh` 生成:一个文件里是清单原文和它的 minisign 签名,签名的 trusted comment 写明通道和版本。格式只增加字段、不改已有的;已发出去的 ShellRS 一直按 `/update/v1/` 这个地址检查。
|
||||
|
||||
macOS 产物名分别为 `ShellRS-<版本>-macos-aarch64.{app.zip,dmg}` 和 `ShellRS-<版本>-macos-x86_64.{app.zip,dmg}`。更新清单保留原有的两个平台键,分别记录对应架构包的地址、大小和 SHA-256;已有 universal 客户端按当前运行的架构升级。Apple Silicon 上通过 Rosetta 运行 Intel 版时仍会收到 Intel 包,需要手动安装 ARM 版才能切换。官网在独立仓库中,下载页也需提供 Apple Silicon / Intel 两个入口,读取清单中各自的 installer 地址。
|
||||
+2
-5
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -365,5 +365,5 @@ ssh.exec.saved_passphrase_rejected:
|
||||
zh-CN: 保存的私钥口令不正确:请先在 ShellRS 中连接一次这台主机并保存密码
|
||||
en: The saved private key passphrase is incorrect. Connect to this host in ShellRS once and save the right one
|
||||
ssh.exec.keyboard_interactive:
|
||||
zh-CN: 服务器要求键盘交互式认证:请先在 ShellRS 中连接一次这台主机并保存密码
|
||||
en: The server asks for keyboard-interactive authentication. Connect to this host in ShellRS once and save the password
|
||||
zh-CN: 服务器在键盘交互式认证中提出了密码以外的问题(如验证码),外部 CLI 无法回答
|
||||
en: The server asks keyboard-interactive questions besides the password (such as a verification code), which the external CLI can’t answer
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -23,8 +23,10 @@ out=$5
|
||||
base="https://dl.shellrs.com/releases/$version"
|
||||
mirror=${SHELLRS_MIRROR_BASE:-}
|
||||
|
||||
mac="ShellRS-$version-macos-universal.app.zip"
|
||||
dmg="ShellRS-$version-macos-universal.dmg"
|
||||
mac_arm="ShellRS-$version-macos-aarch64.app.zip"
|
||||
mac_intel="ShellRS-$version-macos-x86_64.app.zip"
|
||||
dmg_arm="ShellRS-$version-macos-aarch64.dmg"
|
||||
dmg_intel="ShellRS-$version-macos-x86_64.dmg"
|
||||
win="ShellRS-$version-windows-x86_64-setup.exe"
|
||||
linux="ShellRS-$version-linux-x86_64.AppImage"
|
||||
|
||||
@@ -58,10 +60,12 @@ jq -n -j \
|
||||
--arg published "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--rawfile notes "$notes" \
|
||||
--arg notes_url "https://shellrs.com/changelog#$version" \
|
||||
--argjson mac "$(asset "$mac")" \
|
||||
--argjson mac_arm "$(asset "$mac_arm")" \
|
||||
--argjson mac_intel "$(asset "$mac_intel")" \
|
||||
--argjson win "$(asset "$win")" \
|
||||
--argjson linux "$(asset "$linux")" \
|
||||
--arg dmg "$base/$dmg" \
|
||||
--arg dmg_arm "$base/$dmg_arm" \
|
||||
--arg dmg_intel "$base/$dmg_intel" \
|
||||
--arg win_url "$base/$win" \
|
||||
--arg linux_url "$base/$linux" \
|
||||
'{
|
||||
@@ -74,14 +78,14 @@ jq -n -j \
|
||||
notes: $notes,
|
||||
notes_url: $notes_url,
|
||||
assets: {
|
||||
"macos-aarch64": $mac,
|
||||
"macos-x86_64": $mac,
|
||||
"macos-aarch64": $mac_arm,
|
||||
"macos-x86_64": $mac_intel,
|
||||
"windows-x86_64": $win,
|
||||
"linux-x86_64": $linux
|
||||
},
|
||||
installers: {
|
||||
"macos-aarch64": $dmg,
|
||||
"macos-x86_64": $dmg,
|
||||
"macos-aarch64": $dmg_arm,
|
||||
"macos-x86_64": $dmg_intel,
|
||||
"windows-x86_64": $win_url,
|
||||
"linux-x86_64": $linux_url
|
||||
}
|
||||
|
||||
@@ -10,16 +10,16 @@
|
||||
"assets": {
|
||||
"macos-aarch64": {
|
||||
"urls": [
|
||||
"https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.app.zip",
|
||||
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/ShellRS-0.2.0-macos-universal.app.zip"
|
||||
"https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-aarch64.app.zip",
|
||||
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/ShellRS-0.2.0-macos-aarch64.app.zip"
|
||||
],
|
||||
"size": 41234567,
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
"macos-x86_64": {
|
||||
"urls": [
|
||||
"https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.app.zip",
|
||||
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/ShellRS-0.2.0-macos-universal.app.zip"
|
||||
"https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-x86_64.app.zip",
|
||||
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/ShellRS-0.2.0-macos-x86_64.app.zip"
|
||||
],
|
||||
"size": 41234567,
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
@@ -42,8 +42,8 @@
|
||||
}
|
||||
},
|
||||
"installers": {
|
||||
"macos-aarch64": "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.dmg",
|
||||
"macos-x86_64": "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.dmg",
|
||||
"macos-aarch64": "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-aarch64.dmg",
|
||||
"macos-x86_64": "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-x86_64.dmg",
|
||||
"windows-x86_64": "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-windows-x86_64-setup.exe",
|
||||
"linux-x86_64": "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-linux-x86_64.AppImage"
|
||||
}
|
||||
|
||||
@@ -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您要继续吗?
|
||||
@@ -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
@@ -77,7 +77,8 @@ icon_assets!(
|
||||
Zap,
|
||||
ExternalLink,
|
||||
Highlighter,
|
||||
GripVertical
|
||||
GripVertical,
|
||||
FileSearch
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
+34
-3
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -905,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))
|
||||
}
|
||||
|
||||
+9
-12
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+115
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -119,7 +119,7 @@ async fn run(
|
||||
}
|
||||
ConnectionPromptKind::HostKeyChanged(_) => {
|
||||
lock(&seen).changed = true;
|
||||
true
|
||||
false
|
||||
}
|
||||
ConnectionPromptKind::Authentication(_) => false,
|
||||
})
|
||||
|
||||
+3
-1
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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("ed, 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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
@@ -207,8 +207,9 @@ pub trait Installer: Send + Sync {
|
||||
|
||||
/// Remove what earlier updates left next to the installed copy. Called a
|
||||
/// while after start, so a version that crashes right away leaves the
|
||||
/// previous one's backup behind.
|
||||
fn clean_up(&self);
|
||||
/// previous one's backup behind. An update newer than `current` stays:
|
||||
/// it may have been staged since start and be waiting for the restart.
|
||||
fn clean_up(&self, current: &Version);
|
||||
}
|
||||
|
||||
/// For copies that cannot replace themselves.
|
||||
@@ -237,7 +238,7 @@ impl Installer for NoInstaller {
|
||||
))
|
||||
}
|
||||
|
||||
fn clean_up(&self) {}
|
||||
fn clean_up(&self, _: &Version) {}
|
||||
}
|
||||
|
||||
/// The installer for this copy of ShellRS. `bundle` is `cx.app_path()`.
|
||||
@@ -296,6 +297,14 @@ pub(crate) fn sibling(path: &Path, name: &str) -> PathBuf {
|
||||
path.with_file_name(name)
|
||||
}
|
||||
|
||||
/// Whether `name` is an update staged as `<prefix><version>` that this copy
|
||||
/// no longer needs: one not newer than `current`, or not named by a version.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", test))]
|
||||
pub(crate) fn is_stale_update(name: &str, prefix: &str, current: &Version) -> bool {
|
||||
name.strip_prefix(prefix)
|
||||
.is_some_and(|version| Version::parse(version).map_or(true, |version| version <= *current))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -10,7 +10,9 @@ use std::os::unix::fs::PermissionsExt as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::error::UpdateError;
|
||||
use super::install::{InstallKind, Installer, Relaunch, Staged, sibling};
|
||||
use semver::Version;
|
||||
|
||||
use super::install::{InstallKind, Installer, Relaunch, Staged, is_stale_update, sibling};
|
||||
use super::manifest::Release;
|
||||
|
||||
pub struct AppImageInstaller {
|
||||
@@ -59,13 +61,13 @@ impl Installer for AppImageInstaller {
|
||||
})
|
||||
}
|
||||
|
||||
fn clean_up(&self) {
|
||||
fn clean_up(&self, current: &Version) {
|
||||
let Some(folder) = self.file.parent() else {
|
||||
return;
|
||||
};
|
||||
let prefix = self.prefix();
|
||||
for entry in fs::read_dir(folder).into_iter().flatten().flatten() {
|
||||
if entry.file_name().to_string_lossy().starts_with(&prefix) {
|
||||
if is_stale_update(&entry.file_name().to_string_lossy(), &prefix, current) {
|
||||
let _ = fs::remove_file(entry.path());
|
||||
}
|
||||
}
|
||||
@@ -115,13 +117,26 @@ mod tests {
|
||||
"stale",
|
||||
)
|
||||
.unwrap();
|
||||
installer.clean_up();
|
||||
let left: Vec<_> = fs::read_dir(folder.path())
|
||||
// Staged since start and waiting for the restart: kept.
|
||||
fs::write(
|
||||
sibling(&file, ".ShellRS-x86_64.AppImage.update-0.3.0"),
|
||||
"next",
|
||||
)
|
||||
.unwrap();
|
||||
installer.clean_up(&Version::new(0, 2, 0));
|
||||
let mut left: Vec<_> = fs::read_dir(folder.path())
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|entry| entry.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(left.len(), 2, "{left:?}");
|
||||
assert!(left.contains(&"ShellRS-x86_64.AppImage".to_string()));
|
||||
left.sort();
|
||||
assert_eq!(
|
||||
left,
|
||||
[
|
||||
".ShellRS-x86_64.AppImage.update-0.3.0",
|
||||
"ShellRS-x86_64.AppImage",
|
||||
"downloaded"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,11 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
|
||||
use semver::Version;
|
||||
|
||||
use super::build_info::APPLE_TEAM_ID;
|
||||
use super::error::UpdateError;
|
||||
use super::install::{InstallKind, Installer, Relaunch, Staged, sibling};
|
||||
use super::install::{InstallKind, Installer, Relaunch, Staged, is_stale_update, sibling};
|
||||
use super::manifest::Release;
|
||||
use crate::i18n::t;
|
||||
|
||||
@@ -217,7 +219,7 @@ impl Installer for MacInstaller {
|
||||
})
|
||||
}
|
||||
|
||||
fn clean_up(&self) {
|
||||
fn clean_up(&self, current: &Version) {
|
||||
let Some(folder) = self.bundle.parent() else {
|
||||
return;
|
||||
};
|
||||
@@ -227,7 +229,7 @@ impl Installer for MacInstaller {
|
||||
);
|
||||
for entry in fs::read_dir(folder).into_iter().flatten().flatten() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if name.starts_with(&old) || name.starts_with(&update) {
|
||||
if name.starts_with(&old) || is_stale_update(&name, &update, current) {
|
||||
let _ = fs::remove_dir_all(entry.path());
|
||||
}
|
||||
}
|
||||
@@ -349,10 +351,40 @@ mod tests {
|
||||
assert_eq!(names.len(), 2, "{names:?}");
|
||||
assert!(names[0].starts_with(".ShellRS.app.old-"), "{names:?}");
|
||||
|
||||
installer.clean_up();
|
||||
// Run by 0.2.0, once it has started.
|
||||
installer.clean_up(&Version::new(0, 2, 0));
|
||||
assert_eq!(names_in(folder.path()), ["ShellRS.app"]);
|
||||
}
|
||||
|
||||
/// The clean-up runs a while after start; an update downloaded and
|
||||
/// staged before then is still there to install on restart.
|
||||
#[test]
|
||||
fn the_clean_up_keeps_an_update_staged_for_the_restart() {
|
||||
let (folder, bundle) = installed();
|
||||
let installer = MacInstaller::new(bundle.clone(), FakeTools::new("0.2.0", None));
|
||||
for stale in [
|
||||
".ShellRS.app.update-0.1.0",
|
||||
".ShellRS.app.update-later",
|
||||
".ShellRS.app.old-1",
|
||||
] {
|
||||
fs::create_dir(folder.path().join(stale)).unwrap();
|
||||
}
|
||||
let staged = installer
|
||||
.stage(Path::new("package.zip"), &release("0.2.0"))
|
||||
.unwrap();
|
||||
|
||||
installer.clean_up(&Version::new(0, 1, 0));
|
||||
assert_eq!(
|
||||
names_in(folder.path()),
|
||||
[".ShellRS.app.update-0.2.0", "ShellRS.app"]
|
||||
);
|
||||
assert_eq!(
|
||||
installer.apply(&staged, true),
|
||||
Ok(Relaunch::Restart(bundle.clone()))
|
||||
);
|
||||
assert_eq!(version_at(&bundle), "0.2.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_swap_puts_the_old_bundle_back() {
|
||||
let (folder, bundle) = installed();
|
||||
@@ -367,8 +399,12 @@ mod tests {
|
||||
Err(UpdateError::Install(_))
|
||||
));
|
||||
assert_eq!(version_at(&bundle), "0.1.0");
|
||||
installer.clean_up();
|
||||
assert_eq!(names_in(folder.path()), ["ShellRS.app"]);
|
||||
installer.clean_up(&Version::new(0, 1, 0));
|
||||
assert!(
|
||||
!names_in(folder.path())
|
||||
.iter()
|
||||
.any(|name| name.starts_with(".ShellRS.app.old-"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -484,7 +520,7 @@ mod tests {
|
||||
.check_signature(&bundle, Some("SHELLRS"))
|
||||
.is_err()
|
||||
);
|
||||
installer.clean_up();
|
||||
installer.clean_up(&Version::new(0, 2, 0));
|
||||
assert_eq!(names_in(&installed), ["ShellRS.app"]);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use semver::Version;
|
||||
|
||||
use super::error::UpdateError;
|
||||
use super::install::{InstallKind, Installer, Launch, Relaunch, Staged};
|
||||
use super::manifest::Release;
|
||||
@@ -99,7 +101,8 @@ impl Installer for SetupInstaller {
|
||||
}
|
||||
|
||||
/// The setup program removes what the previous version left.
|
||||
fn clean_up(&self) {}
|
||||
// The package waits in the downloads folder, which the updater tidies.
|
||||
fn clean_up(&self, _: &Version) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
//! Which package of a release fits this machine.
|
||||
//!
|
||||
//! The keys are part of the manifest format, so they never change. A
|
||||
//! universal macOS build runs its native half, so an Apple Silicon Mac asks
|
||||
//! for `macos-aarch64` even though both keys point at the same package; an
|
||||
//! Intel-only build under Rosetta would ask for `macos-x86_64` and keep
|
||||
//! getting Intel builds, which is why macOS ships universal.
|
||||
//! The keys are part of the manifest format, so they never change. macOS
|
||||
//! packages match the running binary's architecture. Existing universal
|
||||
//! builds select their native half's package; an Intel build under Rosetta
|
||||
//! keeps receiving Intel packages until the user installs the ARM build.
|
||||
|
||||
/// The manifest's key for this build's platform.
|
||||
pub fn platform_key() -> &'static str {
|
||||
|
||||
@@ -507,7 +507,7 @@ impl Updater {
|
||||
return;
|
||||
};
|
||||
cx.background_spawn(async move {
|
||||
services.installer.clean_up();
|
||||
services.installer.clean_up(&services.current);
|
||||
clean_downloads(&services.folder, &services.current);
|
||||
})
|
||||
.detach();
|
||||
|
||||
+31
-9
@@ -319,7 +319,8 @@ pub(crate) mod tests {
|
||||
let dist = dir.path().join("dist");
|
||||
std::fs::create_dir(&dist).unwrap();
|
||||
for name in [
|
||||
"ShellRS-0.2.0-macos-universal.app.zip",
|
||||
"ShellRS-0.2.0-macos-aarch64.app.zip",
|
||||
"ShellRS-0.2.0-macos-x86_64.app.zip",
|
||||
"ShellRS-0.2.0-windows-x86_64-setup.exe",
|
||||
"ShellRS-0.2.0-linux-x86_64.AppImage",
|
||||
] {
|
||||
@@ -355,15 +356,36 @@ pub(crate) mod tests {
|
||||
};
|
||||
assert_eq!(manifest.version, Version::new(0, 2, 0));
|
||||
assert_eq!(manifest.notes, "### 新增\n\n- 在线升级\n");
|
||||
let mac = &manifest.assets["macos-aarch64"];
|
||||
assert_eq!(mac, &manifest.assets["macos-x86_64"]);
|
||||
// dl.shellrs.com first, the GitHub release only when it fails.
|
||||
for arch in ["aarch64", "x86_64"] {
|
||||
let key = format!("macos-{arch}");
|
||||
let name = format!("ShellRS-0.2.0-{key}.app.zip");
|
||||
let asset = &manifest.assets[&key];
|
||||
// Each architecture has its own package and both download sources.
|
||||
assert_eq!(
|
||||
asset.urls,
|
||||
[
|
||||
format!("https://dl.shellrs.com/releases/0.2.0/{name}"),
|
||||
format!(
|
||||
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/{name}"
|
||||
),
|
||||
]
|
||||
);
|
||||
assert_eq!(verify_file(&dist.join(&name), asset), Ok(()));
|
||||
assert_eq!(
|
||||
manifest.installers[&key],
|
||||
format!("https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-{key}.dmg")
|
||||
);
|
||||
}
|
||||
assert_ne!(
|
||||
manifest.assets["macos-aarch64"].sha256,
|
||||
manifest.assets["macos-x86_64"].sha256
|
||||
);
|
||||
assert_eq!(
|
||||
mac.urls,
|
||||
[
|
||||
"https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.app.zip",
|
||||
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/ShellRS-0.2.0-macos-universal.app.zip",
|
||||
]
|
||||
verify_file(
|
||||
&dist.join("ShellRS-0.2.0-macos-x86_64.app.zip"),
|
||||
&manifest.assets["macos-aarch64"],
|
||||
),
|
||||
Err(UpdateError::Corrupt)
|
||||
);
|
||||
let package = dist.join("ShellRS-0.2.0-linux-x86_64.AppImage");
|
||||
assert_eq!(
|
||||
|
||||
+147
-22
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
})
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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, _| {
|
||||
|
||||
@@ -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, _| {
|
||||
|
||||
@@ -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);
|
||||
|
||||
+33
-6
@@ -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.
|
||||
@@ -1381,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);
|
||||
}
|
||||
@@ -1400,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);
|
||||
})
|
||||
@@ -1851,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);
|
||||
})
|
||||
@@ -1879,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);
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -455,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()),
|
||||
|
||||
@@ -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
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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()),
|
||||
|
||||
Reference in new issue
Block a user