From 5f588a4a18885b56202b4c8ade1cec3d4ec717c9 Mon Sep 17 00:00:00 2001 From: Xuz Date: Tue, 6 Oct 2026 17:54:04 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=88=E7=AB=AF=E5=85=B3=E9=94=AE=E5=AD=97?= =?UTF-8?q?=E9=AB=98=E4=BA=AE=EF=BC=9A=E6=8C=89=E8=A7=84=E5=88=99=E7=BB=99?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E7=9A=84=E6=96=87=E5=AD=97=E4=B8=8A=E8=89=B2?= =?UTF-8?q?=EF=BC=8C=E5=8F=AF=E9=80=89=E5=8C=B9=E9=85=8D=E6=97=B6=E9=80=9A?= =?UTF-8?q?=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 规则是关键字(smart case,同查找)或正则表达式,加颜色(主题的红、黄、 绿、青、蓝、紫)和加粗,在所有终端里显示,回滚历史里的也一样;重叠时 靠前的规则优先。 - 只改显示:每帧对可见行把折行接成逻辑行再匹配,不改服务器的输出,复制 出的文字不变;窗口缩放重排、折成两行的词都照样上色。用 regex crate 匹配,不用 alacritty 的 RegexSearch(编不了 \b,^ 只认第一行)。 - 勾了「匹配时通知」的规则在看不到那个终端时提醒,同响铃;解析线程在 换行前读完整的一行,同一终端 10 秒一次,反复重画的同一行 5 分钟一次。 - 全屏程序(vim、less、top、tmux)里不高亮也不通知。 - 设置左栏新增「关键字高亮」,排在「终端」后面;首次预置 ERROR 红粗、 WARN 黄、IPv4 蓝三条示例。settings.json 里读不了的规则单条丢掉,不让 整个设置回退成默认值。 - 光标闪烁不再每 500 ms 取一整份终端快照。 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 11 +- Cargo.lock | 1 + Cargo.toml | 3 + docs/manual.md | 4 +- src/app/actions.rs | 8 + src/app/assets.rs | 3 +- src/settings/apply.rs | 13 +- src/settings/highlight_dialog.rs | 277 ++++++++++ src/settings/mod.rs | 4 +- src/settings/model.rs | 99 +++- src/settings/settings_panel.rs | 188 ++++++- src/shared/segmented_control.rs | 20 + src/terminal/engine.rs | 67 ++- src/terminal/highlight.rs | 853 +++++++++++++++++++++++++++++++ src/terminal/mod.rs | 5 + src/terminal/notices.rs | 22 +- src/terminal/search.rs | 2 +- src/terminal/terminal_view.rs | 31 +- src/workspace/highlights.rs | 84 +++ src/workspace/mod.rs | 1 + src/workspace/notices.rs | 31 +- src/workspace/workspace_view.rs | 3 + tests/ui/settings.rs | 121 ++++- tests/ui/terminal.rs | 102 ++++ tests/ui/update.rs | 2 +- 25 files changed, 1918 insertions(+), 37 deletions(-) create mode 100644 src/settings/highlight_dialog.rs create mode 100644 src/terminal/highlight.rs create mode 100644 src/workspace/highlights.rs diff --git a/CLAUDE.md b/CLAUDE.md index 18bf628..af05800 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,10 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH - 只认 UTF-8(不做 GBK),上限 5 MB,约 15 种语言语法高亮。 - 远程文件原地改写:截断后写原文件,不走 `.filepart` 加改名。保存前核对,被改过就问「覆盖」。 - **终端通知**:只做程序请求的(OSC 9 / 777)和响铃,不做 OSC 133 长命令通知:那要每台服务器装 shell 集成,收益不抵成本。不在前台发系统通知,在前台而终端在别的标签发应用内通知;响铃在终端就在眼前时不提醒。 +- **关键字高亮**:设置左栏单独一个分类,排在「终端」下面。规则存在 `settings.json`,所有终端共用。 + - 一条规则是「关键字 / 正则 + 颜色(主题的六种色)+ 加粗 + 是否通知」。关键字按 smart case(同查找),正则按写法。重叠时靠前的优先,暂不做排序、按主机区分和背景色。 + - 首次(文件里没有这一项)预置三条示例:ERROR 红粗、WARN 黄、IPv4 蓝,都不通知;用户删光后保持为空。 + - 通知同响铃:看不到那个终端才提醒,同一终端 10 秒一次。全屏程序(备用屏)里不高亮也不通知。 - **预览**:图片和 Markdown(不做 HTML),显示在快速查看式的大对话框里,不是标签。 - 双击图片预览;Markdown 和 SVG 是文本,双击编辑,右键另有「预览」。 - 图片默认适合窗口居中,可放大、缩小、看原图、滚动;比例只写百分比。 @@ -101,7 +105,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `SshConnector`:所有登录都走它(终端、SFTP、exec、测试连接、端口转发、外部 CLI)。 - 远程终端的传输:pty、系统探测、延迟、旁路 exec。 - 代理握手;外部 CLI 用的 `run_command`。 -- `terminal/`:本地 PTY、驱动 `alacritty_terminal` 的引擎、`TerminalView`(网格、选区、查找、清屏)、终端字体全局量、右侧栏工具共用的 `ExecTarget`。 +- `terminal/`:本地 PTY、驱动 `alacritty_terminal` 的引擎、`TerminalView`(网格、选区、查找、清屏)、关键字高亮(`highlight.rs`)、终端字体全局量、右侧栏工具共用的 `ExecTarget`。 - `sftp/`:可注入的传输和本地目录接口、`RemotePath`、`RemoteFs` 协议适配、worker(批次、三次重连、空闲断网检测),以及: - `upload.rs` / `download.rs`:断点续传,续传记录由 `journal.rs` 管; - `operations.rs`:删除、重命名、新建、改权限; @@ -250,6 +254,11 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **终端字体走全局量 `TerminalFont`**(终端模块不依赖 settings),格子在下一帧 prepaint 时按新尺寸重排。 - **链接**:正则找出的地址和 OSC 8 标出的都算,只认 http / https(`links.rs`);OSC 8 的格子优先于正则。一直画成蓝色(`theme.blue`,主题的 `link` 和正文同色)加下划线,⌘ / Ctrl 单击才打开,普通单击照旧选文字。 - **鼠标上报**(`mouse.rs` 是纯编码):按住 Shift 不上报,右键始终是自己的菜单。写入走 `engine.write`,不算用户输入。 +- **关键字高亮只叠加在显示上**(`highlight.rs`)。`snapshot()` 每帧对可见行把折行接成逻辑行,用 `regex` crate 匹配,结果写进 `TerminalCell.highlight`,prepaint 改文字色和粗细;不存坐标,所以缩放重排、回滚都不用失效处理。 + - 不用 alacritty 的 `RegexSearch`(查找、链接用的那套):它的 DFA 编不了 `\b`,`^` 只在第一行生效,`$` 会碰到行尾补的空格。 + - 通知在解析线程上:按 `\n` 切开输出,在 LF **之前**读光标所在的逻辑行(这时 `\r` 和颜色都已生效),锁外匹配。备用屏和同步更新进行中不读。 + - 规则由 `settings::apply` 编译成全局量 `TerminalHighlights`,引擎 `observe_global` 后换进与解析线程共用的槽;重启运行时也要把槽传过去。 + - `settings.json` 里读不了的规则单条丢掉(`readable_rules`),否则整个设置文件会回退成默认值。 - **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/Cargo.lock b/Cargo.lock index 7e57aad..caa2d1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7724,6 +7724,7 @@ dependencies = [ "percent-encoding", "portable-pty", "rand 0.9.5", + "regex", "reqwest", "rusqlite", "russh", diff --git a/Cargo.toml b/Cargo.toml index 4e5dd1d..282b7da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,9 @@ dunce = "1" futures = "0.3" portable-pty = "0.9" rand = "0.9" +# 关键字高亮: the rules match the terminal's text, not Alacritty's grid +# search, whose DFA cannot build `\b` and loses `^` after the first line. +regex = "1" russh = "0.63" russh-sftp = "=3.0.0" serde_json = "1" diff --git a/docs/manual.md b/docs/manual.md index 137591b..317c1c9 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -100,11 +100,12 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 ## 设置 -侧栏最下方的「设置」按钮(或 ⌘,,其他平台 Ctrl+,)在中间区打开「设置」标签页;已经打开时切换到该标签,不会重复打开。设置页分两栏:左边是分类(外观、终端、外部 CLI、关于),上方可搜索,栏宽可拖动;右边是所选分类的设置。设置改动立即生效并保存在数据目录的 `settings.json` 里。 +侧栏最下方的「设置」按钮(或 ⌘,,其他平台 Ctrl+,)在中间区打开「设置」标签页;已经打开时切换到该标签,不会重复打开。设置页分两栏:左边是分类(外观、终端、关键字高亮、外部 CLI、关于),上方可搜索,栏宽可拖动;右边是所选分类的设置。设置改动立即生效并保存在数据目录的 `settings.json` 里。 - **外观 → 常规 → 界面语言**:跟随系统、简体中文、English,默认简体中文。英文界面尚在翻译中,目前只有 GPUI Kit 组件自带的文字(如搜索框提示)会切换。 - **外观 → 常规 → 应用外观**:深色、浅色、跟随系统,默认跟随系统;选「跟随系统」时随系统的深浅色切换。标题栏的主题按钮也会改写这一项。 - **终端 → 字体配置**:字体(只列出本机安装的等宽字体,默认是系统的等宽字体,macOS 上为 Menlo)、字号(8–32 像素,默认 13)、行高(字号的 1.0–2.0 倍,默认 1.54,即原来的 20 像素行高)。改动立即作用于所有已打开的终端。下方的预览按终端的排法显示字母、数字、易混字符、符号、中文、制表符和一行命令。设置文件里的字体在本机没有安装时,使用默认字体。 +- **关键字高亮**:终端里按规则给文字上色、匹配时提醒,见「终端」一节。 - **外部 CLI**:见下一节。 - **关于**:「应用更新」一组:当前版本和检查更新、更新渠道、自动升级,见「下载与更新」。 @@ -176,6 +177,7 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口] - **链接**:输出里的 `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 等命令行工具需要你处理时也会这样提醒。 +- **关键字高亮**:设置 › 关键字高亮 里的规则把匹配的文字按所选颜色(红、黄、绿、青、蓝、紫)和粗细显示在所有终端里,回滚历史里的也一样。只改显示:服务器的输出不变,复制出的文字也不变;窗口缩放、长行折行后照样匹配,被折成两行的词两段都上色。规则按「关键字」匹配(按字面;含大写字母时区分大小写,全小写时不区分)或按「正则表达式」匹配(区分大小写,开头加 `(?i)` 不区分);几条规则匹配到同一处时,列表里靠前的优先。初始有三条示例:`ERROR` 红色加粗、`WARN` 黄色、IPv4 地址蓝色,可以编辑或删除。勾选「匹配时通知」的规则在看不到那个终端时提醒,和响铃一样:ShellRS 不在前台时发系统通知,终端在别的标签时在窗口右上角提示;标题是「标签名:规则」,正文是匹配的那一行。只检查换了行的新输出,同一终端 10 秒内只提醒一次,反复重画的同一行 5 分钟内只提醒一次。vim、less、top、tmux 这类全屏程序里不高亮,也不提醒。**尚未实现:调整规则顺序、按主机区分规则、背景色样式。** - **远程复制**:远程程序用 OSC 52 复制的内容(如 tmux 的 `set -g set-clipboard on`、vim 的 OSC 52 插件)直接进本机剪贴板。远程程序读不了本机剪贴板。 ## 右侧栏 diff --git a/src/app/actions.rs b/src/app/actions.rs index 1fc7adf..a52e1a8 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -107,6 +107,8 @@ gpui_kit::actions!( OpenSettings, /// Close the settings tab. CloseSettings, + /// Open the dialog for a new 关键字高亮 rule. + NewHighlightRule, /// Increase the application base font (interface zoom). ZoomIn, /// Decrease the application base font (interface zoom). @@ -273,6 +275,12 @@ id_actions! { /// Ask for confirmation, then delete a credential. The hosts using it /// go back to logging in on their own. DeleteCredential(CredentialId); + + /// Open the dialog of the 关键字高亮 rule at this position. + EditHighlightRule(usize); + /// Ask for confirmation, then delete the 关键字高亮 rule at this + /// position. + DeleteHighlightRule(usize); } /// Move a host-tree row by dropping it beside a peer or into a group. diff --git a/src/app/assets.rs b/src/app/assets.rs index 8f2a1e6..6adad7a 100644 --- a/src/app/assets.rs +++ b/src/app/assets.rs @@ -72,7 +72,8 @@ icon_assets!( ZoomIn, ZoomOut, Zap, - ExternalLink + ExternalLink, + Highlighter ] ); diff --git a/src/settings/apply.rs b/src/settings/apply.rs index 5fc33ce..5b488b6 100644 --- a/src/settings/apply.rs +++ b/src/settings/apply.rs @@ -2,12 +2,13 @@ use gpui_kit::component::{ActiveTheme as _, Theme}; use gpui_kit::*; use crate::explorer::{FileSizeFormat, ShowHiddenFiles}; -use crate::terminal::{TerminalFont, is_font_installed}; +use crate::terminal::{TerminalFont, TerminalHighlights, is_font_installed}; use super::AppSettings; /// Bring the window in line with the settings: the theme, the locale, the -/// terminal font, the SFTP size format and whether SFTP shows hidden files. +/// terminal font and highlight rules, the SFTP size format and whether SFTP +/// shows hidden files. /// Does nothing where they already agree, so it can run on every settings /// change and every change of the system appearance. pub fn apply(settings: AppSettings, window: &mut Window, cx: &mut App) { @@ -41,6 +42,14 @@ pub fn apply(settings: AppSettings, window: &mut Window, cx: &mut App) { window.refresh(); } + // Compiled once here for every terminal, which observe it; only when + // the rules change, not on every change of the system appearance. + let rules = settings.terminal_highlight.rules; + let current = cx.try_global::(); + if current.map(|highlights| highlights.set().rules()) != Some(rules.as_slice()) { + cx.set_global(TerminalHighlights::new(rules)); + } + if cx.try_global::() != Some(&settings.file_size_format) { cx.set_global(settings.file_size_format); window.refresh(); diff --git a/src/settings/highlight_dialog.rs b/src/settings/highlight_dialog.rs new file mode 100644 index 0000000..d733552 --- /dev/null +++ b/src/settings/highlight_dialog.rs @@ -0,0 +1,277 @@ +//! The dialog of a 关键字高亮 rule: a new one, or one to change. + +use gpui_kit::component::{ + ActiveTheme as _, Sizable as _, WindowExt as _, + checkbox::Checkbox, + form::{Field, Form}, + input::{Input, InputEvent, InputState}, +}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::*; + +use crate::shared::{ + Segment, SegmentedControl, commit_footer, dismiss_form_error, form_error_notification, +}; +use crate::terminal::{HighlightColor, HighlightRule, PatternKind}; + +use super::SettingsStore; + +/// How wide the dialog is: six colors side by side, and a pattern of a fair +/// length on one line. +const DIALOG_WIDTH: f32 = 32.; + +/// The ways a pattern reads, in the order the dialog offers them. +const KINDS: [(PatternKind, &str); 2] = [ + (PatternKind::Keyword, "关键字"), + (PatternKind::Regex, "正则表达式"), +]; + +/// The body of the dialog. Checks the pattern on commit; the settings are +/// only written when it compiles. +pub struct HighlightForm { + store: Entity, + /// The position of the rule being changed, and the rule as it was. + editing: Option<(usize, HighlightRule)>, + pattern: Entity, + kind: PatternKind, + color: HighlightColor, + bold: bool, + notify: bool, + _pattern_changes: Subscription, +} + +impl HighlightForm { + fn new( + editing: Option, + store: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let editing = editing.and_then(|ix| { + let rule = store + .read(cx) + .settings() + .terminal_highlight + .rules + .get(ix)? + .clone(); + Some((ix, rule)) + }); + let rule = editing + .as_ref() + .map(|(_, rule)| rule.clone()) + .unwrap_or_default(); + let pattern = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("例如 ERROR") + .default_value(rule.pattern.clone()) + }); + // The preview shows what is typed. + let pattern_changes = cx.subscribe(&pattern, |_, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + cx.notify(); + } + }); + Self { + store, + editing, + pattern, + kind: rule.kind, + color: rule.color, + bold: rule.bold, + notify: rule.notify, + _pattern_changes: pattern_changes, + } + } + + fn rule(&self, cx: &App) -> HighlightRule { + HighlightRule { + pattern: self.pattern.read(cx).value().to_string(), + kind: self.kind, + color: self.color, + bold: self.bold, + notify: self.notify, + } + } + + /// Check the pattern and write the rule. Returns whether the dialog may + /// close. + fn commit(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let rule = self.rule(cx); + if let Err(error) = rule.compile() { + window.push_notification(form_error_notification(error), cx); + return false; + } + let editing = self.editing.clone(); + self.store.update(cx, |store, cx| { + store.update( + |settings| { + let rules = &mut settings.terminal_highlight.rules; + match editing { + // Only over the rule the dialog opened on. + Some((ix, original)) if rules.get(ix) == Some(&original) => { + rules[ix] = rule; + } + _ => rules.push(rule), + } + }, + cx, + ) + }); + true + } +} + +impl Render for HighlightForm { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let description = match self.kind { + PatternKind::Keyword => "按字面匹配。含大写字母时区分大小写,全小写时不区分。", + PatternKind::Regex => "区分大小写,开头加 (?i) 不区分。", + }; + let pattern = self.pattern.read(cx).value(); + let sample = if pattern.is_empty() { + "示例文字".into() + } else { + pattern + }; + Form::new() + .child( + Field::new().label("匹配方式").child( + SegmentedControl::new("highlight-kind") + .selected_index(KINDS.iter().position(|(kind, _)| *kind == self.kind)) + .on_change(cx.listener(|this, ix: &usize, _, cx| { + if let Some((kind, _)) = KINDS.get(*ix) { + this.kind = *kind; + cx.notify(); + } + })) + .segments(KINDS.map(|(_, label)| Segment::new(label))), + ), + ) + .child( + Field::new() + .label("内容") + .required(true) + .description(description) + .child( + Input::new(&self.pattern) + .id("highlight-pattern") + .small() + .font_family(cx.theme().mono_font_family.clone()), + ), + ) + .child( + Field::new().label("颜色").child( + SegmentedControl::new("highlight-color") + .selected_index( + HighlightColor::ALL + .iter() + .position(|color| *color == self.color), + ) + .on_change(cx.listener(|this, ix: &usize, _, cx| { + if let Some(color) = HighlightColor::ALL.get(*ix) { + this.color = *color; + cx.notify(); + } + })) + .segments( + HighlightColor::ALL + .map(|color| Segment::new(color.label()).swatch(color.hsla(cx))), + ), + ), + ) + .child( + Field::new().child( + Checkbox::new("highlight-bold") + .label("加粗") + .checked(self.bold) + .small() + .on_click(cx.listener(|this, checked: &bool, _, cx| { + this.bold = *checked; + cx.notify(); + })), + ), + ) + .child( + Field::new().label("预览").child( + // On the terminal's background, in its font. + div() + .id("highlight-preview") + .test_support() + .w_full() + .px_2() + .py_1() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().background) + .font_family(cx.theme().mono_font_family.clone()) + .text_sm() + .truncate() + .text_color(self.color.hsla(cx)) + .when(self.bold, |preview| preview.font_weight(FontWeight::BOLD)) + .child(sample), + ), + ) + .child( + Field::new().child( + Checkbox::new("highlight-notify") + .label("匹配时通知") + .checked(self.notify) + .small() + .on_click(cx.listener(|this, checked: &bool, _, cx| { + this.notify = *checked; + cx.notify(); + })) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child( + "看不到这个终端时提醒:ShellRS 在后台,或终端在别的标签。\ + 只检查换了行的新输出,全屏程序里不提醒;\ + 同一终端 10 秒内只提醒一次。", + ), + ), + ), + ) + } +} + +/// Open the dialog for a new rule, or for the rule at `editing`. +pub fn open_highlight_dialog( + editing: Option, + store: Entity, + window: &mut Window, + cx: &mut App, +) { + let form = cx.new(|cx| HighlightForm::new(editing, store, window, cx)); + let (title, commit): (SharedString, SharedString) = if form.read(cx).editing.is_some() { + ("编辑高亮规则".into(), "保存".into()) + } else { + ("添加高亮规则".into(), "添加".into()) + }; + window.open_dialog(cx, { + let form = form.clone(); + move |dialog, window, _| { + dialog + .title(title.clone()) + // Dialog geometry is an API boundary that takes `Pixels`; the + // width follows the interface zoom through the rem. + .w(rems(DIALOG_WIDTH).to_pixels(window.rem_size())) + // Closed by its buttons or Escape, not by a click beside it. + .overlay_closable(false) + .child(form.clone()) + .footer(commit_footer("commit", commit.clone())) + .on_ok({ + let form = form.clone(); + move |_, window, cx| form.update(cx, |form, cx| form.commit(window, cx)) + }) + .on_close(|_, window, cx| dismiss_form_error(window, cx)) + } + }); + // Straight to typing: in the same update as the opening, or the + // dialog's own focus wins. + let pattern = form.read(cx).pattern.clone(); + pattern.update(cx, |pattern, cx| pattern.focus(window, cx)); +} diff --git a/src/settings/mod.rs b/src/settings/mod.rs index a896a86..825b5eb 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -1,14 +1,16 @@ //! 设置: the settings tab, the settings it edits and how they are applied. mod apply; +mod highlight_dialog; mod model; mod settings_panel; mod store; pub use apply::apply; +pub use highlight_dialog::open_highlight_dialog; pub use model::{ AppSettings, Appearance, Choice, ExternalCliSettings, InterfaceLanguage, NotificationSettings, - TerminalFontSettings, UpdateSettings, + TerminalFontSettings, TerminalHighlightSettings, 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 4766e8e..6872255 100644 --- a/src/settings/model.rs +++ b/src/settings/model.rs @@ -1,9 +1,12 @@ use gpui_kit::WindowAppearance; use gpui_kit::component::ThemeMode; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::explorer::{FileSizeFormat, ShowHiddenFiles}; -use crate::terminal::{DEFAULT_FONT_SIZE, DEFAULT_LINE_HEIGHT, FONT_SIZE_RANGE, LINE_HEIGHT_RANGE}; +use crate::terminal::{ + DEFAULT_FONT_SIZE, DEFAULT_LINE_HEIGHT, FONT_SIZE_RANGE, HighlightRule, LINE_HEIGHT_RANGE, + default_rules, +}; use crate::update::Channel; /// Everything the settings page changes. Each field falls back to its @@ -25,6 +28,39 @@ pub struct AppSettings { pub show_hidden: ShowHiddenFiles, /// 终端 → 通知. pub notifications: NotificationSettings, + /// 关键字高亮. + pub terminal_highlight: TerminalHighlightSettings, +} + +/// 关键字高亮: the rules every terminal colors its text by, in the order +/// they win where they overlap. A file without them gets the examples a new +/// installation starts with; one the user emptied stays empty. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TerminalHighlightSettings { + #[serde(deserialize_with = "readable_rules")] + pub rules: Vec, +} + +impl Default for TerminalHighlightSettings { + fn default() -> Self { + Self { + rules: default_rules(), + } + } +} + +/// The rules the file holds that this version can read. One it cannot (a +/// color from a newer version, a hand-made typo) is dropped on its own: +/// failing the whole file would put every setting back to its default. +fn readable_rules<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + let values = Vec::::deserialize(deserializer)?; + Ok(values + .into_iter() + .filter_map(|value| serde_json::from_value(value).ok()) + .collect()) } /// 终端 → 通知: what a terminal may tell the user about while they look @@ -97,6 +133,10 @@ impl AppSettings { /// file nor a typed value can give the terminal a size of zero. pub fn normalized(mut self) -> Self { self.terminal_font = self.terminal_font.normalized(); + // A rule with nothing to match matches nothing. + self.terminal_highlight + .rules + .retain(|rule| !rule.pattern.trim().is_empty()); self } } @@ -311,6 +351,61 @@ mod tests { assert!(settings.notifications.programs && !settings.notifications.bell); } + #[test] + fn highlight_rules_start_as_examples_and_stay_as_the_user_leaves_them() { + let settings: AppSettings = serde_json::from_str("{}").unwrap(); + assert_eq!(settings.terminal_highlight.rules, default_rules()); + let settings: AppSettings = + serde_json::from_str(r#"{"terminal_highlight":{"rules":[]}}"#).unwrap(); + assert!(settings.terminal_highlight.rules.is_empty()); + let json = serde_json::to_value(AppSettings::default()).unwrap(); + assert_eq!( + json["terminal_highlight"]["rules"][0], + serde_json::json!({ + "pattern": "ERROR", + "kind": "keyword", + "color": "red", + "bold": true, + "notify": false + }) + ); + assert_eq!(json["terminal_highlight"]["rules"][2]["kind"], "regex"); + } + + #[test] + fn a_rule_this_version_cannot_read_costs_only_itself() { + let settings: AppSettings = serde_json::from_str( + r#"{ + "appearance": "dark", + "terminal_highlight": {"rules": [ + {"pattern": "FATAL", "color": "orange"}, + {"pattern": "OOM", "color": "magenta", "notify": true}, + 7 + ]} + }"#, + ) + .unwrap(); + assert_eq!(settings.appearance, Appearance::Dark); + let rules = &settings.terminal_highlight.rules; + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].pattern, "OOM"); + assert!(rules[0].notify && !rules[0].bold); + } + + #[test] + fn a_rule_with_nothing_to_match_is_dropped() { + let mut settings = AppSettings::default(); + settings.terminal_highlight.rules[1].pattern = " ".into(); + let rules = settings.normalized().terminal_highlight.rules; + assert_eq!( + rules + .iter() + .map(|rule| rule.pattern.as_str()) + .collect::>(), + ["ERROR", r"\b\d{1,3}(\.\d{1,3}){3}\b"] + ); + } + #[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 debabd4..c51b279 100644 --- a/src/settings/settings_panel.rs +++ b/src/settings/settings_panel.rs @@ -11,19 +11,21 @@ use gpui_kit::component::{ tooltip::Tooltip, v_flex, }; +use std::sync::Arc; + use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::app::{ - CatalogIcon, CenterTab, CheckForUpdates, CloseSettings, CopyAgentSkill, DownloadUpdate, - InstallAgentSkill, InstallCliCommand, OpenDownloadPage, RefreshCliIntegration, - RemoveAgentSkill, RemoveCliCommand, ShowUpdate, + CatalogIcon, CenterTab, CheckForUpdates, CloseSettings, CopyAgentSkill, DeleteHighlightRule, + DownloadUpdate, EditHighlightRule, InstallAgentSkill, InstallCliCommand, NewHighlightRule, + OpenDownloadPage, RefreshCliIntegration, RemoveAgentSkill, RemoveCliCommand, ShowUpdate, }; use crate::cli::{AgentKind, BinaryStatus, CliIntegration, IntegrationStatus, SkillStatus}; use crate::shared::{ClosableTabTitle, close_tab_items}; use crate::terminal::{ - FONT_SIZE_RANGE, LINE_HEIGHT_RANGE, TerminalFontPreview, is_font_installed, - monospace_font_families, + FONT_SIZE_RANGE, HighlightSet, LINE_HEIGHT_RANGE, PatternKind, TerminalFontPreview, + TerminalHighlights, is_font_installed, monospace_font_families, }; use crate::update::{Phase, Tone, UpdateSnapshot, UpdateStep, Updater, build_info, platform}; @@ -200,7 +202,7 @@ struct Category { type CategoryGroups = fn(&SettingsPanel, &App) -> Vec; /// The categories, in the order the left column lists them. -const CATEGORIES: [Category; 4] = [ +const CATEGORIES: [Category; 5] = [ Category { title: "外观", icon: CatalogIcon::Palette, @@ -211,6 +213,11 @@ const CATEGORIES: [Category; 4] = [ icon: CatalogIcon::Terminal, groups: terminal_groups, }, + Category { + title: "关键字高亮", + icon: CatalogIcon::Highlighter, + groups: highlight_groups, + }, Category { title: "外部 CLI", icon: CatalogIcon::SquareTerminal, @@ -357,6 +364,175 @@ fn notification_group(store: &Entity) -> SettingGroup { ]) } +/// 关键字高亮: the rules every terminal colors its text by. Taken from the +/// rules in effect, which also know which of them do not compile. +fn highlight_groups(panel: &SettingsPanel, cx: &App) -> Vec { + vec![ + SettingGroup::new() + .title("规则") + .description( + "匹配的文字在所有终端里按所选样式显示:只改显示,不改服务器的输出,\ + 复制出的文字也不变。几条规则匹配到同一处时,靠前的优先。\ + 全屏程序(vim、less、tmux 等)里不高亮。", + ) + .items([highlight_rules_item( + TerminalHighlights::current(cx), + panel.focus_handle.clone(), + )]), + ] +} + +/// The rules, one row each with the pattern in its own style, and the +/// button that adds one. The buttons dispatch through the panel's focus +/// handle: after a dialog closes nothing has focus, and an action +/// dispatched from the window would never reach the workspace. +fn highlight_rules_item(rules: Arc, dispatch: FocusHandle) -> SettingItem { + SettingItem::render(move |options, _, cx| { + let size = options.size(); + let muted = cx.theme().muted_foreground; + let header = h_flex() + .w_full() + .gap_3() + .pb_1() + .text_xs() + .text_color(muted) + .child(div().flex_1().min_w_0().child("内容")) + .child(div().w_16().flex_shrink_0().child("匹配方式")) + .child(div().w_8().flex_shrink_0().child("通知")) + // Over the row buttons' lane. + .child(div().w_16().flex_shrink_0()); + let rows = rules.rules().iter().enumerate().map(|(ix, rule)| { + let error = rules.error(ix); + let dispatch_edit = dispatch.clone(); + let dispatch_buttons = (dispatch.clone(), dispatch.clone()); + h_flex() + .id(("highlight-rule", ix)) + .test_support() + .w_full() + .gap_3() + .py_1() + .border_t_1() + .border_color(cx.theme().border) + .on_click(move |event, window, cx| { + if event.click_count() == 2 { + dispatch_edit.dispatch_action(&EditHighlightRule(ix), window, cx); + } + }) + .child( + h_flex() + .flex_1() + .min_w_0() + .gap_2() + .child( + // The pattern as the terminal shows its matches. + div() + .min_w_0() + .truncate() + .text_sm() + .font_family(cx.theme().mono_font_family.clone()) + .text_color(rule.color.hsla(cx)) + .when(rule.bold, |text| text.font_weight(FontWeight::BOLD)) + .child(rule.pattern.clone()), + ) + .when_some(error, |row, error| { + row.child( + div() + .flex_shrink_0() + .text_xs() + .text_color(cx.theme().danger) + .child(format!("表达式无效:{error}")), + ) + }), + ) + .child( + div() + .w_16() + .flex_shrink_0() + .text_sm() + .text_color(muted) + .child(match rule.kind { + PatternKind::Keyword => "关键字", + PatternKind::Regex => "正则", + }), + ) + .child( + div() + .w_8() + .flex_shrink_0() + .text_sm() + .text_color(muted) + .child(if rule.notify { "是" } else { "—" }), + ) + .child( + h_flex() + .w_16() + .flex_shrink_0() + .justify_end() + .gap_1() + .child( + Button::new(("edit-highlight-rule", ix)) + .ghost() + .xsmall() + .icon(Icon::new(CatalogIcon::Pencil)) + .tooltip("编辑…") + .on_click(move |_, window, cx| { + dispatch_buttons.0.dispatch_action( + &EditHighlightRule(ix), + window, + cx, + ) + }), + ) + .child( + Button::new(("delete-highlight-rule", ix)) + .ghost() + .xsmall() + .icon(Icon::new(CatalogIcon::Trash)) + .tooltip("删除…") + .on_click(move |_, window, cx| { + dispatch_buttons.1.dispatch_action( + &DeleteHighlightRule(ix), + window, + cx, + ) + }), + ), + ) + }); + let add = dispatch.clone(); + v_flex() + .id("highlight-rules") + .test_support() + .w_full() + .gap_3() + .child(if rules.rules().is_empty() { + div() + .text_sm() + .text_color(muted) + .child("还没有规则。") + .into_any_element() + } else { + v_flex() + .w_full() + .child(header) + .children(rows) + .into_any_element() + }) + .child( + h_flex().child( + Button::new("add-highlight-rule") + .with_size(size) + .icon(Icon::new(IconName::Plus)) + .label("添加规则…") + .on_click(move |_, window, cx| { + add.dispatch_action(&NewHighlightRule, window, cx) + }), + ), + ) + }) + .keywords(["高亮", "关键字", "正则", "规则", "颜色", "通知"]) +} + /// 外部 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/shared/segmented_control.rs b/src/shared/segmented_control.rs index 817a45d..9e31fa8 100644 --- a/src/shared/segmented_control.rs +++ b/src/shared/segmented_control.rs @@ -32,6 +32,9 @@ pub struct Segment { label: SharedString, /// How many there are of what it shows, quieter beside the label. count: Option, + /// A color it stands for, as a dot before the label: 关键字高亮's + /// colors, named as well as shown. + swatch: Option, disabled: bool, /// Why a disabled segment cannot be chosen. tooltip: Option, @@ -42,6 +45,7 @@ impl Segment { Self { label: label.into(), count: None, + swatch: None, disabled: false, tooltip: None, } @@ -53,6 +57,12 @@ impl Segment { self } + /// The color it stands for: 「● 红」. + pub fn swatch(mut self, color: Hsla) -> Self { + self.swatch = Some(color); + self + } + pub fn disabled(mut self, disabled: bool) -> Self { self.disabled = disabled; self @@ -193,6 +203,16 @@ impl RenderOnce for SegmentedControl { Tooltip::new(tooltip.clone()).build(window, cx) }) }) + .when_some(segment.swatch, |radio, color| { + radio.child( + div() + .flex_shrink_0() + .size_2() + .mr_1p5() + .rounded_full() + .bg(color), + ) + }) .child(div().truncate().child(segment.label)) .when_some(segment.count, |radio, count| { radio.child( diff --git a/src/terminal/engine.rs b/src/terminal/engine.rs index 8dfb03a..4bff7f2 100644 --- a/src/terminal/engine.rs +++ b/src/terminal/engine.rs @@ -2,7 +2,7 @@ use std::cell::RefCell; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, mpsc}; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant}; use alacritty_terminal::Term; use alacritty_terminal::event::{Event, EventListener, WindowSize}; @@ -19,6 +19,10 @@ use alacritty_terminal::vte::ansi::{Color, CursorShape, CursorStyle, Processor, use crate::connection::{ConnectionPrompt, ConnectionPromptReply, Latency}; use crate::host::HostOs; +use super::highlight::{ + HighlightStyle, KeywordHit, LineWatcher, SharedHighlights, TerminalHighlights, + advance_watching, current, +}; use super::links::{LinkMarker, url_search, visible_links}; use super::notices::{NoticeScanner, ProgramNotice, TerminalNotice}; use super::search::{MatchMarker, SearchDirection, SearchMark, SearchPosition, TerminalSearch}; @@ -39,6 +43,8 @@ pub struct TerminalCell { pub search: SearchMark, /// The link the cell belongs to, an index into the snapshot's `links`. pub link: Option, + /// How a 关键字高亮 rule shows the cell, when one matches it. + pub highlight: Option, } #[derive(Clone, Debug)] @@ -85,6 +91,10 @@ pub struct TerminalEngine { search: RefCell>, /// Finds the web addresses on screen, built once. url_search: RefCell, + /// The 关键字高亮 rules, shared with the parser thread, which checks new + /// lines against them; swapped when the settings change them. + highlights: SharedHighlights, + _highlights_observer: gpui_kit::Subscription, generation: u64, size: TerminalSize, event_sender: mpsc::Sender, @@ -99,8 +109,21 @@ impl TerminalEngine { // threads therefore never wake GPUI's local executor directly, and a // burst of terminal bytes results in a single render notification. let (event_sender, event_receiver) = mpsc::channel(); - let runtime = - TerminalRuntime::start(generation, size, factory.create(), event_sender.clone()); + let highlights = Arc::new(Mutex::new(TerminalHighlights::current(cx))); + let runtime = TerminalRuntime::start( + generation, + size, + factory.create(), + event_sender.clone(), + highlights.clone(), + ); + let highlights_observer = cx.observe_global::(|this, cx| { + *this + .highlights + .lock() + .unwrap_or_else(|error| error.into_inner()) = TerminalHighlights::current(cx); + cx.notify(); + }); let mut this = Self { factory, runtime, @@ -109,6 +132,8 @@ impl TerminalEngine { latency: None, search: RefCell::new(None), url_search: RefCell::new(url_search()), + highlights, + _highlights_observer: highlights_observer, generation, size, event_sender, @@ -194,6 +219,12 @@ impl TerminalEngine { let line = cursor_line_text(&self.runtime.term.lock()); return Some(TerminalEvent::Notice(TerminalNotice::bell(&line))); } + TerminalUiEventKind::Keyword(hit) => { + return Some(TerminalEvent::Notice(TerminalNotice::keyword( + &hit.pattern, + &hit.line, + ))); + } } None } @@ -211,6 +242,7 @@ impl TerminalEngine { self.size, self.factory.create(), self.event_sender.clone(), + self.highlights.clone(), ); cx.notify(); } @@ -461,6 +493,12 @@ impl TerminalEngine { ) } + /// Whether the cursor blinks: the program may say. Cheaper than a + /// snapshot, which the blink timer would otherwise take twice a second. + pub fn cursor_blinking(&self) -> bool { + self.runtime.term.lock().cursor_style().blinking + } + pub fn snapshot(&self) -> TerminalSnapshot { let term = self.runtime.term.lock(); let content = term.renderable_content(); @@ -478,6 +516,7 @@ impl TerminalEngine { .unwrap_or_default(); let mut marker = MatchMarker::new(&matches, focused.as_ref()); let mut links = LinkMarker::new(visible_links(&term, &mut self.url_search.borrow_mut())); + let highlights = current(&self.highlights).screen(&term); let mut cells: Vec<_> = content .display_iter .map(|indexed| { @@ -495,6 +534,10 @@ impl TerminalEngine { }), search: marker.mark(indexed.point), link: links.mark(indexed.point, indexed.cell.hyperlink()), + highlight: highlights.style( + (indexed.point.line.0 + display_offset) as usize, + indexed.point.column.0, + ), } }) .collect(); @@ -590,6 +633,7 @@ impl TerminalRuntime { size: TerminalSize, transport: Box, ui_events: mpsc::Sender, + highlights: SharedHighlights, ) -> Self { let (commands, command_receiver) = mpsc::channel(); let (transport_events, transport_receiver) = async_channel::unbounded(); @@ -613,13 +657,25 @@ impl TerminalRuntime { let mut processor = Processor::new(); // Alacritty drops the notifications programs ask for. let mut notices = NoticeScanner::default(); + let mut watcher = LineWatcher::new(current(&highlights)); while let Ok(event) = transport_receiver.recv_blocking() { match event { TerminalTransportEvent::Started => { parser_proxy.send(TerminalUiEventKind::Started); } TerminalTransportEvent::Output(bytes) => { - processor.advance(&mut *parser_term.lock(), &bytes); + watcher.sync(current(&highlights)); + // The lines are only read under the lock; the + // rules match them once it is let go. + let lines = advance_watching( + &mut processor, + &mut parser_term.lock(), + &bytes, + watcher.is_watching(), + ); + if let Some(hit) = watcher.check(lines, Instant::now()) { + parser_proxy.send(TerminalUiEventKind::Keyword(hit)); + } for notice in notices.scan(&bytes) { parser_proxy.send(TerminalUiEventKind::Notice(notice)); } @@ -824,6 +880,8 @@ enum TerminalUiEventKind { /// A notification a program asked for, by OSC 9 or 777. Notice(ProgramNotice), Bell, + /// A finished line that a 关键字高亮 rule tells of. + Keyword(KeywordHit), } fn append_message(term: &Arc>, message: &str) { @@ -1388,6 +1446,7 @@ mod tests { SearchMark::None }, link: None, + highlight: None, }; let mut cells = vec![ cell(Flags::WIDE_CHAR, true), diff --git a/src/terminal/highlight.rs b/src/terminal/highlight.rs new file mode 100644 index 0000000..8d9e7ba --- /dev/null +++ b/src/terminal/highlight.rs @@ -0,0 +1,853 @@ +//! 关键字高亮: rules that color what matches them in every terminal, and may +//! tell the user when new output matches while they look elsewhere. +//! +//! Only what is shown changes. A rule matches the text of the grid, what the +//! emulator made of the output with escape sequences and carriage returns +//! already applied, never the bytes: it cannot break a control sequence, and +//! the server's output and copied text stay as they were. +//! +//! The rows in view are matched again every frame, as find and links are, so +//! nothing has to follow scrolling, a resize's reflow or new output. The +//! rows a line wraps over are joined into one logical line first, so a word +//! wrapped onto the next row is still one match, before and after a resize. +//! +//! The `regex` crate matches, not Alacritty's grid search that find and +//! links use: its DFA cannot build `\b`, finds `^` only on the first line it +//! looks at, and sees the blanks that pad a row before `$`. + +use std::collections::VecDeque; +use std::ops::Range; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use alacritty_terminal::event::EventListener; +use alacritty_terminal::grid::Dimensions as _; +use alacritty_terminal::index::{Column, Line, Point}; +use alacritty_terminal::term::cell::Flags; +use alacritty_terminal::term::{Term, TermMode}; +use alacritty_terminal::vte::ansi::{Processor, Timeout as _}; +use gpui_kit::component::ActiveTheme as _; +use gpui_kit::{App, Global, Hsla}; +use regex::{Regex, RegexBuilder}; +use serde::{Deserialize, Serialize}; + +use super::search::MAX_WRAPPED_LINES; + +/// The longest pattern a rule may have, in characters. +pub const PATTERN_LIMIT: usize = 500; + +/// How long a line already told of stays quiet when it shows up again, the +/// way a program that redraws its lines (apt, docker pull) shows it. +const RETELL_AFTER: Duration = Duration::from_secs(300); + +/// How many lines told of are remembered. +const TOLD_LIMIT: usize = 64; + +/// One rule: what to match, how to show it, and whether to tell of it. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct HighlightRule { + pub pattern: String, + pub kind: PatternKind, + pub color: HighlightColor, + pub bold: bool, + /// Tell the user when a finished line of new output matches, while they + /// cannot see the terminal. + pub notify: bool, +} + +/// How a rule's pattern reads. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PatternKind { + /// Literally, in upper and lower case alike unless it has a capital, as + /// find has it. + #[default] + Keyword, + /// As a regular expression, case and all; `(?i)` ignores case. + Regex, +} + +/// The colors a rule may show its matches in: the theme's, so they read on +/// a light background and a dark one alike, as the links' blue does. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HighlightColor { + #[default] + Red, + Yellow, + Green, + Cyan, + Blue, + Magenta, +} + +impl HighlightColor { + /// Every color, in the order the dialog offers them. + pub const ALL: [Self; 6] = [ + Self::Red, + Self::Yellow, + Self::Green, + Self::Cyan, + Self::Blue, + Self::Magenta, + ]; + + pub fn label(self) -> &'static str { + match self { + Self::Red => "红", + Self::Yellow => "黄", + Self::Green => "绿", + Self::Cyan => "青", + Self::Blue => "蓝", + Self::Magenta => "紫", + } + } + + pub fn hsla(self, cx: &App) -> Hsla { + let theme = cx.theme(); + match self { + Self::Red => theme.red, + Self::Yellow => theme.yellow, + Self::Green => theme.green, + Self::Cyan => theme.cyan, + Self::Blue => theme.blue, + Self::Magenta => theme.magenta, + } + } +} + +/// How a match shows. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HighlightStyle { + pub color: HighlightColor, + pub bold: bool, +} + +/// The rules a new installation starts with, none of them notifying. +pub fn default_rules() -> Vec { + vec![ + HighlightRule { + pattern: "ERROR".into(), + kind: PatternKind::Keyword, + color: HighlightColor::Red, + bold: true, + notify: false, + }, + HighlightRule { + pattern: "WARN".into(), + kind: PatternKind::Keyword, + color: HighlightColor::Yellow, + bold: false, + notify: false, + }, + HighlightRule { + pattern: r"\b\d{1,3}(\.\d{1,3}){3}\b".into(), + kind: PatternKind::Regex, + color: HighlightColor::Blue, + bold: false, + notify: false, + }, + ] +} + +impl HighlightRule { + pub fn style(&self) -> HighlightStyle { + HighlightStyle { + color: self.color, + bold: self.bold, + } + } + + /// The pattern, ready to match; or why it cannot be, as the dialog says + /// it. + pub fn compile(&self) -> Result { + if self.pattern.trim().is_empty() { + return Err("请输入要匹配的内容"); + } + if self.pattern.chars().count() > PATTERN_LIMIT { + return Err("内容不能超过 500 个字符"); + } + let built = match self.kind { + PatternKind::Keyword => RegexBuilder::new(®ex::escape(&self.pattern)) + .case_insensitive(!self.pattern.chars().any(char::is_uppercase)) + .build(), + PatternKind::Regex => Regex::new(&self.pattern), + }; + built.map_err(|error| match error { + regex::Error::CompiledTooBig(_) => "正则表达式过于复杂", + _ => "正则表达式写法有误", + }) + } +} + +/// Whether `regex` finds some text in `text`. A match of nothing, as `\b` +/// or `x*` make, colors nothing and tells of nothing. +fn finds_text(regex: &Regex, text: &str) -> bool { + regex.find_iter(text).any(|found| !found.is_empty()) +} + +/// The rules, compiled once for every terminal. A rule that does not compile +/// (only a hand-edited settings file has one) is left out of matching. +#[derive(Debug, Default)] +pub struct HighlightSet { + rules: Vec, + compiled: Vec>, +} + +// Shared by the UI thread and every terminal's parser thread. +const _: fn() = || { + fn shared() {} + shared::(); +}; + +impl HighlightSet { + pub fn new(rules: Vec) -> Self { + let compiled = rules.iter().map(HighlightRule::compile).collect(); + Self { rules, compiled } + } + + /// The rules this set was compiled from, in their order. + pub fn rules(&self) -> &[HighlightRule] { + &self.rules + } + + /// Why the rule at `ix` matches nothing, when it does not compile. + pub fn error(&self, ix: usize) -> Option<&'static str> { + self.compiled.get(ix)?.as_ref().err().copied() + } + + /// Whether any rule tells of the lines it matches. + pub fn notifies(&self) -> bool { + self.notifying().next().is_some() + } + + /// The rules that compiled, by their index. + fn active(&self) -> impl DoubleEndedIterator { + self.rules + .iter() + .zip(&self.compiled) + .enumerate() + .filter_map(|(ix, (rule, compiled))| Some((ix, rule, compiled.as_ref().ok()?))) + } + + fn notifying(&self) -> impl Iterator { + self.active().filter(|(_, rule, _)| rule.notify) + } + + /// How each cell in view shows. Nothing while a full-screen program has + /// the alternate screen: it redraws itself, and an editor's `error` + /// identifier is not news. + pub fn screen(&self, term: &Term) -> ScreenHighlights { + let columns = term.columns(); + let rows = term.screen_lines(); + let mut screen = ScreenHighlights { + columns, + styles: Vec::new(), + }; + if self.active().next().is_none() || term.mode().contains(TermMode::ALT_SCREEN) { + return screen; + } + screen.styles = vec![None; columns * rows]; + let top = Line(-(term.grid().display_offset() as i32)); + let bottom = Line(top.0 + rows as i32 - 1); + // From where the line the top row is part of starts, so a match that + // starts above the view shows its part in it. + let mut line = line_start(term, top); + while line <= bottom { + let end = line_end(term, line); + let text = LineText::read(term, line, end); + // The last rule first, so an earlier one paints over it where + // they overlap. + for (_, rule, regex) in self.active().rev() { + for found in regex.find_iter(&text.text) { + screen.mark(&text, found.range(), rule.style(), top); + } + } + line = Line(end.0 + 1); + } + screen + } +} + +/// The application's rules, which `settings::apply` sets. The terminal +/// module reads them here rather than depending on settings. +#[derive(Clone, Default)] +pub struct TerminalHighlights(Arc); + +impl Global for TerminalHighlights {} + +impl TerminalHighlights { + pub fn new(rules: Vec) -> Self { + Self(Arc::new(HighlightSet::new(rules))) + } + + /// The rules in effect; none before settings are applied. + pub fn current(cx: &App) -> Arc { + cx.try_global::() + .map(|highlights| highlights.0.clone()) + .unwrap_or_default() + } + + pub fn set(&self) -> &Arc { + &self.0 + } +} + +/// The rules a terminal's parser thread checks new lines against, swapped +/// by the engine when they change. +pub(super) type SharedHighlights = Arc>>; + +pub(super) fn current(shared: &SharedHighlights) -> Arc { + shared + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() +} + +/// How each cell in view shows, by its row on screen and column. +pub struct ScreenHighlights { + columns: usize, + /// Row by row; empty when nothing is highlighted. + styles: Vec>, +} + +impl ScreenHighlights { + pub fn style(&self, row: usize, column: usize) -> Option { + self.styles + .get(row * self.columns + column) + .copied() + .flatten() + } + + /// Show the characters of `text` in the byte range `found` with + /// `style`, those in view: the view's top row is `top`. + fn mark(&mut self, text: &LineText, found: Range, style: HighlightStyle, top: Line) { + let first = text.cells.partition_point(|cell| cell.offset < found.start); + let last = text.cells.partition_point(|cell| cell.offset < found.end); + for cell in &text.cells[first..last] { + let width = if cell.wide { 2 } else { 1 }; + for column in cell.point.column.0..cell.point.column.0 + width { + let row = cell.point.line.0 - top.0; + if row < 0 || column >= self.columns { + continue; + } + if let Some(slot) = self.styles.get_mut(row as usize * self.columns + column) { + *slot = Some(style); + } + } + } + } +} + +/// A character of a logical line: where it starts in the text, the cell it +/// is in, and whether it takes that cell's neighbour too. +struct LineCell { + offset: usize, + point: Point, + wide: bool, +} + +/// One logical line of the grid, the rows it wraps over joined, as text. +struct LineText { + text: String, + /// Each character's cell, in order. + cells: Vec, +} + +impl LineText { + /// The text of rows `first..=last`, without the blanks that pad the + /// last one. A wide character's spacer is no character; a concealed one + /// reads as a blank, so it neither matches nor shows in a notification. + fn read(term: &Term, first: Line, last: Line) -> Self { + let mut text = String::new(); + let mut cells = Vec::new(); + for line in first.0..=last.0 { + let row = &term.grid()[Line(line)]; + for column in 0..row.len() { + let cell = &row[Column(column)]; + if cell + .flags + .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) + { + continue; + } + cells.push(LineCell { + offset: text.len(), + point: Point::new(Line(line), Column(column)), + wide: cell.flags.contains(Flags::WIDE_CHAR), + }); + if cell.flags.contains(Flags::HIDDEN) { + text.push(' '); + continue; + } + text.push(cell.c); + if let Some(zerowidth) = cell.zerowidth() { + text.extend(zerowidth); + } + } + } + let end = text.trim_end_matches(' ').len(); + text.truncate(end); + while cells.last().is_some_and(|cell| cell.offset >= end) { + cells.pop(); + } + Self { text, cells } + } +} + +/// Whether the row `line` goes on in the next one. +fn wraps(term: &Term, line: Line) -> bool { + let row = &term.grid()[line]; + row.len() > 0 && row[Column(row.len() - 1)].flags.contains(Flags::WRAPLINE) +} + +/// The first row of the logical line that `line` is part of, at most +/// `MAX_WRAPPED_LINES` above it. +fn line_start(term: &Term, line: Line) -> Line { + let top = term.topmost_line(); + let mut start = line; + while start > top && line.0 - start.0 < MAX_WRAPPED_LINES && wraps(term, Line(start.0 - 1)) { + start = Line(start.0 - 1); + } + start +} + +/// The last row of the logical line that `line` is part of, at most +/// `MAX_WRAPPED_LINES` below it. +fn line_end(term: &Term, line: Line) -> Line { + let bottom = term.bottommost_line(); + let mut end = line; + while end < bottom && end.0 - line.0 < MAX_WRAPPED_LINES && wraps(term, end) { + end = Line(end.0 + 1); + } + end +} + +/// The text of the logical line the cursor is on. +fn cursor_line(term: &Term) -> String { + let line = term.grid().cursor.point.line; + LineText::read(term, line_start(term, line), line_end(term, line)).text +} + +/// Feed `bytes` to the emulator. With `watching`, also take the text of +/// every line a line feed finishes, just before it does: the cursor is then +/// still on that line, whose text is final, carriage returns and colors +/// applied. Not on the alternate screen, whose programs redraw rather than +/// print, nor while a synchronized update holds its output back, as the +/// grid then still shows what came before. +pub(super) fn advance_watching( + processor: &mut Processor, + term: &mut Term, + bytes: &[u8], + watching: bool, +) -> Vec { + if !watching { + processor.advance(term, bytes); + return Vec::new(); + } + let mut finished = Vec::new(); + let mut rest = bytes; + while let Some(newline) = rest.iter().position(|byte| *byte == b'\n') { + processor.advance(term, &rest[..newline]); + if !term.mode().contains(TermMode::ALT_SCREEN) + && !processor.sync_timeout().pending_timeout() + { + finished.push(cursor_line(term)); + } + // The line feed starts the next part. + rest = &rest[newline..]; + processor.advance(term, &rest[..1]); + rest = &rest[1..]; + } + processor.advance(term, rest); + finished +} + +/// A finished line of new output that a notifying rule matched. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct KeywordHit { + pub pattern: String, + pub line: String, +} + +/// Checks the lines a terminal finishes against the notifying rules, on its +/// parser thread. +pub(super) struct LineWatcher { + set: Arc, + /// The lines told of lately, by rule, and when. + told: VecDeque<(usize, String, Instant)>, +} + +impl LineWatcher { + pub fn new(set: Arc) -> Self { + Self { + set, + told: VecDeque::new(), + } + } + + /// Follow the rules as they change. What was told under the old ones is + /// forgotten. + pub fn sync(&mut self, set: Arc) { + if !Arc::ptr_eq(&self.set, &set) { + self.set = set; + self.told.clear(); + } + } + + pub fn is_watching(&self) -> bool { + self.set.notifies() + } + + /// The first of `lines` a notifying rule matches, unless that rule told + /// of the same line in the last five minutes. One at most: a burst of + /// output is one notice. + pub fn check(&mut self, lines: Vec, now: Instant) -> Option { + self.told + .retain(|(_, _, at)| now.saturating_duration_since(*at) < RETELL_AFTER); + for line in lines { + for (ix, rule, regex) in self.set.notifying() { + if !finds_text(regex, &line) + || self + .told + .iter() + .any(|(told, text, _)| *told == ix && *text == line) + { + continue; + } + if self.told.len() == TOLD_LIMIT { + self.told.pop_front(); + } + self.told.push_back((ix, line.clone(), now)); + return Some(KeywordHit { + pattern: rule.pattern.clone(), + line, + }); + } + } + None + } +} + +#[cfg(test)] +mod tests { + use alacritty_terminal::event::VoidListener; + use alacritty_terminal::grid::Scroll; + use alacritty_terminal::term::Config; + + use super::*; + use crate::terminal::TerminalSize; + + fn keyword(pattern: &str, color: HighlightColor) -> HighlightRule { + HighlightRule { + pattern: pattern.into(), + color, + ..HighlightRule::default() + } + } + + fn regex(pattern: &str, color: HighlightColor) -> HighlightRule { + HighlightRule { + kind: PatternKind::Regex, + ..keyword(pattern, color) + } + } + + fn notifying(rule: HighlightRule) -> HighlightRule { + HighlightRule { + notify: true, + ..rule + } + } + + /// A terminal fed by one parser, as a terminal's parser thread feeds it. + struct Screen { + term: Term, + processor: Processor, + } + + impl Screen { + fn new(columns: usize, rows: usize) -> Self { + Self { + term: Term::new( + Config::default(), + &TerminalSize::new(columns, rows, 8, 16), + VoidListener, + ), + processor: Processor::new(), + } + } + + fn feed(&mut self, text: &str) { + self.processor.advance(&mut self.term, text.as_bytes()); + } + + /// The lines finished while feeding `text`, as the parser thread + /// collects them. + fn feed_watching(&mut self, text: &str) -> Vec { + advance_watching(&mut self.processor, &mut self.term, text.as_bytes(), true) + } + + fn resize(&mut self, columns: usize, rows: usize) { + self.term.resize(TerminalSize::new(columns, rows, 8, 16)); + } + + /// The highlighted runs on screen in reading order, with their + /// color. A run goes on from the end of one row to the start of the + /// next, as a wrapped match does. + fn marks(&self, rules: Vec) -> Vec<(String, HighlightColor)> { + let screen = HighlightSet::new(rules).screen(&self.term); + let offset = self.term.grid().display_offset() as i32; + let mut runs: Vec<(String, HighlightColor)> = Vec::new(); + let mut open = false; + for row in 0..self.term.screen_lines() { + let line = &self.term.grid()[Line(row as i32 - offset)]; + for column in 0..self.term.columns() { + let cell = &line[Column(column)]; + match screen.style(row, column) { + Some(style) => { + if !open || runs.last().is_some_and(|(_, color)| *color != style.color) + { + runs.push((String::new(), style.color)); + } + open = true; + if !cell.flags.contains(Flags::WIDE_CHAR_SPACER) { + runs.last_mut().unwrap().0.push(cell.c); + } + } + None => open = false, + } + } + } + runs + } + } + + fn runs(marks: &[(&str, HighlightColor)]) -> Vec<(String, HighlightColor)> { + marks + .iter() + .map(|(text, color)| (text.to_string(), *color)) + .collect() + } + + use HighlightColor::{Blue, Red, Yellow}; + + #[test] + fn keywords_are_literal_and_smart_case_regexes_are_as_written() { + let compile = |rule: HighlightRule| rule.compile().unwrap(); + let dotted = compile(keyword("a.b", Red)); + assert!(dotted.is_match("x a.b y") && !dotted.is_match("axb")); + assert!(compile(regex("a.b", Red)).is_match("axb")); + // A keyword in lower case finds any case; with a capital, its own. + assert!(compile(keyword("error", Red)).is_match("ERROR: x")); + assert!(!compile(keyword("ERROR", Red)).is_match("error: x")); + assert!(!compile(regex("error", Red)).is_match("ERROR")); + assert!(compile(regex("(?i)error", Red)).is_match("ERROR")); + let address = compile(default_rules().remove(2)); + assert!(address.is_match("from 10.0.0.12 port 22")); + assert!(!address.is_match("1234.5.6.7")); + } + + #[test] + fn a_rule_that_cannot_match_says_why() { + assert_eq!( + keyword(" ", Red).compile().err(), + Some("请输入要匹配的内容") + ); + assert_eq!( + regex("(ERROR", Red).compile().err(), + Some("正则表达式写法有误") + ); + assert_eq!( + regex(r"(\w{1000}){1000}", Red).compile().err(), + Some("正则表达式过于复杂") + ); + let long = "x".repeat(PATTERN_LIMIT + 1); + assert_eq!( + keyword(&long, Red).compile().err(), + Some("内容不能超过 500 个字符") + ); + let set = HighlightSet::new(vec![regex("(", Red), keyword("ok", Red)]); + assert_eq!(set.error(0), Some("正则表达式写法有误")); + assert_eq!(set.error(1), None); + } + + #[test] + fn matches_are_marked_where_they_show() { + let mut screen = Screen::new(30, 4); + screen.feed("ok ERROR x WARN\r\n"); + assert_eq!( + screen.marks(vec![keyword("ERROR", Red), keyword("WARN", Yellow)]), + runs(&[("ERROR", Red), ("WARN", Yellow)]) + ); + // A word split between two colors is one word on screen. + screen.feed("E\x1b[31mRR\x1b[0mOR\r\n"); + assert_eq!( + screen.marks(vec![keyword("ERROR", Red)]), + runs(&[("ERROR", Red), ("ERROR", Red)]) + ); + } + + #[test] + fn line_anchors_and_word_boundaries_hold_on_every_line() { + let mut screen = Screen::new(30, 5); + screen.feed("a ERROR\r\nERROR b\r\n2024-10 ERROR\r\n"); + assert_eq!( + screen.marks(vec![regex("^ERROR", Red)]), + runs(&[("ERROR", Red)]) + ); + // The blanks padding a row are not text before `$`. + assert_eq!( + screen.marks(vec![regex("ERROR$", Red)]), + runs(&[("ERROR", Red), ("ERROR", Red)]) + ); + assert_eq!( + screen.marks(vec![regex(r"^\d+", Blue)]), + runs(&[("2024", Blue)]) + ); + assert_eq!( + screen.marks(vec![regex(r"\bRR", Red)]), + Vec::<(String, HighlightColor)>::new() + ); + } + + #[test] + fn a_wrapped_match_is_marked_on_both_rows_before_and_after_a_resize() { + // `1234567ERR` and `OR tail`. + let mut screen = Screen::new(10, 6); + screen.feed("1234567ERROR tail\r\n"); + let rules = || vec![keyword("ERROR", Red)]; + assert_eq!(screen.marks(rules()), runs(&[("ERROR", Red)])); + // Narrowed, Alacritty reflows them to `1234567E`, pushed into the + // scrollback, and `RROR tai` on the screen's top row. + screen.resize(8, 6); + assert_eq!(screen.marks(rules()), runs(&[("RROR", Red)])); + screen.term.scroll_display(Scroll::Delta(1)); + assert_eq!(screen.marks(rules()), runs(&[("ERROR", Red)])); + // Widened, one row again. + screen.term.scroll_display(Scroll::Bottom); + screen.resize(40, 6); + assert_eq!(screen.marks(rules()), runs(&[("ERROR", Red)])); + } + + #[test] + fn a_match_starting_above_the_view_shows_its_part_in_it() { + // `xxxxE` and `RROR` in the scrollback, `next` and `last` on screen. + let mut screen = Screen::new(5, 2); + screen.feed("xxxxERROR\r\nnext\r\nlast"); + assert!(screen.marks(vec![keyword("ERROR", Red)]).is_empty()); + // Scrolled back a row, the view starts at `RROR`. + screen.term.scroll_display(Scroll::Delta(1)); + assert_eq!( + screen.marks(vec![keyword("ERROR", Red)]), + runs(&[("RROR", Red)]) + ); + } + + #[test] + fn wide_characters_take_both_cells() { + let mut screen = Screen::new(20, 2); + screen.feed("连接错误 x"); + assert_eq!( + screen.marks(vec![keyword("错误", Red)]), + runs(&[("错误", Red)]) + ); + } + + #[test] + fn the_first_rule_wins_where_rules_overlap() { + let mut screen = Screen::new(30, 2); + screen.feed("OutOfMemoryError"); + assert_eq!( + screen.marks(vec![keyword("Memory", Blue), regex(r"\w+Error", Red)]), + runs(&[("OutOf", Red), ("Memory", Blue), ("Error", Red)]) + ); + } + + #[test] + fn nothing_is_marked_on_the_alternate_screen() { + let mut screen = Screen::new(20, 3); + screen.feed("\x1b[?1049hERROR"); + assert!(screen.marks(vec![keyword("ERROR", Red)]).is_empty()); + screen.feed("\x1b[?1049l"); + screen.feed("ERROR"); + assert_eq!( + screen.marks(vec![keyword("ERROR", Red)]), + runs(&[("ERROR", Red)]) + ); + } + + #[test] + fn a_line_is_taken_when_its_line_feed_comes() { + let mut screen = Screen::new(40, 4); + assert!(screen.feed_watching("prompt$ ERR").is_empty()); + assert_eq!(screen.feed_watching("OR boom\r\n"), ["prompt$ ERROR boom"]); + // Overwritten by a carriage return, the first text is gone. + assert_eq!(screen.feed_watching("ERROR\rok \r\n"), ["ok"]); + assert_eq!(screen.feed_watching("a\nb\n"), ["a", " b"]); + } + + #[test] + fn a_wrapped_line_is_taken_whole() { + let mut screen = Screen::new(10, 4); + assert_eq!( + screen.feed_watching("0123456789ERROR\r\n"), + ["0123456789ERROR"] + ); + } + + #[test] + fn full_screen_programs_and_synchronized_updates_are_not_watched() { + let mut screen = Screen::new(20, 4); + assert!(screen.feed_watching("\x1b[?1049hERROR\r\n").is_empty()); + assert_eq!( + screen.feed_watching("\x1b[?1049lback\r\n").len(), + 1, + "the main screen is watched again" + ); + assert!(screen.feed_watching("\x1b[?2026hERROR\r\n").is_empty()); + } + + #[test] + fn a_notifying_rule_tells_of_a_line_once_in_a_while() { + let set = Arc::new(HighlightSet::new(vec![ + keyword("WARN", Yellow), + notifying(keyword("ERROR", Red)), + ])); + let mut watcher = LineWatcher::new(set); + assert!(watcher.is_watching()); + let start = Instant::now(); + let lines = |lines: &[&str]| lines.iter().map(|line| line.to_string()).collect(); + assert_eq!(watcher.check(lines(&["WARN low disk"]), start), None); + assert_eq!( + watcher.check(lines(&["ok", "1 ERROR a", "2 ERROR b"]), start), + Some(KeywordHit { + pattern: "ERROR".into(), + line: "1 ERROR a".into() + }) + ); + // Redrawn, the same line is not news; another one is. + assert_eq!(watcher.check(lines(&["1 ERROR a"]), start), None); + assert!(watcher.check(lines(&["2 ERROR b"]), start).is_some()); + let later = start + RETELL_AFTER; + assert!(watcher.check(lines(&["1 ERROR a"]), later).is_some()); + // New rules start afresh. + watcher.sync(Arc::new(HighlightSet::new(vec![notifying(keyword( + "ERROR", Red, + ))]))); + assert!(watcher.check(lines(&["1 ERROR a"]), later).is_some()); + watcher.sync(Arc::new(HighlightSet::new(vec![keyword("ERROR", Red)]))); + assert!(!watcher.is_watching()); + } + + #[test] + fn a_match_of_nothing_tells_of_nothing() { + let set = Arc::new(HighlightSet::new(vec![notifying(regex(r"\b", Red))])); + let mut watcher = LineWatcher::new(set); + assert_eq!( + watcher.check(vec!["any words".into()], Instant::now()), + None + ); + } +} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index e79a2cd..8ab18f5 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -3,6 +3,7 @@ mod engine; mod exec; mod font; +mod highlight; mod links; mod local_pty; mod local_terminal_panel; @@ -20,6 +21,10 @@ pub use font::{ DEFAULT_FONT_SIZE, DEFAULT_LINE_HEIGHT, FONT_SIZE_RANGE, LINE_HEIGHT_RANGE, TerminalFont, TerminalFontPreview, is_font_installed, monospace_font_families, }; +pub use highlight::{ + HighlightColor, HighlightRule, HighlightSet, HighlightStyle, PATTERN_LIMIT, PatternKind, + TerminalHighlights, default_rules, +}; pub use local_pty::LocalPtyTransportFactory; pub use local_terminal_panel::{LocalTerminalPanel, LocalTerminalPanelEvent}; pub use model::{ diff --git a/src/terminal/notices.rs b/src/terminal/notices.rs index 73f3e7b..83b03d9 100644 --- a/src/terminal/notices.rs +++ b/src/terminal/notices.rs @@ -1,6 +1,6 @@ //! 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. +//! and VTE have it), the bell, or a line a 关键字高亮 rule tells of. //! //! Alacritty drops both OSC sequences, so the parser thread runs the output //! through `NoticeScanner` too. @@ -10,6 +10,8 @@ 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; +/// The longest pattern a notification's title names, in characters. +const PATTERN_LIMIT: usize = 40; /// Something a terminal asks the user to look at. #[derive(Clone, Debug, PartialEq, Eq)] @@ -19,6 +21,9 @@ pub enum TerminalNotice { /// The bell rang. The line the cursor is on, which a program waiting /// for an answer usually has its question on. Bell { line: String }, + /// A line of new output matched a 关键字高亮 rule that notifies: the + /// rule's pattern, and the line. + Keyword { pattern: String, line: String }, } impl TerminalNotice { @@ -28,6 +33,14 @@ impl TerminalNotice { line: clean_text(line, BODY_LIMIT), } } + + /// A rule with `pattern` matched `line`. + pub fn keyword(pattern: &str, line: &str) -> Self { + TerminalNotice::Keyword { + pattern: clean_text(pattern, PATTERN_LIMIT), + line: clean_text(line, BODY_LIMIT), + } + } } /// A notification a program asked for, as it asked. @@ -191,6 +204,13 @@ mod tests { assert_eq!(clean_text(" a\tb\n\x1bc ", 10), "a b c"); assert_eq!(clean_text("一二三四五", 4), "一二三…"); assert_eq!(clean_text("一二三四", 4), "一二三四"); + assert_eq!( + TerminalNotice::keyword(" ERROR ", "12:00\tERROR\x1b boom"), + TerminalNotice::Keyword { + pattern: "ERROR".into(), + line: "12:00 ERROR boom".into() + } + ); assert_eq!( notice(Some(" "), "x").cleaned(), TerminalNotice::Program { diff --git a/src/terminal/search.rs b/src/terminal/search.rs index 476d5ad..68a5752 100644 --- a/src/terminal/search.rs +++ b/src/terminal/search.rs @@ -14,7 +14,7 @@ use gpui_kit::SharedString; /// How far a highlighted match may reach above the first visible row along a /// wrapped line. Longer wrapped lines are cut there, as in Alacritty. -const MAX_WRAPPED_LINES: i32 = 100; +pub(super) const MAX_WRAPPED_LINES: i32 = 100; /// Which way to step from the focused match: `Up` towards older output, /// `Down` towards newer output. diff --git a/src/terminal/terminal_view.rs b/src/terminal/terminal_view.rs index 33831fe..64d3d83 100644 --- a/src/terminal/terminal_view.rs +++ b/src/terminal/terminal_view.rs @@ -259,7 +259,7 @@ impl TerminalView { .await; if this .update(cx, |this, cx| { - if this.focused && this.snapshot(cx).cursor_blinking { + if this.focused && this.engine.read(cx).cursor_blinking() { this.cursor_visible = !this.cursor_visible; cx.notify(); } else if this.focused && !this.cursor_visible { @@ -541,10 +541,6 @@ impl TerminalView { .update(cx, |engine, cx| engine.set_search_query(&query, cx)); } - fn snapshot(&self, cx: &App) -> TerminalSnapshot { - self.engine.read(cx).snapshot() - } - fn on_key_down(&mut self, event: &KeyDownEvent, _: &mut Window, cx: &mut Context) { let mode = self.engine.read(cx).mode(); if let Some(bytes) = encode_key(&event.keystroke, mode) { @@ -1232,9 +1228,23 @@ impl Element for TerminalElement { } let character = painted_cell_text(&cell.character, cell.flags); - let font = terminal_font(text_style.font(), cell.flags); + // A 关键字高亮 rule's bold adds to the program's. + let flags = match cell.highlight { + Some(highlight) if highlight.bold => cell.flags | Flags::BOLD, + _ => cell.flags, + }; + let font = terminal_font(text_style.font(), flags); let len = character.len(); text.push_str(character); + // A rule's color, dimmed as the program dimmed the text. + let highlight = cell.highlight.map(|highlight| { + let color = highlight.color.hsla(cx); + if cell.flags.contains(Flags::DIM) { + color.opacity(0.66) + } else { + color + } + }); // Links always show as links, blue whatever color the // program gave them. Not the theme's `link`: that is the // color of text. @@ -1245,6 +1255,9 @@ impl Element for TerminalElement { cx.theme().blue } }); + // A rule colors a link's text too; the underline still says + // it is one. + let text_color = highlight.or(link).unwrap_or(foreground); runs.push(TextRun { len, font, @@ -1253,7 +1266,7 @@ impl Element for TerminalElement { } else if cell.search == SearchMark::Focused { cx.theme().warning_foreground } else { - link.unwrap_or(foreground) + text_color }, background_color: None, underline: match link { @@ -1267,7 +1280,7 @@ impl Element for TerminalElement { .intersects(Flags::ALL_UNDERLINES) .then_some(UnderlineStyle { thickness: px(1.), - color: Some(foreground), + color: Some(text_color), wavy: cell.flags.contains(Flags::UNDERCURL), }) } @@ -1275,7 +1288,7 @@ impl Element for TerminalElement { strikethrough: cell.flags.contains(Flags::STRIKEOUT).then_some( StrikethroughStyle { thickness: px(1.), - color: Some(foreground), + color: Some(text_color), }, ), }); diff --git a/src/workspace/highlights.rs b/src/workspace/highlights.rs new file mode 100644 index 0000000..9c7518f --- /dev/null +++ b/src/workspace/highlights.rs @@ -0,0 +1,84 @@ +//! The workspace's share of 关键字高亮: the commands of the rule list in +//! the settings, which open the rule dialog and ask before deleting. + +use std::rc::Rc; + +use gpui_kit::*; + +use crate::app::{DeleteHighlightRule, EditHighlightRule, NewHighlightRule}; +use crate::settings::open_highlight_dialog; +use crate::shared::confirm_delete; + +use super::workspace_view::Workspace; + +impl Workspace { + pub(super) fn on_new_highlight_rule( + &mut self, + _: &NewHighlightRule, + window: &mut Window, + cx: &mut Context, + ) { + open_highlight_dialog(None, self.settings().clone(), window, cx); + } + + pub(super) fn on_edit_highlight_rule( + &mut self, + action: &EditHighlightRule, + window: &mut Window, + cx: &mut Context, + ) { + let exists = action.0 + < self + .settings() + .read(cx) + .settings() + .terminal_highlight + .rules + .len(); + if exists { + open_highlight_dialog(Some(action.0), self.settings().clone(), window, cx); + } + } + + pub(super) fn on_delete_highlight_rule( + &mut self, + action: &DeleteHighlightRule, + window: &mut Window, + cx: &mut Context, + ) { + let ix = action.0; + let Some(rule) = self + .settings() + .read(cx) + .settings() + .terminal_highlight + .rules + .get(ix) + .cloned() + else { + return; + }; + let settings = self.settings().clone(); + let pattern = rule.pattern.clone(); + confirm_delete( + &pattern, + None, + Rc::new(move |_, cx| { + settings.update(cx, |settings, cx| { + settings.update( + |settings| { + // Only the rule that was asked about. + let rules = &mut settings.terminal_highlight.rules; + if rules.get(ix) == Some(&rule) { + rules.remove(ix); + } + }, + cx, + ) + }); + }), + window, + cx, + ); + } +} diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index e3d5731..92b6b35 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -6,6 +6,7 @@ mod credentials; mod dock_skin; mod editors; mod forwards; +mod highlights; mod links; mod notices; mod recent_hosts; diff --git a/src/workspace/notices.rs b/src/workspace/notices.rs index 7ba8d9d..0955042 100644 --- a/src/workspace/notices.rs +++ b/src/workspace/notices.rs @@ -1,6 +1,7 @@ //! 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. +//! programs ask for, the bell, and lines a 关键字高亮 rule tells of. The +//! system's notification when the window is not in front, one in the window +//! when another tab is. use std::time::{Duration, Instant}; @@ -14,9 +15,11 @@ 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. +/// the bell and 关键字高亮 rules (all of them together) every 10. More in +/// between are dropped. const PROGRAM_INTERVAL: Duration = Duration::from_secs(2); const BELL_INTERVAL: Duration = Duration::from_secs(10); +const KEYWORD_INTERVAL: Duration = Duration::from_secs(10); /// Where a notice goes. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -30,7 +33,9 @@ pub(super) enum Delivery { /// 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. +/// complete), and a keyword the user sees colored on screen, or typed +/// (`grep ERROR`), is no news, so these only tell of a terminal out of +/// sight. A rule that notifies is its own switch. pub(super) fn delivery( notice: &TerminalNotice, settings: NotificationSettings, @@ -40,6 +45,7 @@ pub(super) fn delivery( let (allowed, quiet_in_front) = match notice { TerminalNotice::Program { .. } => (settings.programs, false), TerminalNotice::Bell { .. } => (settings.bell, true), + TerminalNotice::Keyword { .. } => (true, true), }; if !allowed { Delivery::None @@ -62,6 +68,7 @@ pub(super) fn notice_text(tab: &str, notice: &TerminalNotice) -> (String, String } => (format!("{tab}:{title}"), body.clone()), TerminalNotice::Program { title: None, body } => (tab.to_string(), body.clone()), TerminalNotice::Bell { line } => (format!("{tab}:响铃"), line.clone()), + TerminalNotice::Keyword { pattern, line } => (format!("{tab}:{pattern}"), line.clone()), } } @@ -113,6 +120,7 @@ impl Workspace { let (kind, interval) = match notice { TerminalNotice::Program { .. } => ("program", PROGRAM_INTERVAL), TerminalNotice::Bell { .. } => ("bell", BELL_INTERVAL), + TerminalNotice::Keyword { .. } => ("keyword", KEYWORD_INTERVAL), }; let key = format!("{tag}:{kind}"); // The executor's clock, which tests can move on. @@ -216,22 +224,29 @@ mod tests { } } + fn keyword() -> TerminalNotice { + TerminalNotice::keyword("ERROR", "12:00 ERROR boom") + } + #[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()] { + for notice in [program(None), bell(), keyword()] { 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. + // In sight, a program still notifies; the bell and a keyword do not. assert_eq!(delivery(&program(None), on, true, true), Delivery::InApp); assert_eq!(delivery(&bell(), on, true, true), Delivery::None); + assert_eq!(delivery(&keyword(), 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); + // A rule's own switch says whether it notifies. + assert_eq!(delivery(&keyword(), off, false, false), Delivery::System); } #[test] @@ -248,6 +263,10 @@ mod tests { notice_text("本地终端 1", &bell()), ("本地终端 1:响铃".to_string(), "y/N?".to_string()) ); + assert_eq!( + notice_text("web-01", &keyword()), + ("web-01:ERROR".to_string(), "12:00 ERROR boom".to_string()) + ); } #[test] diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index f9e7371..264efbb 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -2470,6 +2470,9 @@ impl Render for Workspace { .on_action(cx.listener(Self::on_refresh_history)) .on_action(cx.listener(Self::on_enter_command)) .on_action(cx.listener(Self::on_copy_command)) + .on_action(cx.listener(Self::on_new_highlight_rule)) + .on_action(cx.listener(Self::on_edit_highlight_rule)) + .on_action(cx.listener(Self::on_delete_highlight_rule)) .on_action(cx.listener(Self::on_new_snippet)) .on_action(cx.listener(Self::on_new_snippet_in)) .on_action(cx.listener(Self::on_edit_snippet)) diff --git a/tests/ui/settings.rs b/tests/ui/settings.rs index 8faa31c..ec7964f 100644 --- a/tests/ui/settings.rs +++ b/tests/ui/settings.rs @@ -126,7 +126,7 @@ fn open_external_cli_settings(cx: &mut TestAppContext, handle: WindowHandle, cx: &App) -> Vec { + workspace + .read(cx) + .settings() + .read(cx) + .settings() + .terminal_highlight + .rules + .iter() + .map(|rule| rule.pattern.clone()) + .collect() +} + +#[gpui_kit::test] +async fn highlight_rules_are_added_edited_and_deleted_on_their_page(cx: &mut TestAppContext) { + use shellrs::terminal::{HighlightColor, HighlightRule, PatternKind, TerminalHighlights}; + + let (handle, workspace) = open_workspace(cx); + in_frame(cx, handle, |window, cx| window.click("open-settings", cx)); + // 关键字高亮 comes right after 终端, with the examples a new + // installation starts with. + in_frame(cx, handle, |window, cx| { + window.within("settings").click("0-2", cx) + }); + in_frame(cx, handle, |window, _| { + assert!(window.find(("highlight-rule", 2usize)).visible()); + assert!(window.try_find(("highlight-rule", 3usize)).is_none()); + }); + let examples = [ + "ERROR".to_string(), + "WARN".into(), + r"\b\d{1,3}(\.\d{1,3}){3}\b".into(), + ]; + assert_eq!(cx.update(|cx| highlight_patterns(&workspace, cx)), examples); + + // A new rule takes the keyboard at once; a regex that does not compile + // is refused, the dialog staying open. + in_frame(cx, handle, |window, cx| { + window.click("add-highlight-rule", cx) + }); + in_frame(cx, handle, |window, cx| { + assert_eq!(window.find("highlight-pattern").focused(), Some(true)); + window.input("OutOf(Memory", cx); + window.within("highlight-kind").click(1usize, cx); + window.click("commit", cx); + }); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| { + window + .try_find("form-error") + .is_some_and(|error| error.label() == Some("正则表达式写法有误")) + }) + .await; + in_frame(cx, handle, |window, cx| { + window.click("highlight-pattern", cx); + window.press("cmd-a", cx); + window.input(r"OutOfMemory\w*", cx); + window.within("highlight-color").click(5usize, cx); + window.click("highlight-bold", cx); + window.click("highlight-notify", cx); + window.click("commit", cx); + }); + cx.run_until_parked(); + let rules = cx.update(|cx| { + workspace + .read(cx) + .settings() + .read(cx) + .settings() + .terminal_highlight + .rules + }); + assert_eq!( + rules.last(), + Some(&HighlightRule { + pattern: r"OutOfMemory\w*".into(), + kind: PatternKind::Regex, + color: HighlightColor::Magenta, + bold: true, + notify: true, + }) + ); + + // With the dialog closed nothing has focus, and the row's button still + // reaches the workspace. + in_frame(cx, handle, |window, cx| { + window.click(("edit-highlight-rule", 3usize), cx) + }); + in_frame(cx, handle, |window, cx| { + assert_eq!(window.find("highlight-pattern").focused(), Some(true)); + window.press("cmd-a", cx); + window.input("OOM", cx); + window.within("highlight-kind").click(0usize, cx); + window.click("commit", cx); + }); + cx.run_until_parked(); + assert_eq!(cx.update(|cx| highlight_patterns(&workspace, cx))[3], "OOM"); + + // Deleted once confirmed; the terminals follow. + in_frame(cx, handle, |window, cx| { + window.click(("delete-highlight-rule", 0usize), cx) + }); + in_frame(cx, handle, |window, cx| window.click("ok", cx)); + cx.run_until_parked(); + cx.update(|cx| { + let patterns = highlight_patterns(&workspace, cx); + assert_eq!( + patterns, + [examples[1].clone(), examples[2].clone(), "OOM".into()] + ); + let in_effect: Vec<_> = TerminalHighlights::current(cx) + .rules() + .iter() + .map(|rule| rule.pattern.clone()) + .collect(); + assert_eq!(in_effect, patterns); + }); +} diff --git a/tests/ui/terminal.rs b/tests/ui/terminal.rs index 8e8a1a4..3ad25e2 100644 --- a/tests/ui/terminal.rs +++ b/tests/ui/terminal.rs @@ -1000,3 +1000,105 @@ async fn terminals_notify_where_the_user_will_see_it(cx: &mut TestAppContext) { ); }); } + +/// Have the settings hold `rules`, as the 关键字高亮 page writes them. +fn set_highlight_rules( + cx: &mut TestAppContext, + workspace: &Entity, + rules: Vec, +) { + let settings = cx.update(|cx| workspace.read(cx).settings().clone()); + settings.update(cx, |settings, cx| { + settings.update(|settings| settings.terminal_highlight.rules = rules, cx) + }); + cx.run_until_parked(); +} + +#[gpui_kit::test] +async fn a_matching_line_notifies_from_a_terminal_out_of_sight(cx: &mut TestAppContext) { + use shellrs::terminal::HighlightRule; + + let factory = Arc::new(FakeTerminalFactory::default()); + let (handle, workspace) = open_running_local_terminal(cx, factory).await; + // WARN only colors; ERROR tells. + set_highlight_rules( + cx, + &workspace, + vec![ + HighlightRule { + pattern: "WARN".into(), + ..HighlightRule::default() + }, + HighlightRule { + pattern: "ERROR".into(), + notify: true, + ..HighlightRule::default() + }, + ], + ); + in_frame(cx, handle, |window, _| window.activate_window()); + + // Behind a second terminal, a finished line that matches shows in the + // window; neither the WARN line nor the line still being written does. + 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, "12:01 WARN low disk\n12:02 ERR"); + cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| { + local_screen(&workspace, cx).contains("12:02 ERR") + }) + .await; + print_into(cx, &workspace, 1, "OR boom\n"); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.notifications(cx).len() == 1 + }) + .await; + + // With the window away, the system's notification names the tab and + // the rule, and has the line. + cx.background_executor + .advance_clock(Duration::from_secs(10)); + cx.run_until_parked(); + gpui_kit::VisualTestContext::from_window(handle.into(), cx).deactivate_window(); + print_into(cx, &workspace, 1, "12:03 ERROR again\n"); + 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:ERROR"); + assert_eq!(shown[0].body.as_ref(), "12:03 ERROR again"); + + // In sight, a matching line says nothing: had it, the next one would + // be held back as too soon after it. + cx.background_executor + .advance_clock(Duration::from_secs(10)); + cx.simulate_system_notification_response(gpui_kit::SystemNotificationResponse { + tag: shown[0].tag.clone(), + action_id: None, + }); + cx.run_until_parked(); + in_frame(cx, handle, |window, _| window.activate_window()); + cx.update(|cx| { + assert_eq!( + workspace.read(cx).active_tab(), + Some(shellrs::app::CenterTab::LocalTerminal(LocalTerminalId(1))) + ); + }); + // The program's notice after it comes once the line has been weighed. + print_into(cx, &workspace, 1, "12:04 ERROR seen\n\x1b]9;done\x07"); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.notifications(cx).len() == 1 + }) + .await; + gpui_kit::VisualTestContext::from_window(handle.into(), cx).deactivate_window(); + print_into(cx, &workspace, 1, "12:05 ERROR later\n"); + let shown = system_notifications(cx, 2).await; + assert_eq!(shown[1].body.as_ref(), "12:05 ERROR later"); +} diff --git a/tests/ui/update.rs b/tests/ui/update.rs index 415ddd0..5c8e994 100644 --- a/tests/ui/update.rs +++ b/tests/ui/update.rs @@ -230,7 +230,7 @@ fn serve_updates( fn open_about_settings(cx: &mut TestAppContext, handle: WindowHandle) { in_frame(cx, handle, |window, cx| window.click("open-settings", cx)); in_frame(cx, handle, |window, cx| { - window.within("settings").click("0-3", cx) + window.within("settings").click("0-4", cx) }); }