Compare 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
15 changed files with 683 additions and 23 deletions

No files matched your search

+1
View File
@@ -29,6 +29,7 @@ on:
- "src/credential/credential_dialog.rs" - "src/credential/credential_dialog.rs"
- "src/settings/settings_panel.rs" - "src/settings/settings_panel.rs"
- "src/app/shortcuts.rs" - "src/app/shortcuts.rs"
- "src/terminal/paste_image.rs"
- "src/workspace/window_state.rs" - "src/workspace/window_state.rs"
- "src/testing.rs" - "src/testing.rs"
- "tests/cli_bin.rs" - "tests/cli_bin.rs"
+1 -1
View File
@@ -70,7 +70,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH
- 只认 UTF-8(不做 GBK),上限 5 MB,约 15 种语言语法高亮。 - 只认 UTF-8(不做 GBK),上限 5 MB,约 15 种语言语法高亮。
- 远程文件原地改写:截断后写原文件,不走 `.filepart` 加改名。保存前核对,被改过就问「覆盖」。 - 远程文件原地改写:截断后写原文件,不走 `.filepart` 加改名。保存前核对,被改过就问「覆盖」。
- **设置 › 应用 › 窗口**:「记住窗口大小」(含最大化)和「记住窗口位置」两个开关,默认都开,下次启动生效。屏幕不在了或窗口不在屏幕上就居中,比屏幕大就缩小;全屏不记。 - **设置 › 应用 › 窗口**:「记住窗口大小」(含最大化)和「记住窗口位置」两个开关,默认都开,下次启动生效。屏幕不在了或窗口不在屏幕上就居中,比屏幕大就缩小;全屏不记。
- **终端交互**:「选中复制」默认关;「右键行为」无动作 / 粘贴 / 显示菜单,默认显示菜单。 - **终端交互**:「选中复制」默认关;「右键行为」无动作 / 粘贴 / 显示菜单,默认显示菜单。本地终端和已保存主机的远程终端里,剪贴板是图(没有文字)时粘贴会把图存成文件并把路径打进输入行,给终端里的 Agent 读;外部连接和临时连接不做。
- **终端通知**:只做程序请求的(OSC 9 / 777)和响铃,不做 OSC 133 长命令通知:那要每台服务器装 shell 集成,收益不抵成本。不在前台发系统通知,在前台而终端在别的标签发应用内通知;响铃在终端就在眼前时不提醒。 - **终端通知**:只做程序请求的(OSC 9 / 777)和响铃,不做 OSC 133 长命令通知:那要每台服务器装 shell 集成,收益不抵成本。不在前台发系统通知,在前台而终端在别的标签发应用内通知;响铃在终端就在眼前时不提醒。
- **主题**:「应用外观」(浅色 / 深色 / 跟随系统)决定深浅;「外观 › 主题」左右两栏里浅色、深色各选一套,按当前外观生效。组里第一项「界面跟随主题」默认关闭:界面是中性的灰(浅色是 gpui-kit 默认主题,深色见下条),只有终端按主题配色;打开后界面也按主题配色。用户试过默认跟随,觉得中性界面更合适,别改回去。 - **主题**:「应用外观」(浅色 / 深色 / 跟随系统)决定深浅;「外观 › 主题」左右两栏里浅色、深色各选一套,按当前外观生效。组里第一项「界面跟随主题」默认关闭:界面是中性的灰(浅色是 gpui-kit 默认主题,深色见下条),只有终端按主题配色;打开后界面也按主题配色。用户试过默认跟随,觉得中性界面更合适,别改回去。
- 默认深色(ShellRS Dark)不用 gpui-kit 的近黑,用参照微信深色的炭灰:底色 `#1e1e1f`,标题栏、标签栏、状态栏 `#2f2f30`,边框和悬停 `#3b3b3d`,文字 `#e0e0e4`。用户嫌近黑刺眼。次要文字比微信的 `#7b7b7f` 亮一些(`#929296`),保持对比度 ≥ 4.5。侧栏三个列表和「最近连接」的选中行同色(`selected_row_color`):浅色是悬停色三倍的对比,深色那样太刺眼,用加深后的悬停色,列表和表格的悬停只有它的一半(`DARK_HOVER_SHARE`)。两种外观下悬停和选中都要分得开。页面「重置」对卡片和开关各管各的。 - 默认深色(ShellRS Dark)不用 gpui-kit 的近黑,用参照微信深色的炭灰:底色 `#1e1e1f`,标题栏、标签栏、状态栏 `#2f2f30`,边框和悬停 `#3b3b3d`,文字 `#e0e0e4`。用户嫌近黑刺眼。次要文字比微信的 `#7b7b7f` 亮一些(`#929296`),保持对比度 ≥ 4.5。侧栏三个列表和「最近连接」的选中行同色(`selected_row_color`):浅色是悬停色三倍的对比,深色那样太刺眼,用加深后的悬停色,列表和表格的悬停只有它的一半(`DARK_HOVER_SHARE`)。两种外观下悬停和选中都要分得开。页面「重置」对卡片和开关各管各的。
+1
View File
@@ -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. - **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.** - **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. - **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 ## Right sidebar
+1
View File
@@ -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 等命令行工具需要你处理时也会这样提醒。 - **通知**:程序可以请求通知(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 这类全屏程序里不高亮,也不提醒。**尚未实现:按主机区分规则、加粗等文字样式、用键盘调整顺序。** - **关键字高亮**:设置 › 关键字高亮 里的规则把匹配的文字按规则的颜色显示在所有终端里,回滚历史里的也一样。只改显示:服务器的输出不变,复制出的文字也不变;窗口缩放、长行折行后照样匹配,被折成两行的词两段都上色。这个功能默认关闭,打开页面上方的「启用关键字高亮」后才生效,关掉它就一次停用全部规则;「预览」用当前终端的字体和配色显示一段示例输出,规则改动立即反映在预览和已打开的终端里。规则表一行一条,直接在表里改:「启用」、「正则表达式」(区分大小写,开头加 `(?i)` 不区分)、「备注」、颜色(点色块用取色器,或在旁边填 `#rrggbb`)、「通知」,行尾的垃圾桶删除这一条。正则写错时在框下方提示,这条规则暂不生效。规则从上到下应用,重叠处前面的规则优先;按住行首的把手拖动可调整顺序。初始有三条示例:`ERROR` 红色、`WARN` 琥珀色、IPv4 地址蓝色,都已启用、不通知,打开总开关就能看到效果,可以修改或删除。勾选「通知」的规则在看不到那个终端时提醒,和响铃一样:ShellRS 不在前台时发系统通知,终端在别的标签时在窗口右上角提示;标题是「标签名:备注」(没填备注时用正则),正文是匹配的那一行。只检查换了行的新输出,同一终端 10 秒内只提醒一次,反复重画的同一行 5 分钟内只提醒一次。vim、less、top、tmux 这类全屏程序里不高亮,也不提醒。**尚未实现:按主机区分规则、加粗等文字样式、用键盘调整顺序。**
- **远程复制**:远程程序用 OSC 52 复制的内容(如 tmux 的 `set -g set-clipboard on`、vim 的 OSC 52 插件)直接进本机剪贴板。远程程序读不了本机剪贴板。 - **远程复制**:远程程序用 OSC 52 复制的内容(如 tmux 的 `set -g set-clipboard on`、vim 的 OSC 52 插件)直接进本机剪贴板。远程程序读不了本机剪贴板。
- **粘贴图片**:本地终端和已保存主机的远程终端里,剪贴板是截图(没有文字)时,⌘V(其他平台 Ctrl+Shift+V)或右键粘贴会把图存成文件,再把路径粘进输入行,给终端里的 Agent 去读。本地写到系统临时目录;已保存的主机经 SFTP 写到远端 `/tmp`。剪贴板里有文字时仍粘文字。外部连接和临时连接不支持。
## 右侧栏 ## 右侧栏
+20
View File
@@ -71,6 +71,26 @@ terminal.command.multiline:
zh-CN: 这是一条多行命令,在这个终端里只能直接执行 zh-CN: 这是一条多行命令,在这个终端里只能直接执行
en: This is a multi-line command, which this terminal can only run directly 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 # State of a terminal
terminal.lifecycle.starting: terminal.lifecycle.starting:
zh-CN: 正在启动 zh-CN: 正在启动
+2
View File
@@ -11,10 +11,12 @@ mod download;
mod journal; mod journal;
mod meter; mod meter;
mod operations; mod operations;
mod put;
mod speed; mod speed;
mod sync; mod sync;
mod upload; mod upload;
mod worker; mod worker;
pub use put::put_bytes;
pub use worker::SshSftpTransportProvider; pub use worker::SshSftpTransportProvider;
#[cfg(all(test, unix))] #[cfg(all(test, unix))]
mod protocol_tests; 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), HostOsDetected(HostOs),
/// Something to show the user while they look elsewhere. /// Something to show the user while they look elsewhere.
Notice(TerminalNotice), 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 { struct TerminalRuntime {
+20 -7
View File
@@ -24,6 +24,7 @@ pub enum LocalTerminalPanelEvent {
Closed(LocalTerminalId), Closed(LocalTerminalId),
StatusChanged(LocalTerminalId), StatusChanged(LocalTerminalId),
Notice(LocalTerminalId, TerminalNotice), Notice(LocalTerminalId, TerminalNotice),
PasteProblem(SharedString),
} }
/// A local login-shell Dock tab backed by the shared terminal view. /// A local login-shell Dock tab backed by the shared terminal view.
@@ -42,24 +43,36 @@ impl LocalTerminalPanel {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
let terminal = cx.new(|cx| { let terminal = cx.new(|cx| {
TerminalView::new( let mut terminal = TerminalView::new(
("local-terminal", id.0), ("local-terminal", id.0),
move |_| default_title(id), move |_| default_title(id),
factory, factory,
window, window,
cx, cx,
) );
terminal.set_image_paste(super::ImagePaste::Local);
terminal
}); });
let subscriptions = vec![ let subscriptions = vec![
cx.observe(&terminal, move |_, _, cx| { cx.observe(&terminal, move |_, _, cx| {
cx.emit(LocalTerminalPanelEvent::StatusChanged(id)); cx.emit(LocalTerminalPanelEvent::StatusChanged(id));
cx.notify(); cx.notify();
}), }),
cx.subscribe(&terminal, move |_, _, event: &TerminalEvent, cx| { cx.subscribe(
if let TerminalEvent::Notice(notice) = event { &terminal,
cx.emit(LocalTerminalPanelEvent::Notice(id, notice.clone())); 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 { Self {
+5
View File
@@ -10,6 +10,7 @@ mod local_terminal_panel;
mod model; mod model;
mod mouse; mod mouse;
mod notices; mod notices;
mod paste_image;
mod run; mod run;
mod scrollbar; mod scrollbar;
mod search; mod search;
@@ -34,6 +35,10 @@ pub use model::{
TerminalSize, TerminalStatus, TerminalSize, TerminalStatus,
}; };
pub use notices::TerminalNotice; pub use notices::TerminalNotice;
pub use paste_image::{
ClipboardImage, ClipboardPaste, ImagePaste, MAX_BYTES, clipboard_paste, paste_file_name,
save_local,
};
pub use run::{ pub use run::{
BusyReason, RunContext, RunEvent, RunFailure, RunHandle, RunProgress, RunShell, RunTiming, BusyReason, RunContext, RunEvent, RunFailure, RunHandle, RunProgress, RunShell, RunTiming,
TerminalRun, 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), PromptRequested(RemoteTerminalId, HostId, ConnectionPrompt),
HostOsDetected(HostId, HostOs), HostOsDetected(HostId, HostOs),
Notice(RemoteTerminalId, TerminalNotice), 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 /// A remote-host Dock panel backed by the shared terminal engine. The
@@ -77,6 +83,11 @@ impl TerminalPanel {
cx, cx,
); );
terminal.set_menu_items(connection_menu_items(id, host_id)); 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 terminal
}); });
let subscriptions = vec![ let subscriptions = vec![
@@ -104,6 +115,15 @@ impl TerminalPanel {
TerminalEvent::Notice(notice) => { TerminalEvent::Notice(notice) => {
cx.emit(TerminalPanelEvent::Notice(this.id, notice.clone())); 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 crate::i18n::{UiLocale, t};
use super::mouse::{self, MouseReport, ReportButton, ReportKind}; use super::mouse::{self, MouseReport, ReportButton, ReportKind};
use super::paste_image::{ClipboardPaste, ImagePaste, clipboard_paste, save_local};
use super::scrollbar::ScrollbackHandle; use super::scrollbar::ScrollbackHandle;
use super::search::SearchMark; use super::search::SearchMark;
use super::{ use super::{
@@ -226,6 +227,8 @@ pub struct TerminalView {
reported_button: Option<ReportButton>, reported_button: Option<ReportButton>,
/// The cell the program last heard of: motion is reported once a cell. /// The cell the program last heard of: motion is reported once a cell.
reported_cell: Option<(usize, usize)>, reported_cell: Option<(usize, usize)>,
/// What a screenshot on the clipboard does in this terminal.
image_paste: ImagePaste,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
_blink_task: Task<()>, _blink_task: Task<()>,
} }
@@ -313,6 +316,7 @@ impl TerminalView {
link_tooltip_task: None, link_tooltip_task: None,
reported_button: None, reported_button: None,
reported_cell: None, reported_cell: None,
image_paste: ImagePaste::Off,
_subscriptions: subscriptions, _subscriptions: subscriptions,
_blink_task: blink_task, _blink_task: blink_task,
} }
@@ -426,19 +430,52 @@ impl TerminalView {
} }
pub fn paste_clipboard(&mut self, cx: &mut Context<Self>) { 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() { if !self.engine.read(cx).lifecycle().accepts_input() {
return; return;
} }
let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else { match clipboard_paste(&item) {
return; ClipboardPaste::Text(text) => self.paste_text(&text, cx),
}; ClipboardPaste::Image(image) => self.paste_image(image, cx),
self.engine.read(cx).paste(&text); ClipboardPaste::Empty => {}
}
}
pub fn paste_text(&mut self, text: &str, cx: &mut Context<Self>) {
self.engine.read(cx).paste(text);
self.selecting = false; self.selecting = false;
self.scroll_accumulator.reset(); self.scroll_accumulator.reset();
self.cursor_visible = true; self.cursor_visible = true;
cx.notify(); 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 /// Put `command` on the shell's input line, with `replace` in place of
/// what is typed there, and with `run` run it; see /// what is typed there, and with `run` run it; see
/// [`TerminalEngine::enter_command`]. /// [`TerminalEngine::enter_command`].
@@ -872,12 +909,7 @@ impl EntityInputHandler for TerminalView {
} }
fn paste(&mut self, item: ClipboardItem, _: &mut Window, cx: &mut Context<Self>) { fn paste(&mut self, item: ClipboardItem, _: &mut Window, cx: &mut Context<Self>) {
if let Some(text) = item.text() { self.paste_item(item, cx);
self.engine.read(cx).paste(&text);
self.selecting = false;
self.scroll_accumulator.reset();
cx.notify();
}
} }
fn replace_text_in_range( fn replace_text_in_range(
+112 -5
View File
@@ -62,15 +62,15 @@ use crate::settings::{
WindowSettings, WindowSettings,
}; };
use crate::sftp::{ use crate::sftp::{
SharedLocalDirectoryProvider, SharedSftpTransportProvider, SshSftpTransportProvider, RemotePath, SharedLocalDirectoryProvider, SharedSftpTransportProvider,
SystemLocalDirectoryProvider, TransferDirection, SshSftpTransportProvider, SystemLocalDirectoryProvider, TransferDirection, put_bytes,
}; };
use crate::shared::{commit_footer, confirm_danger, open_rename_tab_dialog}; use crate::shared::{commit_footer, confirm_danger, open_rename_tab_dialog};
use crate::terminal::{ use crate::terminal::{
DEFAULT_FONT_SIZE, LocalPtyTransportFactory, LocalTerminalId, LocalTerminalPanel, DEFAULT_FONT_SIZE, LocalPtyTransportFactory, LocalTerminalId, LocalTerminalPanel,
LocalTerminalPanelEvent, RemoteTerminalId, SearchDirection, LocalTerminalPanelEvent, MAX_BYTES, RemoteTerminalId, SearchDirection,
SharedRemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalLifecycle, SharedRemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalLifecycle,
TerminalPanel, TerminalPanelEvent, TerminalView, TerminalPanel, TerminalPanelEvent, TerminalView, paste_file_name,
}; };
use crate::update::{UpdateServices, Updater, UpdaterEvent}; 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( fn on_find_in_terminal(
&mut self, &mut self,
_: &FindInTerminal, _: &FindInTerminal,
@@ -2260,7 +2343,7 @@ fn new_terminal_panel(
let subscription = cx.subscribe_in( let subscription = cx.subscribe_in(
&panel, &panel,
window, window,
|this, _, event: &TerminalPanelEvent, window, cx| match event { |this, panel, event: &TerminalPanelEvent, window, cx| match event {
TerminalPanelEvent::Activated(terminal_id, _) => { TerminalPanelEvent::Activated(terminal_id, _) => {
this.set_active_tab(Some(CenterTab::Terminal(*terminal_id)), window, cx) this.set_active_tab(Some(CenterTab::Terminal(*terminal_id)), window, cx)
} }
@@ -2300,6 +2383,24 @@ fn new_terminal_panel(
TerminalPanelEvent::Notice(terminal_id, notice) => { TerminalPanelEvent::Notice(terminal_id, notice) => {
this.on_terminal_notice(CenterTab::Terminal(*terminal_id), notice, window, cx) 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) (panel, subscription)
@@ -2462,6 +2563,12 @@ fn new_local_terminal_panel(
LocalTerminalPanelEvent::Notice(id, notice) => { LocalTerminalPanelEvent::Notice(id, notice) => {
this.on_terminal_notice(CenterTab::LocalTerminal(*id), notice, window, cx) 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) (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; let shown = system_notifications(cx, 2).await;
assert_eq!(shown[1].body.as_ref(), "12:05 ERROR later"); 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()
);
}