Compare commits

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

本地写到系统临时目录 shellrs-pastes/(目录 0700、文件 0600),超过 20 个或超过 7 天的旧文件会清掉。已保存的主机另开一条 SFTP 把文件写到远端 /tmp/shellrs-paste-….png,连上后再粘路径;登录要问答就直接失败,不弹框。超过 8 MB 拒绝。外部连接和临时连接提示不支持。剪贴板里有文字时仍粘文字。
2026-10-11 16:19:01 +08:00
okooo 552d55777d release workflow 暂时只打 Windows 包
macOS 和 Linux 的 job 关掉,另行通知后再打开。
2026-10-11 09:59:39 +08:00
okooo 6674a47662 快捷键允许 Shift 加 Insert、Delete、Home、End、Page Up、Page Down
这些键不挡打字,终端粘贴可以录成 Shift+Insert。单独按仍给终端和列表。
2026-10-11 09:57:07 +08:00
18 changed files with 721 additions and 32 deletions

No files matched your search

+4
View File
@@ -82,6 +82,8 @@ jobs:
macos:
needs: verify
# 暂时只打 Windows 包,另行通知后再打开。
if: false
runs-on: macos-14
strategy:
fail-fast: false
@@ -212,6 +214,8 @@ jobs:
linux:
needs: verify
# 暂时只打 Windows 包,另行通知后再打开。
if: false
# Ubuntu 22.04's glibc (2.35) is the oldest the AppImage runs on.
runs-on: ubuntu-22.04
timeout-minutes: 120
+1
View File
@@ -29,6 +29,7 @@ on:
- "src/credential/credential_dialog.rs"
- "src/settings/settings_panel.rs"
- "src/app/shortcuts.rs"
- "src/terminal/paste_image.rs"
- "src/workspace/window_state.rs"
- "src/testing.rs"
- "tests/cli_bin.rs"
+2 -2
View File
@@ -70,7 +70,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH
- 只认 UTF-8(不做 GBK),上限 5 MB,约 15 种语言语法高亮。
- 远程文件原地改写:截断后写原文件,不走 `.filepart` 加改名。保存前核对,被改过就问「覆盖」。
- **设置 › 应用 › 窗口**:「记住窗口大小」(含最大化)和「记住窗口位置」两个开关,默认都开,下次启动生效。屏幕不在了或窗口不在屏幕上就居中,比屏幕大就缩小;全屏不记。
- **终端交互**:「选中复制」默认关;「右键行为」无动作 / 粘贴 / 显示菜单,默认显示菜单。
- **终端交互**:「选中复制」默认关;「右键行为」无动作 / 粘贴 / 显示菜单,默认显示菜单。本地终端和已保存主机的远程终端里,剪贴板是图(没有文字)时粘贴会把图存成文件并把路径打进输入行,给终端里的 Agent 读;外部连接和临时连接不做。
- **终端通知**:只做程序请求的(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`)。两种外观下悬停和选中都要分得开。页面「重置」对卡片和开关各管各的。
@@ -79,7 +79,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–F24,或是 Shift 加 Insert / Delete / Home / End / Page Up / Page Down(方向键上方那六个,不挡打字;单独按仍给终端和列表)。撞上同一处的另一个快捷键或输入框等内置键就拒绝并提示占用者,不抢、不允许重复。
- 存在 `settings.json` 的 `shortcuts`,只存和默认不同的(`null` 是禁用)。`Shortcut` 的 `id` 一经发布不能改。
- **关键字高亮**:设置左栏单独一个分类,排在「终端」下面,三组:常规(总开关)、预览(当前终端主题下的示例输出)、规则(N)。规则存在 `settings.json`,所有终端共用。
- 规则表在行里直接改,不弹对话框:拖动把手(一直显示,只有把手能拖)、启用、正则表达式、备注、颜色(gpui-kit `ColorPicker` 加 `#rrggbb` 输入框)、通知、删除(不确认)。规则都是正则,区分大小写;颜色是用户数据,任意 RGB,不跟主题;不做加粗。重叠时靠前的优先。
+2 -1
View File
@@ -111,7 +111,7 @@ The “Settings” button at the bottom of the sidebar, or ⌘, (Ctrl+, on Windo
- **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.
- A combination needs ⌘, ⌥ or ⌃ (Ctrl or Alt on Windows and Linux), or must be one of F1–F12, or Shift with Insert, Delete, Home, End, Page Up or Page Down (the six keys above the arrows, which do not type); for “Switch to tab 1…9”, press any digit with a modifier, and all nine change together.
- A combination already taken by another shortcut in the same place, or by text fields and other built-in features, is refused, and you’re told what uses it; to use it anyway, change or disable that one first. Terminal and editor shortcuts can share keys with general shortcuts, and in the terminal or editor they take precedence (in a terminal, for example, ⌘K clears the screen).
- Defaults (macOS / Windows and Linux): New host ⌘N / Ctrl+N, New group ⌘⇧N / Ctrl+Shift+N, Open settings ⌘, / Ctrl+,, Show or hide sidebar ⌘B / Ctrl+B, Show or hide right sidebar ⌘⌥B / Ctrl+Alt+B, Focus search ⌘K / Ctrl+K, Zoom in, Zoom out and Actual size ⌘= ⌘- ⌘0 / Ctrl+= Ctrl+- Ctrl+0, Quit ⌘Q / Ctrl+Q; New local terminal ⌘T / Ctrl+T, Close tab ⌘W / Ctrl+W, Next tab ⌘⇧] / Ctrl+Tab, Previous tab ⌘⇧[ / Ctrl+Shift+Tab, Switch to tab 1…9 ⌘1…⌘9 / Alt+1…9 (9 is the last tab); in the terminal, Copy, Paste, Find, Find next, Find previous and Clear screen ⌘C ⌘V ⌘F ⌘G ⌘⇧G ⌘K / Ctrl+Shift+C, Ctrl+Shift+V, Ctrl+Shift+F, F3, Shift+F3, Ctrl+Shift+K, and Bigger text, Smaller text and Default text size ⌘= ⌘- ⌘0 / Ctrl+= Ctrl+- Ctrl+0; in the editor, Save ⌘S / Ctrl+S. Switching tabs stays within the group of tabs the current tab is in, wrapping around at either end. In a terminal, ⌘= / ⌘- / ⌘0 change the terminal font size (1 pixel at a time, within the same 10–28 range as “Terminal › Font size”; saved in the settings and kept for the next launch); outside a terminal they zoom the whole interface (five steps, back to the default after a restart).
- Shortcuts in the SFTP file list and the image preview, and fixed keys such as Enter, the arrow keys, Tab and Esc, aren’t changed here.
@@ -202,6 +202,7 @@ The terminal’s context menu has “Copy”, “Paste”, “Find…” and “
- **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
+2 -1
View File
@@ -111,7 +111,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
- **终端 → 终端交互**:「选中复制」打开后,用鼠标选中文字、松开时就复制到系统剪贴板,不用再按 ⌘C;默认关闭,单击没选中文字时剪贴板不变。「右键行为」决定在终端里按右键做什么:显示菜单(默认)、粘贴(直接粘贴剪贴板,同 ⌘V)、无动作。选后两种时,菜单里的复制、粘贴、查找、清屏仍可用快捷键,打开 SFTP、重新连接在标签的右键菜单里。改动立即作用于所有已打开的终端。
- **关键字高亮**:总开关、预览和规则表,终端里按规则给文字上色、匹配时提醒,见「终端」一节。
- **键盘快捷键**:主要功能的快捷键,分通用、标签、终端、编辑器四组,每行是功能名和它的按键。点按键进入「按下组合键」,按下新的组合就换上;Esc 或点别处放弃。行尾两个按钮:禁用(之后显示「无」,再点一下恢复默认)、恢复默认;页面上方的「重置」也能逐项恢复。改动立即生效,工具栏提示和右键菜单里显示的按键跟着变。
- 组合键要带 ⌘、⌥ 或 ⌃(其他平台 Ctrl 或 Alt),或者是 F1–F12;「切换到标签 1…9」按任意一个带修饰键的数字即可,九个一起换。
- 组合键要带 ⌘、⌥ 或 ⌃(其他平台 Ctrl 或 Alt),或者是 F1–F12,或者 Shift 加 Insert、Delete、Home、End、Page Up、Page Down(方向键上方那六个,不挡打字);「切换到标签 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 这些固定键不在这里改。
@@ -208,6 +208,7 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口]
- **通知**:程序可以请求通知(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`。剪贴板里有文字时仍粘文字。外部连接和临时连接不支持。
## 右侧栏
+4 -4
View File
@@ -95,11 +95,11 @@ app.shortcut_group.editor:
en: Editor
app.shortcut_refusal.needs_modifier_mac:
zh-CN: 快捷键要带 ⌘、⌥ 或 ⌃,或者使用 F1–F12。
en: A shortcut needs ⌘, ⌥ or ⌃, or one of F1–F12.
zh-CN: 快捷键要带 ⌘、⌥ 或 ⌃,或者使用 F1–F12,或者 Shift 加 Insert、Delete、Home、End、Page Up、Page Down。
en: A shortcut needs ⌘, ⌥ or ⌃, or one of F1–F12, or Shift with Insert, Delete, Home, End, Page Up or Page Down.
app.shortcut_refusal.needs_modifier:
zh-CN: 快捷键要带 Ctrl 或 Alt,或者使用 F1–F12。
en: A shortcut needs Ctrl or Alt, or one of F1–F12.
zh-CN: 快捷键要带 Ctrl 或 Alt,或者使用 F1–F12,或者 Shift 加 Insert、Delete、Home、End、Page Up、Page Down。
en: A shortcut needs Ctrl or Alt, or one of F1–F12, or Shift with Insert, Delete, Home, End, Page Up or Page Down.
app.shortcut_refusal.needs_digit_mac:
zh-CN: 这一项要按带修饰键的数字 1,例如 ⌘1。
en: Press the digit 1 with a modifier, such as ⌘1.
+20
View File
@@ -71,6 +71,26 @@ terminal.command.multiline:
zh-CN: 这是一条多行命令,在这个终端里只能直接执行
en: This is a multi-line command, which this terminal can only run directly
# A screenshot pasted for an agent in the terminal
terminal.paste.image_empty:
zh-CN: 剪贴板里的图片是空的
en: The image on the clipboard is empty
terminal.paste.image_too_large:
zh-CN: 图片太大,粘贴不了
en: The image is too large to paste
terminal.paste.image_save_failed:
zh-CN: 没法把图片存到本机:%{error}
en: "Couldn’t save the image on this computer: %{error}"
terminal.paste.image_upload_failed:
zh-CN: 没法把图片传到主机:%{error}
en: "Couldn’t send the image to the host: %{error}"
terminal.paste.image_unsupported:
zh-CN: 外部连接和未保存的主机不能粘贴图片
en: External connections and unsaved hosts can’t paste images
terminal.paste.image_title:
zh-CN: 粘贴图片
en: Paste image
# State of a terminal
terminal.lifecycle.starting:
zh-CN: 正在启动
+27 -2
View File
@@ -487,7 +487,7 @@ fn same_keystroke(a: &Keystroke, b: &Keystroke) -> bool {
/// Why a key cannot be a shortcut.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Refusal {
/// A plain key: it would stop typing it.
/// A key that would stop typing: a letter, a digit, or Shift with one.
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_cluster_key(&keystroke.key))
{
return Err(Refusal::NeedsModifier);
}
let keys = keystroke.unparse();
@@ -589,6 +592,17 @@ fn is_function_key(key: &str) -> bool {
.is_some_and(|number| (1..=24).contains(&number))
}
/// Insert, Delete, Home, End, Page Up and Page Down: they sit above the
/// arrow keys and type nothing, so Shift with one of them does not stop
/// typing the way Shift+J or Shift+= would. Alone they still go to the
/// terminal or a list, as vim and the SFTP pane use them.
fn is_cluster_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: ⌃⌥⇧⌘
/// on macOS, Ctrl Alt Shift Win elsewhere. A numbered shortcut's key
/// shows as 1…9.
@@ -1041,8 +1055,19 @@ 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, "shift-="), Err(Refusal::NeedsModifier));
assert_eq!(check(new_host, "shift-up"), Err(Refusal::NeedsModifier));
assert_eq!(check(new_host, "insert"), Err(Refusal::NeedsModifier));
assert_eq!(check(new_host, "f5"), Ok("f5".into()));
assert_eq!(check(new_host, "alt-j"), Ok("alt-j".into()));
for key in ["insert", "delete", "home", "end", "pageup", "pagedown"] {
let keys = format!("shift-{key}");
assert_eq!(check(new_host, &keys), Ok(keys), "{key}");
}
assert_eq!(
check(shortcut("terminal-paste"), "shift-insert"),
Ok("shift-insert".into())
);
// Taken by another shortcut working anywhere.
let terminal = shortcut("new-local-terminal");
assert_eq!(
+2
View File
@@ -11,10 +11,12 @@ mod download;
mod journal;
mod meter;
mod operations;
mod put;
mod speed;
mod sync;
mod upload;
mod worker;
pub use put::put_bytes;
pub use worker::SshSftpTransportProvider;
#[cfg(all(test, unix))]
mod protocol_tests;
+115
View File
@@ -0,0 +1,115 @@
//! Write one file on a host over SFTP, for a screenshot pasted into a
//! remote terminal. A connection of its own: the terminal's channel stays
//! the shell. Nobody is there to answer a question, so a host not trusted
//! yet or a password not saved fails at once.
use crate::connection::{ConnectionPromptKind, ConnectionPromptReply};
use crate::host::HostLogin;
use crate::i18n::t;
use gpui_kit::SharedString;
use super::{RemotePath, SftpCommand, SftpEvent, SharedSftpTransportProvider};
/// Put `bytes` at `path` on `login`'s host. Blocks, so it belongs on a
/// background thread.
pub fn put_bytes(
provider: &SharedSftpTransportProvider,
login: &HostLogin,
path: &RemotePath,
bytes: Vec<u8>,
) -> Result<(), SharedString> {
let transport = provider.create(login);
let (commands, command_receiver) = async_channel::unbounded();
let (event_sender, events) = async_channel::unbounded();
let worker = std::thread::Builder::new()
.name("shellrs-paste-sftp".into())
.spawn(move || transport.run(command_receiver, event_sender))
.map_err(|error| {
t!(
"terminal.paste.image_upload_failed",
error = error.to_string()
)
})?;
let result = write_once(&commands, &events, path, bytes);
let _ = commands.send_blocking(SftpCommand::Shutdown);
let _ = worker.join();
result
}
fn write_once(
commands: &async_channel::Sender<SftpCommand>,
events: &async_channel::Receiver<SftpEvent>,
path: &RemotePath,
bytes: Vec<u8>,
) -> Result<(), SharedString> {
loop {
match events.recv_blocking() {
Ok(SftpEvent::Connected { .. }) => break,
Ok(SftpEvent::Prompt(prompt)) => {
refuse(commands, &prompt);
return Err(prompt_error(&prompt));
}
Ok(SftpEvent::Disconnected(reason)) => {
return Err(t!("terminal.paste.image_upload_failed", error = reason));
}
Ok(_) => {}
Err(_) => {
return Err(t!(
"terminal.paste.image_upload_failed",
error = t!("cli.transfer.sftp_ended")
));
}
}
}
let _ = commands.send_blocking(SftpCommand::WriteFile {
request_id: 1,
path: path.clone(),
bytes,
expected: None,
});
loop {
match events.recv_blocking() {
Ok(SftpEvent::FileWritten { result, .. }) => {
return result.map(|_| ()).map_err(|error| {
t!(
"terminal.paste.image_upload_failed",
error = error.to_string()
)
});
}
Ok(SftpEvent::Prompt(prompt)) => {
refuse(commands, &prompt);
return Err(prompt_error(&prompt));
}
Ok(SftpEvent::Disconnected(reason)) => {
return Err(t!("terminal.paste.image_upload_failed", error = reason));
}
Ok(_) => {}
Err(_) => {
return Err(t!(
"terminal.paste.image_upload_failed",
error = t!("cli.transfer.sftp_ended")
));
}
}
}
}
fn refuse(
commands: &async_channel::Sender<SftpCommand>,
prompt: &crate::connection::ConnectionPrompt,
) {
let _ = commands.send_blocking(SftpCommand::PromptReply {
request_id: prompt.request_id(),
reply: ConnectionPromptReply::Cancel,
});
}
fn prompt_error(prompt: &crate::connection::ConnectionPrompt) -> SharedString {
match prompt.kind() {
ConnectionPromptKind::UnknownHost(_) => t!("cli.transfer.host_key_unknown"),
ConnectionPromptKind::HostKeyChanged(_) => t!("cli.transfer.host_key_changed"),
ConnectionPromptKind::Authentication(_) => t!("cli.transfer.missing_credential"),
}
}
+7
View File
@@ -686,6 +686,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 {
+20 -7
View File
@@ -24,6 +24,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 +43,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 {
+5
View File
@@ -10,6 +10,7 @@ mod local_terminal_panel;
mod model;
mod mouse;
mod notices;
mod paste_image;
mod run;
mod scrollbar;
mod search;
@@ -34,6 +35,10 @@ pub use model::{
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,
+228
View File
@@ -0,0 +1,228 @@
//! What a paste does when the clipboard holds a screenshot: write a file
//! the agent can read, and put that path on the input line.
//!
//! A local terminal writes into the system temp folder. A saved remote
//! host is written over SFTP by the workspace. An external connection does
//! not take images: another channel would end the bastion session.
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use gpui_kit::{ClipboardEntry, ClipboardItem, SharedString};
use crate::i18n::t;
/// Largest screenshot ShellRS will write. Bigger than that is refused.
pub const MAX_BYTES: usize = 8 * 1024 * 1024;
/// How many paste files to keep in the local folder.
const KEEP: usize = 20;
/// How a terminal handles a screenshot on the clipboard.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ImagePaste {
/// Write a file on this computer and paste its path.
Local,
/// Ask the owner to put the file on the host, then paste that path.
Remote,
/// Leave the image alone (an external connection, or a host not saved).
#[default]
Off,
}
/// Bytes to write, and the extension the file should have.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ClipboardImage {
pub bytes: Vec<u8>,
pub extension: &'static str,
}
/// What is on the clipboard for a terminal paste.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ClipboardPaste {
Text(String),
Image(ClipboardImage),
Empty,
}
/// Text wins when the clipboard has both: copying a path or a sentence
/// should still paste that, even if an app also put a thumbnail there.
pub fn clipboard_paste(item: &ClipboardItem) -> ClipboardPaste {
if let Some(text) = item.text().filter(|text| !text.is_empty()) {
return ClipboardPaste::Text(text);
}
for entry in item.entries() {
if let ClipboardEntry::Image(image) = entry {
if image.bytes.is_empty() {
continue;
}
return ClipboardPaste::Image(ClipboardImage {
bytes: image.bytes.clone(),
extension: image.format.extension(),
});
}
}
ClipboardPaste::Empty
}
/// The folder local pastes go to: the system temp directory, so they do
/// not sit next to the database.
pub fn pastes_dir() -> PathBuf {
std::env::temp_dir().join("shellrs-pastes")
}
/// A unique name such as `shellrs-paste-….png`.
pub fn paste_file_name(extension: &str) -> String {
let nanos = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
format!("shellrs-paste-{nanos:x}.{extension}")
}
/// Write `bytes` into `dir` under a new name. Creates the directory.
pub fn save_in(dir: &Path, bytes: &[u8], extension: &str) -> Result<PathBuf, SharedString> {
if bytes.is_empty() {
return Err(t!("terminal.paste.image_empty"));
}
if bytes.len() > MAX_BYTES {
return Err(t!("terminal.paste.image_too_large"));
}
create_private_dir(dir).map_err(|error| {
t!(
"terminal.paste.image_save_failed",
error = error.to_string()
)
})?;
prune(dir);
let path = dir.join(paste_file_name(extension));
write_private_file(&path, bytes).map_err(|error| {
t!(
"terminal.paste.image_save_failed",
error = error.to_string()
)
})?;
Ok(path)
}
/// Write a screenshot for a local terminal.
pub fn save_local(bytes: &[u8], extension: &str) -> Result<PathBuf, SharedString> {
save_in(&pastes_dir(), bytes, extension)
}
fn prune(dir: &Path) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
let mut files: Vec<(SystemTime, PathBuf)> = entries
.flatten()
.filter_map(|entry| {
let path = entry.path();
let meta = entry.metadata().ok()?;
if !meta.is_file() {
return None;
}
Some((meta.modified().ok()?, path))
})
.collect();
files.sort_by_key(|(modified, _)| std::cmp::Reverse(*modified));
let week = Duration::from_secs(7 * 24 * 3600);
let now = SystemTime::now();
for (index, (modified, path)) in files.into_iter().enumerate() {
let old = now.duration_since(modified).is_ok_and(|age| age > week);
if index >= KEEP || old {
let _ = fs::remove_file(path);
}
}
}
#[cfg(unix)]
fn create_private_dir(dir: &Path) -> std::io::Result<()> {
use std::os::unix::fs::DirBuilderExt as _;
fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)
}
#[cfg(not(unix))]
fn create_private_dir(dir: &Path) -> std::io::Result<()> {
fs::create_dir_all(dir)
}
#[cfg(unix)]
fn write_private_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::os::unix::fs::OpenOptionsExt as _;
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)?;
file.write_all(bytes)?;
file.sync_all()
}
#[cfg(not(unix))]
fn write_private_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)?;
file.write_all(bytes)?;
file.sync_all()
}
#[cfg(test)]
mod tests {
use super::*;
use gpui_kit::Image;
#[test]
fn text_on_the_clipboard_is_pasted_as_text() {
let item = ClipboardItem::new_string("hello".into());
assert_eq!(clipboard_paste(&item), ClipboardPaste::Text("hello".into()));
}
#[test]
fn an_image_without_text_is_pasted_as_a_file() {
let item = ClipboardItem::new_image(&Image {
format: gpui_kit::ImageFormat::Png,
bytes: b"png".to_vec(),
id: 1,
});
match clipboard_paste(&item) {
ClipboardPaste::Image(image) => {
assert_eq!(image.bytes, b"png");
assert_eq!(image.extension, "png");
}
other => panic!("{other:?}"),
}
}
#[test]
fn a_screenshot_is_written_where_a_local_agent_can_read_it() {
let dir = tempfile::tempdir().unwrap();
let path = save_in(dir.path(), b"png-bytes", "png").unwrap();
assert_eq!(fs::read(&path).unwrap(), b"png-bytes");
assert!(
path.file_name()
.unwrap()
.to_str()
.unwrap()
.starts_with("shellrs-paste-")
);
assert!(path.extension().is_some_and(|ext| ext == "png"));
}
#[test]
fn a_huge_image_is_refused() {
let dir = tempfile::tempdir().unwrap();
let bytes = vec![0; MAX_BYTES + 1];
assert_eq!(
save_in(dir.path(), &bytes, "png").unwrap_err(),
t!("terminal.paste.image_too_large")
);
}
}
+20
View File
@@ -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,11 @@ impl TerminalPanel {
cx,
);
terminal.set_menu_items(connection_menu_items(id, host_id));
// 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 +115,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()));
}
},
),
];
+42 -10
View File
@@ -30,6 +30,7 @@ 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::{
@@ -226,6 +227,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<()>,
}
@@ -313,6 +316,7 @@ impl TerminalView {
link_tooltip_task: None,
reported_button: None,
reported_cell: None,
image_paste: ImagePaste::Off,
_subscriptions: subscriptions,
_blink_task: blink_task,
}
@@ -426,19 +430,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`].
@@ -872,12 +909,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(
+112 -5
View File
@@ -62,15 +62,15 @@ 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,
LocalTerminalPanelEvent, MAX_BYTES, RemoteTerminalId, SearchDirection,
SharedRemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalLifecycle,
TerminalPanel, TerminalPanelEvent, TerminalView,
TerminalPanel, TerminalPanelEvent, TerminalView, paste_file_name,
};
use crate::update::{UpdateServices, Updater, UpdaterEvent};
@@ -1764,6 +1764,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 +2343,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 +2383,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 +2563,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)
+108
View File
@@ -1257,3 +1257,111 @@ 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(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()
);
}