终端通知:程序请求的通知(OSC 9 / 777)和响铃提醒
- 程序用 OSC 9 或 OSC 777 notify 请求通知,终端响铃时也提醒;OSC 9 里 ConEmu 的进度条、当前目录等命令不算。 - ShellRS 不在前台时发系统通知,点了回到那个终端;在前台但终端在别的标签 时弹应用内通知,点一下切过去。响铃在终端就在眼前时不提醒。 - 标题是标签名,同一终端程序通知 2 秒、响铃 10 秒内只提醒一次;设置 › 终端 › 通知可分别关掉。 - 启动时设置应用标识 com.shellrs.ShellRS,Windows 安装程序的快捷方式用 同一个 AppUserModelID。 - 不做 OSC 133 长命令通知:要每台服务器装 shell 集成,收益不抵成本。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
befdfc7b02
commit
a9e8d8dbef
16 files changed
+799
-13
No files matched your search
@@ -56,6 +56,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH
|
||||
- 双击、回车、F4、右键「编辑」都打开它,本地文件也行;新建文件后直接打开。
|
||||
- 只认 UTF-8(不做 GBK),上限 5 MB,约 15 种语言语法高亮。
|
||||
- 远程文件原地改写:截断后写原文件,不走 `.filepart` 加改名。保存前核对,被改过就问「覆盖」。
|
||||
- **终端通知**:只做程序请求的(OSC 9 / 777)和响铃,不做 OSC 133 长命令通知:那要每台服务器装 shell 集成,收益不抵成本。不在前台发系统通知,在前台而终端在别的标签发应用内通知;响铃在终端就在眼前时不提醒。
|
||||
- **预览**:图片和 Markdown(不做 HTML),显示在快速查看式的大对话框里,不是标签。
|
||||
- 双击图片预览;Markdown 和 SVG 是文本,双击编辑,右键另有「预览」。
|
||||
- 图片默认适合窗口居中,可放大、缩小、看原图、滚动;比例只写百分比。
|
||||
@@ -249,6 +250,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- **链接**:正则找出的地址和 OSC 8 标出的都算,只认 http / https(`links.rs`);OSC 8 的格子优先于正则。一直画成蓝色(`theme.blue`,主题的 `link` 和正文同色)加下划线,⌘ / Ctrl 单击才打开,普通单击照旧选文字。
|
||||
- **鼠标上报**(`mouse.rs` 是纯编码):按住 Shift 不上报,右键始终是自己的菜单。写入走 `engine.write`,不算用户输入。
|
||||
- **OSC 52 只写不读**(`Osc52::OnlyCopy`),只认剪贴板 `c`,主选择区忽略。
|
||||
- **通知**:OSC 9 / 777 由 `terminal/notices.rs` 的扫描器在解析线程上认(alacritty 会丢掉),OSC 9 里「数字;」开头的是 ConEmu 的命令,不算通知。投递在 `workspace/notices.rs`:窗口不在前台走 GPUI 的 `show_system_notification`(tag 是 `terminal:local:<id>` / `terminal:remote:<id>`,点击回调按 tag 切标签),在前台走应用内通知。`shellrs::init` 里的 `set_app_identity` 用 bundle id,测试平台没有它就不发系统通知;macOS 上 `cargo run` 没有 bundle,看不到系统通知。
|
||||
|
||||
## 测试约定
|
||||
|
||||
|
||||
@@ -173,6 +173,7 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口]
|
||||
- **清屏**(⌘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 的菜单。
|
||||
- **通知**:程序可以请求通知(OSC 9,或 OSC 777 的 `notify;标题;正文`),终端响铃时也会提醒。ShellRS 不在前台时发系统通知(macOS 第一次会询问是否允许),点通知回到那个终端;ShellRS 在前台但终端在别的标签时,在窗口右上角提示,点一下切过去。程序请求的通知即使终端就在眼前也会提示;响铃只在看不见那个终端时提醒(Tab 补全等也会响铃)。通知标题是标签名,看得出是哪台主机发的。同一终端的程序通知 2 秒内、响铃 10 秒内只提醒一次。设置 › 终端 › 通知可以分别关掉。命令跑完想被提醒,可以在后面加上 `; printf '\e]9;完成\a'` 或 `; tput bel`;Claude Code 等命令行工具需要你处理时也会这样提醒。
|
||||
- **远程复制**:远程程序用 OSC 52 复制的内容(如 tmux 的 `set -g set-clipboard on`、vim 的 OSC 52 插件)直接进本机剪贴板。远程程序读不了本机剪贴板。
|
||||
|
||||
## 右侧栏
|
||||
|
||||
@@ -60,8 +60,11 @@ Source: "{#BuildDir}\shellrs.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#BuildDir}\shellrs-cli.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\ShellRS"; Filename: "{app}\shellrs.exe"
|
||||
Name: "{autodesktop}\ShellRS"; Filename: "{app}\shellrs.exe"; Tasks: desktopicon
|
||||
; The AppUserModelID ShellRS gives itself (`set_app_identity`, for its
|
||||
; notifications): the same here keeps a pinned shortcut and the running
|
||||
; window one taskbar button.
|
||||
Name: "{autoprograms}\ShellRS"; Filename: "{app}\shellrs.exe"; AppUserModelID: "com.shellrs.ShellRS"
|
||||
Name: "{autodesktop}\ShellRS"; Filename: "{app}\shellrs.exe"; AppUserModelID: "com.shellrs.ShellRS"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
; Interactive installs only: an update is restarted by ShellRS's own helper,
|
||||
|
||||
@@ -38,5 +38,9 @@ mod testing;
|
||||
/// Initialize GPUI Kit, locale, key bindings and global actions. Call once
|
||||
/// inside `Application::run` before creating any view.
|
||||
pub fn init(cx: &mut gpui_kit::App) {
|
||||
// The identity the system shows notifications under, set before any
|
||||
// window opens: on Windows the AppUserModelID, the same as the macOS
|
||||
// bundle's.
|
||||
cx.set_app_identity("com.shellrs.ShellRS", "ShellRS");
|
||||
app::init(cx);
|
||||
}
|
||||
+2
-2
@@ -7,8 +7,8 @@ mod store;
|
||||
|
||||
pub use apply::apply;
|
||||
pub use model::{
|
||||
AppSettings, Appearance, Choice, ExternalCliSettings, InterfaceLanguage, TerminalFontSettings,
|
||||
UpdateSettings,
|
||||
AppSettings, Appearance, Choice, ExternalCliSettings, InterfaceLanguage, NotificationSettings,
|
||||
TerminalFontSettings, UpdateSettings,
|
||||
};
|
||||
pub use settings_panel::{SettingsPanel, SettingsPanelEvent};
|
||||
pub use store::{SettingsStore, SettingsStoreEvent};
|
||||
@@ -23,6 +23,28 @@ pub struct AppSettings {
|
||||
/// 显示隐藏文件 in the SFTP lists, each side on its own, switched from
|
||||
/// their toolbars and menus.
|
||||
pub show_hidden: ShowHiddenFiles,
|
||||
/// 终端 → 通知.
|
||||
pub notifications: NotificationSettings,
|
||||
}
|
||||
|
||||
/// 终端 → 通知: what a terminal may tell the user about while they look
|
||||
/// elsewhere. Both on until the user turns them off.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct NotificationSettings {
|
||||
/// 程序发送的通知: OSC 9 and 777.
|
||||
pub programs: bool,
|
||||
/// 响铃时通知.
|
||||
pub bell: bool,
|
||||
}
|
||||
|
||||
impl Default for NotificationSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
programs: true,
|
||||
bell: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 外部 CLI.
|
||||
@@ -280,6 +302,15 @@ mod tests {
|
||||
assert!(!settings.show_hidden.local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notifications_are_on_unless_the_file_says_otherwise() {
|
||||
let settings: AppSettings = serde_json::from_str("{}").unwrap();
|
||||
assert!(settings.notifications.programs && settings.notifications.bell);
|
||||
let settings: AppSettings =
|
||||
serde_json::from_str(r#"{"notifications":{"bell":false}}"#).unwrap();
|
||||
assert!(settings.notifications.programs && !settings.notifications.bell);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_updates_are_on_unless_the_file_says_otherwise() {
|
||||
let settings: AppSettings = serde_json::from_str("{}").unwrap();
|
||||
|
||||
@@ -27,7 +27,7 @@ use crate::terminal::{
|
||||
};
|
||||
use crate::update::{Phase, Tone, UpdateSnapshot, UpdateStep, Updater, build_info, platform};
|
||||
|
||||
use super::{AppSettings, Choice, SettingsStore};
|
||||
use super::{AppSettings, Choice, NotificationSettings, SettingsStore};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SettingsPanelEvent {
|
||||
@@ -315,9 +315,48 @@ fn terminal_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
|
||||
})
|
||||
.keywords(["预览", "字体", "字号", "行高"]),
|
||||
]),
|
||||
notification_group(store),
|
||||
]
|
||||
}
|
||||
|
||||
/// 终端 → 通知: 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 {
|
||||
let switch = |read: fn(&NotificationSettings) -> bool,
|
||||
write: fn(&mut NotificationSettings, bool)| {
|
||||
let (reader, writer) = (store.clone(), store.clone());
|
||||
SettingField::switch(
|
||||
move |cx| read(&reader.read(cx).settings().notifications),
|
||||
move |on, cx| {
|
||||
writer.update(cx, |store, cx| {
|
||||
store.update(|settings| write(&mut settings.notifications, on), cx)
|
||||
});
|
||||
},
|
||||
)
|
||||
.default_value(true)
|
||||
};
|
||||
SettingGroup::new()
|
||||
.title("通知")
|
||||
.description(
|
||||
"ShellRS 不在前台时发系统通知;在前台但终端在别的标签时,在窗口右上角提示,点一下切过去。",
|
||||
)
|
||||
.items([
|
||||
SettingItem::new(
|
||||
"程序发送的通知",
|
||||
switch(
|
||||
|settings| settings.programs,
|
||||
|settings, on| settings.programs = on,
|
||||
),
|
||||
)
|
||||
.description("远程或本地程序用 OSC 9 / 777 请求的通知,例如 printf '\\e]9;完成\\a'。"),
|
||||
SettingItem::new(
|
||||
"响铃时通知",
|
||||
switch(|settings| settings.bell, |settings, on| settings.bell = on),
|
||||
)
|
||||
.description("终端就在当前标签时不提示。"),
|
||||
])
|
||||
}
|
||||
|
||||
/// 外部 CLI: whether the `shellrs` command may use the saved hosts, the
|
||||
/// command itself, and the skill that teaches agents to use it.
|
||||
fn external_cli_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
|
||||
|
||||
+67
-1
@@ -20,6 +20,7 @@ use crate::connection::{ConnectionPrompt, ConnectionPromptReply, Latency};
|
||||
use crate::host::HostOs;
|
||||
|
||||
use super::links::{LinkMarker, url_search, visible_links};
|
||||
use super::notices::{NoticeScanner, ProgramNotice, TerminalNotice};
|
||||
use super::search::{MatchMarker, SearchDirection, SearchMark, SearchPosition, TerminalSearch};
|
||||
use super::{
|
||||
ExecRequest, ExecResult, SharedTerminalTransportFactory, TerminalLifecycle, TerminalSize,
|
||||
@@ -186,6 +187,13 @@ impl TerminalEngine {
|
||||
TerminalUiEventKind::ClipboardStore(text) => {
|
||||
cx.write_to_clipboard(gpui_kit::ClipboardItem::new_string(text));
|
||||
}
|
||||
TerminalUiEventKind::Notice(notice) => {
|
||||
return Some(TerminalEvent::Notice(notice.cleaned()));
|
||||
}
|
||||
TerminalUiEventKind::Bell => {
|
||||
let line = cursor_line_text(&self.runtime.term.lock());
|
||||
return Some(TerminalEvent::Notice(TerminalNotice::bell(&line)));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -508,6 +516,25 @@ impl TerminalEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of the line the cursor is on, or of the nearest line above it
|
||||
/// on screen that has any: where a program that rings for an answer has
|
||||
/// its question.
|
||||
fn cursor_line_text(term: &AlacrittyTerm) -> String {
|
||||
let mut line = term.grid().cursor.point.line;
|
||||
loop {
|
||||
let text: String = term.grid()[line]
|
||||
.into_iter()
|
||||
.filter(|cell| !cell.flags.contains(Flags::WIDE_CHAR_SPACER))
|
||||
.map(|cell| cell.c)
|
||||
.collect();
|
||||
let text = text.trim();
|
||||
if !text.is_empty() || line.0 <= 0 {
|
||||
return text.to_string();
|
||||
}
|
||||
line = Line(line.0 - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the leading cell and spacer of a full-width glyph visually atomic,
|
||||
/// for the selection and for find highlights alike.
|
||||
/// Alacritty stores CJK glyphs in one `WIDE_CHAR` cell followed by a
|
||||
@@ -546,6 +573,8 @@ impl Drop for TerminalEngine {
|
||||
pub enum TerminalEvent {
|
||||
PromptRequested(ConnectionPrompt),
|
||||
HostOsDetected(HostOs),
|
||||
/// Something to show the user while they look elsewhere.
|
||||
Notice(TerminalNotice),
|
||||
}
|
||||
|
||||
struct TerminalRuntime {
|
||||
@@ -582,6 +611,8 @@ impl TerminalRuntime {
|
||||
.name("shellrs-terminal-parser".into())
|
||||
.spawn(move || {
|
||||
let mut processor = Processor::new();
|
||||
// Alacritty drops the notifications programs ask for.
|
||||
let mut notices = NoticeScanner::default();
|
||||
while let Ok(event) = transport_receiver.recv_blocking() {
|
||||
match event {
|
||||
TerminalTransportEvent::Started => {
|
||||
@@ -589,6 +620,9 @@ impl TerminalRuntime {
|
||||
}
|
||||
TerminalTransportEvent::Output(bytes) => {
|
||||
processor.advance(&mut *parser_term.lock(), &bytes);
|
||||
for notice in notices.scan(&bytes) {
|
||||
parser_proxy.send(TerminalUiEventKind::Notice(notice));
|
||||
}
|
||||
parser_proxy.wakeup();
|
||||
}
|
||||
TerminalTransportEvent::HostOsDetected(os) => {
|
||||
@@ -731,10 +765,13 @@ impl EventListener for TerminalEventProxy {
|
||||
.commands
|
||||
.send(TerminalTransportCommand::Write(response.into_bytes()));
|
||||
}
|
||||
Event::Bell => {
|
||||
self.send(TerminalUiEventKind::Bell);
|
||||
self.wakeup();
|
||||
}
|
||||
Event::Wakeup
|
||||
| Event::MouseCursorDirty
|
||||
| Event::CursorBlinkingChange
|
||||
| Event::Bell
|
||||
| Event::Exit
|
||||
| Event::ChildExit(_) => self.wakeup(),
|
||||
// Only the clipboard: a program setting the primary selection
|
||||
@@ -784,6 +821,9 @@ enum TerminalUiEventKind {
|
||||
ColorRequest(usize, Arc<dyn Fn(Rgb) -> String + Send + Sync>),
|
||||
/// A program's copy, by OSC 52.
|
||||
ClipboardStore(String),
|
||||
/// A notification a program asked for, by OSC 9 or 777.
|
||||
Notice(ProgramNotice),
|
||||
Bell,
|
||||
}
|
||||
|
||||
fn append_message(term: &Arc<FairMutex<AlacrittyTerm>>, message: &str) {
|
||||
@@ -1018,6 +1058,32 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notifications_and_the_bell_reach_the_ui() {
|
||||
let (mut term, _, ui_events) = test_term_with_channels(40, 4);
|
||||
// The parser thread scans what it feeds the terminal.
|
||||
let output = "构建中\r\n\x1b]777;notify;构建;完成\x07ok?\x07".as_bytes();
|
||||
let mut scanner = NoticeScanner::default();
|
||||
let found = scanner.scan(output);
|
||||
feed(&mut term, output);
|
||||
assert_eq!(
|
||||
found,
|
||||
[ProgramNotice {
|
||||
title: Some("构建".into()),
|
||||
body: "完成".into()
|
||||
}]
|
||||
);
|
||||
let bells = ui_events
|
||||
.try_iter()
|
||||
.filter(|event| matches!(event.kind, TerminalUiEventKind::Bell))
|
||||
.count();
|
||||
assert_eq!(bells, 1, "the BEL ending the OSC does not ring");
|
||||
assert_eq!(cursor_line_text(&term), "ok?");
|
||||
// An empty cursor line gives the nearest line above with text.
|
||||
feed(&mut term, b"\r\n\r\n");
|
||||
assert_eq!(cursor_line_text(&term), "ok?");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn programs_may_copy_to_the_clipboard_but_not_read_it() {
|
||||
let (mut term, commands, ui_events) = test_term_with_channels(20, 2);
|
||||
|
||||
@@ -13,8 +13,8 @@ use crate::app::{CatalogIcon, CenterTab, CloseLocalTerminal, RestartLocalTermina
|
||||
use crate::shared::{ClosableTabTitle, close_tab_items};
|
||||
|
||||
use super::{
|
||||
LocalTerminalId, SharedTerminalTransportFactory, TerminalLifecycle, TerminalStatus,
|
||||
TerminalView,
|
||||
LocalTerminalId, SharedTerminalTransportFactory, TerminalEvent, TerminalLifecycle,
|
||||
TerminalNotice, TerminalStatus, TerminalView,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -22,6 +22,7 @@ pub enum LocalTerminalPanelEvent {
|
||||
Activated(LocalTerminalId),
|
||||
Closed(LocalTerminalId),
|
||||
StatusChanged(LocalTerminalId),
|
||||
Notice(LocalTerminalId, TerminalNotice),
|
||||
}
|
||||
|
||||
/// A local login-shell Dock tab backed by the shared terminal view.
|
||||
@@ -50,10 +51,17 @@ impl LocalTerminalPanel {
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let subscriptions = vec![cx.observe(&terminal, move |_, _, cx| {
|
||||
cx.emit(LocalTerminalPanelEvent::StatusChanged(id));
|
||||
cx.notify();
|
||||
})];
|
||||
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()));
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
Self {
|
||||
id,
|
||||
@@ -64,6 +72,12 @@ impl LocalTerminalPanel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 「本地终端 1」: what a notification calls it, whatever title the
|
||||
/// shell gives the tab.
|
||||
pub fn default_title(&self) -> &str {
|
||||
&self.default_title
|
||||
}
|
||||
|
||||
pub fn tab_group(&self) -> Option<WeakEntity<TabGroup>> {
|
||||
self.tab_group.clone()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ mod local_pty;
|
||||
mod local_terminal_panel;
|
||||
mod model;
|
||||
mod mouse;
|
||||
mod notices;
|
||||
mod search;
|
||||
mod terminal_panel;
|
||||
mod terminal_view;
|
||||
@@ -24,6 +25,7 @@ pub use local_terminal_panel::{LocalTerminalPanel, LocalTerminalPanelEvent};
|
||||
pub use model::{
|
||||
LocalTerminalId, RemoteTerminalId, TerminalLifecycle, TerminalSize, TerminalStatus,
|
||||
};
|
||||
pub use notices::TerminalNotice;
|
||||
pub use search::{SearchDirection, SearchMark, SearchPosition};
|
||||
pub use terminal_panel::{TerminalPanel, TerminalPanelEvent};
|
||||
pub(crate) use terminal_view::terminal_key_bindings;
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//! What a terminal has to tell the user while they look elsewhere: a
|
||||
//! notification a program asks for (OSC 9 as iTerm2 has it, OSC 777 as urxvt
|
||||
//! and VTE have it), or the bell.
|
||||
//!
|
||||
//! Alacritty drops both OSC sequences, so the parser thread runs the output
|
||||
//! through `NoticeScanner` too.
|
||||
|
||||
use alacritty_terminal::vte::{Parser, Perform};
|
||||
|
||||
/// The longest title and body a notification shows, in characters.
|
||||
const TITLE_LIMIT: usize = 80;
|
||||
const BODY_LIMIT: usize = 240;
|
||||
|
||||
/// Something a terminal asks the user to look at.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum TerminalNotice {
|
||||
/// A program asked for a notification. Its title, when it gave one.
|
||||
Program { title: Option<String>, body: String },
|
||||
/// The bell rang. The line the cursor is on, which a program waiting
|
||||
/// for an answer usually has its question on.
|
||||
Bell { line: String },
|
||||
}
|
||||
|
||||
impl TerminalNotice {
|
||||
/// The bell, with the line the cursor is on.
|
||||
pub fn bell(line: &str) -> Self {
|
||||
TerminalNotice::Bell {
|
||||
line: clean_text(line, BODY_LIMIT),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A notification a program asked for, as it asked.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ProgramNotice {
|
||||
pub title: Option<String>,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
impl ProgramNotice {
|
||||
/// The notice to show: control characters out, long text cut.
|
||||
pub fn cleaned(self) -> TerminalNotice {
|
||||
TerminalNotice::Program {
|
||||
title: self
|
||||
.title
|
||||
.map(|title| clean_text(&title, TITLE_LIMIT))
|
||||
.filter(|title| !title.is_empty()),
|
||||
body: clean_text(&self.body, BODY_LIMIT),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the notifications programs ask for in terminal output, however the
|
||||
/// output is split into chunks.
|
||||
pub struct NoticeScanner {
|
||||
parser: Parser,
|
||||
found: Vec<ProgramNotice>,
|
||||
}
|
||||
|
||||
impl Default for NoticeScanner {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
parser: Parser::new(),
|
||||
found: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NoticeScanner {
|
||||
/// The notifications that end in `bytes`.
|
||||
pub fn scan(&mut self, bytes: &[u8]) -> Vec<ProgramNotice> {
|
||||
let mut found = Found(&mut self.found);
|
||||
self.parser.advance(&mut found, bytes);
|
||||
std::mem::take(&mut self.found)
|
||||
}
|
||||
}
|
||||
|
||||
struct Found<'a>(&'a mut Vec<ProgramNotice>);
|
||||
|
||||
impl Perform for Found<'_> {
|
||||
fn osc_dispatch(&mut self, params: &[&[u8]], _: bool) {
|
||||
if let Some(notice) = program_notice(params) {
|
||||
self.0.push(notice);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The notification an OSC sequence asks for, if it is one:
|
||||
/// - `9;body`, unless the body is a ConEmu command (`9;4;50` is a progress
|
||||
/// bar, `9;9;/tmp` the working directory);
|
||||
/// - `777;notify;title;body`.
|
||||
fn program_notice(params: &[&[u8]]) -> Option<ProgramNotice> {
|
||||
// The text after the first parameters, `;` and all: a body may hold one.
|
||||
let rest = |from: usize| -> String {
|
||||
params
|
||||
.get(from..)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|param| String::from_utf8_lossy(param))
|
||||
.collect::<Vec<_>>()
|
||||
.join(";")
|
||||
};
|
||||
let notice = match *params.first()? {
|
||||
b"9" => {
|
||||
let body = rest(1);
|
||||
let conemu = params.len() > 1
|
||||
&& !params[1].is_empty()
|
||||
&& params[1].iter().all(u8::is_ascii_digit);
|
||||
ProgramNotice {
|
||||
title: None,
|
||||
body: (!conemu).then_some(body)?,
|
||||
}
|
||||
}
|
||||
b"777" if params.get(1) == Some(&&b"notify"[..]) => ProgramNotice {
|
||||
title: Some(String::from_utf8_lossy(params.get(2)?).into_owned()),
|
||||
body: rest(3),
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
(!notice.body.trim().is_empty()).then_some(notice)
|
||||
}
|
||||
|
||||
/// `text` on one line, as a notification shows it: control characters and
|
||||
/// runs of white space become one space, and what is longer than `max`
|
||||
/// characters is cut with an ellipsis.
|
||||
pub fn clean_text(text: &str, max: usize) -> String {
|
||||
let words: Vec<&str> = text
|
||||
.split(|character: char| character.is_whitespace() || character.is_control())
|
||||
.filter(|word| !word.is_empty())
|
||||
.collect();
|
||||
let line = words.join(" ");
|
||||
if line.chars().count() <= max {
|
||||
return line;
|
||||
}
|
||||
let mut cut: String = line.chars().take(max.saturating_sub(1)).collect();
|
||||
cut.push('…');
|
||||
cut
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn scan(chunks: &[&[u8]]) -> Vec<ProgramNotice> {
|
||||
let mut scanner = NoticeScanner::default();
|
||||
chunks
|
||||
.iter()
|
||||
.flat_map(|chunk| scanner.scan(chunk))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn notice(title: Option<&str>, body: &str) -> ProgramNotice {
|
||||
ProgramNotice {
|
||||
title: title.map(str::to_string),
|
||||
body: body.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_is_a_notification_unless_it_is_a_conemu_command() {
|
||||
assert_eq!(
|
||||
scan(&[b"x\x1b]9;\xe9\x83\xa8\xe7\xbd\xb2\xe5\xae\x8c\xe6\x88\x90\x07y"]),
|
||||
[notice(None, "部署完成")]
|
||||
);
|
||||
// A body may hold a `;`.
|
||||
assert_eq!(scan(&[b"\x1b]9;a;b\x1b\\"]), [notice(None, "a;b")]);
|
||||
assert!(scan(&[b"\x1b]9;4;1;50\x07\x1b]9;9;/tmp\x07\x1b]9;\x07\x1b]9; \x07"]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_777_notify_has_a_title_and_a_body() {
|
||||
assert_eq!(
|
||||
scan(&["\x1b]777;notify;构建;完成\x07".as_bytes()]),
|
||||
[notice(Some("构建"), "完成")]
|
||||
);
|
||||
assert!(scan(&[b"\x1b]777;preexec\x07\x1b]777;notify;t\x07"]).is_empty());
|
||||
// Other sequences are not notifications.
|
||||
assert!(scan(&[b"\x1b]0;title\x07\x1b]52;c;aGk=\x07\x07"]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sequence_split_between_chunks_is_found_once_it_ends() {
|
||||
assert_eq!(
|
||||
scan(&[b"\x1b]777;noti", b"fy;t;", b"done\x1b", b"\\"]),
|
||||
[notice(Some("t"), "done")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_text_is_one_line_and_cut_when_long() {
|
||||
assert_eq!(clean_text(" a\tb\n\x1bc ", 10), "a b c");
|
||||
assert_eq!(clean_text("一二三四五", 4), "一二三…");
|
||||
assert_eq!(clean_text("一二三四", 4), "一二三四");
|
||||
assert_eq!(
|
||||
notice(Some(" "), "x").cleaned(),
|
||||
TerminalNotice::Program {
|
||||
title: None,
|
||||
body: "x".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ use crate::shared::{ClosableTabTitle, HostMark, LatencyLabel, RenamableTab, clos
|
||||
|
||||
use super::{
|
||||
RemoteTerminalId, SharedRemoteTerminalTransportProvider, TerminalEvent, TerminalLifecycle,
|
||||
TerminalMenuItems, TerminalStatus, TerminalView,
|
||||
TerminalMenuItems, TerminalNotice, TerminalStatus, TerminalView,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -28,6 +28,7 @@ pub enum TerminalPanelEvent {
|
||||
StatusChanged(RemoteTerminalId, HostId),
|
||||
PromptRequested(RemoteTerminalId, HostId, ConnectionPrompt),
|
||||
HostOsDetected(HostId, HostOs),
|
||||
Notice(RemoteTerminalId, TerminalNotice),
|
||||
}
|
||||
|
||||
/// A remote-host Dock panel backed by the shared terminal engine. The
|
||||
@@ -93,6 +94,9 @@ impl TerminalPanel {
|
||||
TerminalEvent::HostOsDetected(os) => {
|
||||
cx.emit(TerminalPanelEvent::HostOsDetected(this.host_id, *os));
|
||||
}
|
||||
TerminalEvent::Notice(notice) => {
|
||||
cx.emit(TerminalPanelEvent::Notice(this.id, notice.clone()));
|
||||
}
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
@@ -7,6 +7,7 @@ mod dock_skin;
|
||||
mod editors;
|
||||
mod forwards;
|
||||
mod links;
|
||||
mod notices;
|
||||
mod recent_hosts;
|
||||
mod sidebar;
|
||||
mod snippets;
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
//! What terminals tell the user while they look elsewhere: notifications
|
||||
//! programs ask for and the bell. The system's notification when the window
|
||||
//! is not in front, one in the window when another tab is.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gpui_kit::component::{WindowExt as _, notification::Notification};
|
||||
use gpui_kit::*;
|
||||
|
||||
use super::Workspace;
|
||||
use crate::app::CenterTab;
|
||||
use crate::settings::NotificationSettings;
|
||||
use crate::shared::RenamableTab as _;
|
||||
use crate::terminal::{LocalTerminalId, RemoteTerminalId, TerminalNotice};
|
||||
|
||||
/// How often one terminal may notify: a program at most every 2 seconds,
|
||||
/// the bell every 10. More in between are dropped.
|
||||
const PROGRAM_INTERVAL: Duration = Duration::from_secs(2);
|
||||
const BELL_INTERVAL: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Where a notice goes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Delivery {
|
||||
/// The system's notification center: the window is not in front.
|
||||
System,
|
||||
/// A notification in the window.
|
||||
InApp,
|
||||
None,
|
||||
}
|
||||
|
||||
/// Where `notice` goes. A program asked to notify, so it does so even over
|
||||
/// its own terminal; the bell rings for typing too (a Tab with nothing to
|
||||
/// complete), so it only tells of a terminal out of sight.
|
||||
pub(super) fn delivery(
|
||||
notice: &TerminalNotice,
|
||||
settings: NotificationSettings,
|
||||
window_active: bool,
|
||||
in_front: bool,
|
||||
) -> Delivery {
|
||||
let (allowed, quiet_in_front) = match notice {
|
||||
TerminalNotice::Program { .. } => (settings.programs, false),
|
||||
TerminalNotice::Bell { .. } => (settings.bell, true),
|
||||
};
|
||||
if !allowed {
|
||||
Delivery::None
|
||||
} else if !window_active {
|
||||
Delivery::System
|
||||
} else if in_front && quiet_in_front {
|
||||
Delivery::None
|
||||
} else {
|
||||
Delivery::InApp
|
||||
}
|
||||
}
|
||||
|
||||
/// The title and body: the tab's name first, so a notification says which
|
||||
/// terminal it is from, and a program cannot pass for another application.
|
||||
pub(super) fn notice_text(tab: &str, notice: &TerminalNotice) -> (String, String) {
|
||||
match notice {
|
||||
TerminalNotice::Program {
|
||||
title: Some(title),
|
||||
body,
|
||||
} => (format!("{tab}:{title}"), body.clone()),
|
||||
TerminalNotice::Program { title: None, body } => (tab.to_string(), body.clone()),
|
||||
TerminalNotice::Bell { line } => (format!("{tab}:响铃"), line.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a notice may go out now, the last of its kind having gone at
|
||||
/// `last`.
|
||||
pub(super) fn due(last: Option<Instant>, now: Instant, interval: Duration) -> bool {
|
||||
last.is_none_or(|last| now.saturating_duration_since(last) >= interval)
|
||||
}
|
||||
|
||||
/// The system notification's tag: one terminal's newest replaces its last,
|
||||
/// and a click on it says which terminal to show.
|
||||
pub(super) fn notice_tag(tab: CenterTab) -> Option<String> {
|
||||
match tab {
|
||||
CenterTab::Terminal(id) => Some(format!("terminal:remote:{}", id.0)),
|
||||
CenterTab::LocalTerminal(id) => Some(format!("terminal:local:{}", id.0)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn tab_of_tag(tag: &str) -> Option<CenterTab> {
|
||||
let rest = tag.strip_prefix("terminal:")?;
|
||||
let (kind, id) = rest.split_once(':')?;
|
||||
let id = id.parse().ok()?;
|
||||
match kind {
|
||||
"remote" => Some(CenterTab::Terminal(RemoteTerminalId(id))),
|
||||
"local" => Some(CenterTab::LocalTerminal(LocalTerminalId(id))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
/// A terminal has something to tell: say it where the user will see it.
|
||||
pub(super) fn on_terminal_notice(
|
||||
&mut self,
|
||||
tab: CenterTab,
|
||||
notice: &TerminalNotice,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let settings = self.settings().read(cx).settings().notifications;
|
||||
let in_front = self.active_tab == Some(tab);
|
||||
let delivery = delivery(notice, settings, window.is_window_active(), in_front);
|
||||
let (Some(tag), Some(tab_title)) = (notice_tag(tab), self.notice_tab_title(tab, cx)) else {
|
||||
return;
|
||||
};
|
||||
if delivery == Delivery::None {
|
||||
return;
|
||||
}
|
||||
let (kind, interval) = match notice {
|
||||
TerminalNotice::Program { .. } => ("program", PROGRAM_INTERVAL),
|
||||
TerminalNotice::Bell { .. } => ("bell", BELL_INTERVAL),
|
||||
};
|
||||
let key = format!("{tag}:{kind}");
|
||||
// The executor's clock, which tests can move on.
|
||||
let now = cx.background_executor().now();
|
||||
if !due(self.notice_times.get(&key).copied(), now, interval) {
|
||||
return;
|
||||
}
|
||||
self.notice_times.insert(key, now);
|
||||
let (title, body) = notice_text(&tab_title, notice);
|
||||
match delivery {
|
||||
Delivery::System => cx.show_system_notification(SystemNotification {
|
||||
tag: tag.into(),
|
||||
title: title.into(),
|
||||
body: body.into(),
|
||||
actions: Vec::new(),
|
||||
}),
|
||||
Delivery::InApp => {
|
||||
let workspace = cx.weak_entity();
|
||||
let notification = Notification::new()
|
||||
.id1::<TerminalNoticeTag>(SharedString::from(tag))
|
||||
.title(title)
|
||||
.message(body)
|
||||
.on_click(move |_, window, cx| {
|
||||
_ = workspace.update(cx, |workspace, cx| {
|
||||
workspace.show_terminal_tab(tab, window, cx)
|
||||
});
|
||||
});
|
||||
window.push_notification(notification, cx);
|
||||
}
|
||||
Delivery::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A system notification was clicked: bring the window forward on the
|
||||
/// terminal it came from, if that is still open.
|
||||
pub(super) fn on_notice_clicked(
|
||||
&mut self,
|
||||
tag: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
crate::app::bring_forward(window, cx);
|
||||
if let Some(tab) = tab_of_tag(tag) {
|
||||
self.show_terminal_tab(tab, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn show_terminal_tab(&self, tab: CenterTab, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let target = match tab {
|
||||
CenterTab::Terminal(id) => self
|
||||
.terminals
|
||||
.get(&id)
|
||||
.map(|panel| (panel.read(cx).tab_group(), panel.entity_id())),
|
||||
CenterTab::LocalTerminal(id) => self
|
||||
.local_terminals
|
||||
.get(&id)
|
||||
.map(|panel| (panel.read(cx).tab_group(), panel.entity_id())),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((group, panel)) = target {
|
||||
self.activate_tab(group, panel, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn notice_tab_title(&self, tab: CenterTab, cx: &App) -> Option<String> {
|
||||
match tab {
|
||||
CenterTab::Terminal(id) => Some(self.terminals.get(&id)?.read(cx).tab_title(cx).into()),
|
||||
CenterTab::LocalTerminal(id) => Some(
|
||||
self.local_terminals
|
||||
.get(&id)?
|
||||
.read(cx)
|
||||
.default_title()
|
||||
.to_string(),
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The in-window notifications of terminals, one a terminal at a time.
|
||||
struct TerminalNoticeTag;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
BELL_INTERVAL, CenterTab, Delivery, Duration, Instant, LocalTerminalId,
|
||||
NotificationSettings, RemoteTerminalId, TerminalNotice, delivery, due, notice_tag,
|
||||
notice_text, tab_of_tag,
|
||||
};
|
||||
|
||||
fn program(title: Option<&str>) -> TerminalNotice {
|
||||
TerminalNotice::Program {
|
||||
title: title.map(str::to_string),
|
||||
body: "完成".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn bell() -> TerminalNotice {
|
||||
TerminalNotice::Bell {
|
||||
line: "y/N?".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_system_tells_of_a_window_away_and_the_window_of_another_tab() {
|
||||
let on = NotificationSettings::default();
|
||||
for notice in [program(None), bell()] {
|
||||
assert_eq!(delivery(¬ice, on, false, true), Delivery::System);
|
||||
assert_eq!(delivery(¬ice, on, true, false), Delivery::InApp);
|
||||
}
|
||||
// In sight, a program still notifies; the bell does not.
|
||||
assert_eq!(delivery(&program(None), on, true, true), Delivery::InApp);
|
||||
assert_eq!(delivery(&bell(), on, true, true), Delivery::None);
|
||||
let off = NotificationSettings {
|
||||
programs: false,
|
||||
bell: false,
|
||||
};
|
||||
assert_eq!(delivery(&program(None), off, false, false), Delivery::None);
|
||||
assert_eq!(delivery(&bell(), off, false, false), Delivery::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_notice_is_titled_with_its_tab() {
|
||||
assert_eq!(
|
||||
notice_text("web-01", &program(Some("构建"))),
|
||||
("web-01:构建".to_string(), "完成".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
notice_text("web-01", &program(None)),
|
||||
("web-01".to_string(), "完成".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
notice_text("本地终端 1", &bell()),
|
||||
("本地终端 1:响铃".to_string(), "y/N?".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notices_are_spaced_out() {
|
||||
let start = Instant::now();
|
||||
assert!(due(None, start, BELL_INTERVAL));
|
||||
assert!(!due(
|
||||
Some(start),
|
||||
start + Duration::from_secs(9),
|
||||
BELL_INTERVAL
|
||||
));
|
||||
assert!(due(
|
||||
Some(start),
|
||||
start + Duration::from_secs(10),
|
||||
BELL_INTERVAL
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tag_names_its_terminal() {
|
||||
for tab in [
|
||||
CenterTab::Terminal(RemoteTerminalId(3)),
|
||||
CenterTab::LocalTerminal(LocalTerminalId(12)),
|
||||
] {
|
||||
assert_eq!(tab_of_tag(¬ice_tag(tab).unwrap()), Some(tab));
|
||||
}
|
||||
assert_eq!(notice_tag(CenterTab::Settings), None);
|
||||
assert_eq!(tab_of_tag("terminal:local:x"), None);
|
||||
assert_eq!(tab_of_tag("other:local:1"), None);
|
||||
}
|
||||
}
|
||||
@@ -189,6 +189,9 @@ pub struct Workspace {
|
||||
/// Editor tabs, one per file.
|
||||
pub(super) editors: HashMap<EditorId, Entity<EditorPanel>>,
|
||||
pub(super) next_editor_id: u64,
|
||||
/// When each terminal last notified, of each kind, keyed by
|
||||
/// `notices::notice_tag` and the kind.
|
||||
pub(super) notice_times: HashMap<String, std::time::Instant>,
|
||||
pub(super) _subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
@@ -439,6 +442,16 @@ impl Workspace {
|
||||
.filter(|host| host.state.is_connected())
|
||||
.map(|host| host.id)
|
||||
.collect();
|
||||
// A click on a terminal's system notification shows that terminal.
|
||||
let this = cx.weak_entity();
|
||||
let handle = window.window_handle();
|
||||
cx.on_system_notification_response(move |response, cx| {
|
||||
_ = handle.update(cx, |_, window, cx| {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.on_notice_clicked(&response.tag, window, cx)
|
||||
});
|
||||
});
|
||||
});
|
||||
// ⌘Q (and on Windows and Linux the window's close button) asks first
|
||||
// when an editor has changes not saved yet.
|
||||
let this = cx.weak_entity();
|
||||
@@ -534,10 +547,16 @@ impl Workspace {
|
||||
focus_handle,
|
||||
editors: HashMap::new(),
|
||||
next_editor_id: 1,
|
||||
notice_times: HashMap::new(),
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// The center tab in front.
|
||||
pub fn active_tab(&self) -> Option<CenterTab> {
|
||||
self.active_tab
|
||||
}
|
||||
|
||||
/// The host store, for tests and for panels created later.
|
||||
pub fn store(&self) -> &Entity<HostStore> {
|
||||
&self.store
|
||||
@@ -2151,6 +2170,9 @@ fn new_terminal_panel(
|
||||
this.store
|
||||
.update(cx, |store, cx| store.set_host_os(host_id, Some(os), cx));
|
||||
}
|
||||
TerminalPanelEvent::Notice(terminal_id, notice) => {
|
||||
this.on_terminal_notice(CenterTab::Terminal(*terminal_id), notice, window, cx)
|
||||
}
|
||||
},
|
||||
);
|
||||
(panel, subscription)
|
||||
@@ -2304,6 +2326,9 @@ fn new_local_terminal_panel(
|
||||
cx.notify();
|
||||
}
|
||||
LocalTerminalPanelEvent::StatusChanged(_) => cx.notify(),
|
||||
LocalTerminalPanelEvent::Notice(id, notice) => {
|
||||
this.on_terminal_notice(CenterTab::LocalTerminal(*id), notice, window, cx)
|
||||
}
|
||||
},
|
||||
);
|
||||
(panel, subscription)
|
||||
|
||||
@@ -889,3 +889,114 @@ async fn a_program_copies_to_the_local_clipboard(cx: &mut TestAppContext) {
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Have local terminal `id` print `text`: the fake shell prints back what it
|
||||
/// is sent, and a paste outside bracketed paste mode sends it as it is.
|
||||
fn print_into(cx: &mut TestAppContext, workspace: &Entity<Workspace>, id: u64, text: &str) {
|
||||
cx.update(|cx| {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(text.to_string()));
|
||||
let terminal = workspace
|
||||
.read(cx)
|
||||
.local_terminal(LocalTerminalId(id))
|
||||
.expect("the terminal is open")
|
||||
.read(cx)
|
||||
.terminal()
|
||||
.clone();
|
||||
terminal.update(cx, |terminal, cx| terminal.paste_clipboard(cx));
|
||||
});
|
||||
}
|
||||
|
||||
/// The system notifications shown once there are `count` of them.
|
||||
async fn system_notifications(
|
||||
cx: &mut TestAppContext,
|
||||
count: usize,
|
||||
) -> Vec<gpui_kit::SystemNotification> {
|
||||
for _ in 0..200 {
|
||||
let shown = cx.shown_system_notifications();
|
||||
if shown.len() >= count {
|
||||
return shown;
|
||||
}
|
||||
cx.executor().timer(Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!(
|
||||
"expected {count} system notifications, got {:?}",
|
||||
cx.shown_system_notifications()
|
||||
);
|
||||
}
|
||||
|
||||
fn in_window(cx: &mut TestAppContext, handle: WindowHandle<Root>) -> usize {
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.notifications(cx).len()
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn terminals_notify_where_the_user_will_see_it(cx: &mut TestAppContext) {
|
||||
let factory = Arc::new(FakeTerminalFactory::default());
|
||||
let (handle, workspace) = open_running_local_terminal(cx, factory).await;
|
||||
// Test windows start out not active.
|
||||
in_frame(cx, handle, |window, _| window.activate_window());
|
||||
assert!(cx.shown_system_notifications().is_empty());
|
||||
|
||||
// In sight, a program's notice shows in the window; the bell says nothing.
|
||||
print_into(cx, &workspace, 1, "\x1b]777;notify;构建;完成\x07\x07");
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
||||
window.notifications(cx).len() == 1
|
||||
})
|
||||
.await;
|
||||
cx.background_executor
|
||||
.advance_clock(Duration::from_millis(200));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(in_window(cx, handle), 1);
|
||||
assert!(cx.shown_system_notifications().is_empty());
|
||||
|
||||
// Its notice gone, the first terminal rings behind a second one.
|
||||
cx.background_executor
|
||||
.advance_clock(Duration::from_secs(10));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(in_window(cx, handle), 0);
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click("new-local-terminal", cx)
|
||||
});
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| {
|
||||
workspace
|
||||
.read(cx)
|
||||
.local_terminal(LocalTerminalId(2))
|
||||
.is_some_and(|terminal| {
|
||||
terminal.read(cx).status(cx).lifecycle() == &TerminalLifecycle::Running
|
||||
})
|
||||
})
|
||||
.await;
|
||||
print_into(cx, &workspace, 1, "y/N\x07");
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
||||
window.notifications(cx).len() == 1
|
||||
})
|
||||
.await;
|
||||
|
||||
// With the window away, a notice goes to the system, under the tab's
|
||||
// name, and clicking it shows that terminal.
|
||||
cx.update(|cx| {
|
||||
assert_eq!(
|
||||
workspace.read(cx).active_tab(),
|
||||
Some(shellrs::app::CenterTab::LocalTerminal(LocalTerminalId(2)))
|
||||
);
|
||||
});
|
||||
gpui_kit::VisualTestContext::from_window(handle.into(), cx).deactivate_window();
|
||||
print_into(cx, &workspace, 1, "\x1b]9;部署完成\x07");
|
||||
let shown = system_notifications(cx, 1).await;
|
||||
assert_eq!(shown[0].tag.as_ref(), "terminal:local:1");
|
||||
assert_eq!(shown[0].title.as_ref(), "本地终端 1");
|
||||
assert_eq!(shown[0].body.as_ref(), "部署完成");
|
||||
cx.simulate_system_notification_response(gpui_kit::SystemNotificationResponse {
|
||||
tag: shown[0].tag.clone(),
|
||||
action_id: None,
|
||||
});
|
||||
cx.run_until_parked();
|
||||
cx.update(|cx| {
|
||||
assert_eq!(
|
||||
workspace.read(cx).active_tab(),
|
||||
Some(shellrs::app::CenterTab::LocalTerminal(LocalTerminalId(1)))
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user