Compare commits

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:12:19 +08:00
25 changed files with 614 additions and 90 deletions

No files matched your search

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