From a9e8d8dbeffd1ab93b89ee1fab2dc8359de5f3d4 Mon Sep 17 00:00:00 2001 From: Xuz Date: Tue, 6 Oct 2026 14:37:53 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=88=E7=AB=AF=E9=80=9A=E7=9F=A5=EF=BC=9A?= =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E8=AF=B7=E6=B1=82=E7=9A=84=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=EF=BC=88OSC=209=20/=20777=EF=BC=89=E5=92=8C=E5=93=8D=E9=93=83?= =?UTF-8?q?=E6=8F=90=E9=86=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 程序用 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 --- CLAUDE.md | 2 + docs/manual.md | 1 + packaging/windows/shellrs.iss | 7 +- src/lib.rs | 4 + src/settings/mod.rs | 4 +- src/settings/model.rs | 31 +++ src/settings/settings_panel.rs | 41 +++- src/terminal/engine.rs | 68 ++++++- src/terminal/local_terminal_panel.rs | 26 ++- src/terminal/mod.rs | 2 + src/terminal/notices.rs | 202 +++++++++++++++++++ src/terminal/terminal_panel.rs | 6 +- src/workspace/mod.rs | 1 + src/workspace/notices.rs | 281 +++++++++++++++++++++++++++ src/workspace/workspace_view.rs | 25 +++ tests/ui/terminal.rs | 111 +++++++++++ 16 files changed, 799 insertions(+), 13 deletions(-) create mode 100644 src/terminal/notices.rs create mode 100644 src/workspace/notices.rs diff --git a/CLAUDE.md b/CLAUDE.md index 919443d..53acbbc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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:` / `terminal:remote:`,点击回调按 tag 切标签),在前台走应用内通知。`shellrs::init` 里的 `set_app_identity` 用 bundle id,测试平台没有它就不发系统通知;macOS 上 `cargo run` 没有 bundle,看不到系统通知。 ## 测试约定 diff --git a/docs/manual.md b/docs/manual.md index 14afa26..e4ec341 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -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 插件)直接进本机剪贴板。远程程序读不了本机剪贴板。 ## 右侧栏 diff --git a/packaging/windows/shellrs.iss b/packaging/windows/shellrs.iss index 97713f0..2e42619 100644 --- a/packaging/windows/shellrs.iss +++ b/packaging/windows/shellrs.iss @@ -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, diff --git a/src/lib.rs b/src/lib.rs index dfecb28..cb0577f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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); } diff --git a/src/settings/mod.rs b/src/settings/mod.rs index c78ffd9..a896a86 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -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}; diff --git a/src/settings/model.rs b/src/settings/model.rs index dbda787..4766e8e 100644 --- a/src/settings/model.rs +++ b/src/settings/model.rs @@ -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(); diff --git a/src/settings/settings_panel.rs b/src/settings/settings_panel.rs index addac84..debabd4 100644 --- a/src/settings/settings_panel.rs +++ b/src/settings/settings_panel.rs @@ -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 { }) .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) -> 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 { diff --git a/src/terminal/engine.rs b/src/terminal/engine.rs index 2319b08..8dfb03a 100644 --- a/src/terminal/engine.rs +++ b/src/terminal/engine.rs @@ -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 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>, 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); diff --git a/src/terminal/local_terminal_panel.rs b/src/terminal/local_terminal_panel.rs index 40402ba..01cbace 100644 --- a/src/terminal/local_terminal_panel.rs +++ b/src/terminal/local_terminal_panel.rs @@ -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> { self.tab_group.clone() } diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 0b71817..e79a2cd 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -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; diff --git a/src/terminal/notices.rs b/src/terminal/notices.rs new file mode 100644 index 0000000..73f3e7b --- /dev/null +++ b/src/terminal/notices.rs @@ -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, 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, + 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, +} + +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 { + 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); + +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 { + // 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::>() + .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 { + 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() + } + ); + } +} diff --git a/src/terminal/terminal_panel.rs b/src/terminal/terminal_panel.rs index 1302867..2c8101f 100644 --- a/src/terminal/terminal_panel.rs +++ b/src/terminal/terminal_panel.rs @@ -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())); + } }, ), ]; diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index 8d35913..e3d5731 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -7,6 +7,7 @@ mod dock_skin; mod editors; mod forwards; mod links; +mod notices; mod recent_hosts; mod sidebar; mod snippets; diff --git a/src/workspace/notices.rs b/src/workspace/notices.rs new file mode 100644 index 0000000..7ba8d9d --- /dev/null +++ b/src/workspace/notices.rs @@ -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, 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 { + 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 { + 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, + ) { + 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::(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, + ) { + 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) { + 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 { + 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); + } +} diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index fb87545..f9e7371 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -189,6 +189,9 @@ pub struct Workspace { /// Editor tabs, one per file. pub(super) editors: HashMap>, 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, pub(super) _subscriptions: Vec, } @@ -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 { + self.active_tab + } + /// The host store, for tests and for panels created later. pub fn store(&self) -> &Entity { &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) diff --git a/tests/ui/terminal.rs b/tests/ui/terminal.rs index 2eb9fcd..8e8a1a4 100644 --- a/tests/ui/terminal.rs +++ b/tests/ui/terminal.rs @@ -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, 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 { + 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) -> 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))) + ); + }); +}