终端关键字高亮:按规则给匹配的文字上色,可选匹配时通知
- 规则是关键字(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 <noreply@anthropic.com>
This commit is contained in:
1 parent
09ccaa3036
commit
5f588a4a18
25 files changed
+1918
-37
No files matched your search
@@ -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:<id>` / `terminal:remote:<id>`,点击回调按 tag 切标签),在前台走应用内通知。`shellrs::init` 里的 `set_app_identity` 用 bundle id,测试平台没有它就不发系统通知;macOS 上 `cargo run` 没有 bundle,看不到系统通知。
|
||||
|
||||
|
||||
Generated
+1
@@ -7724,6 +7724,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"portable-pty",
|
||||
"rand 0.9.5",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"russh",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+3
-1
@@ -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 插件)直接进本机剪贴板。远程程序读不了本机剪贴板。
|
||||
|
||||
## 右侧栏
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-1
@@ -72,7 +72,8 @@ icon_assets!(
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Zap,
|
||||
ExternalLink
|
||||
ExternalLink,
|
||||
Highlighter
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
+11
-2
@@ -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::<TerminalHighlights>();
|
||||
if current.map(|highlights| highlights.set().rules()) != Some(rules.as_slice()) {
|
||||
cx.set_global(TerminalHighlights::new(rules));
|
||||
}
|
||||
|
||||
if cx.try_global::<FileSizeFormat>() != Some(&settings.file_size_format) {
|
||||
cx.set_global(settings.file_size_format);
|
||||
window.refresh();
|
||||
|
||||
@@ -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<SettingsStore>,
|
||||
/// The position of the rule being changed, and the rule as it was.
|
||||
editing: Option<(usize, HighlightRule)>,
|
||||
pattern: Entity<InputState>,
|
||||
kind: PatternKind,
|
||||
color: HighlightColor,
|
||||
bold: bool,
|
||||
notify: bool,
|
||||
_pattern_changes: Subscription,
|
||||
}
|
||||
|
||||
impl HighlightForm {
|
||||
fn new(
|
||||
editing: Option<usize>,
|
||||
store: Entity<SettingsStore>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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<Self>) -> 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<Self>) -> 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<usize>,
|
||||
store: Entity<SettingsStore>,
|
||||
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));
|
||||
}
|
||||
+3
-1
@@ -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};
|
||||
+97
-2
@@ -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<HighlightRule>,
|
||||
}
|
||||
|
||||
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<Vec<HighlightRule>, D::Error> {
|
||||
let values = Vec::<serde_json::Value>::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::<Vec<_>>(),
|
||||
["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();
|
||||
|
||||
@@ -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<SettingGroup>;
|
||||
|
||||
/// 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<SettingsStore>) -> 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<SettingGroup> {
|
||||
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<HighlightSet>, 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<SettingGroup> {
|
||||
|
||||
@@ -32,6 +32,9 @@ pub struct Segment {
|
||||
label: SharedString,
|
||||
/// How many there are of what it shows, quieter beside the label.
|
||||
count: Option<usize>,
|
||||
/// A color it stands for, as a dot before the label: 关键字高亮's
|
||||
/// colors, named as well as shown.
|
||||
swatch: Option<Hsla>,
|
||||
disabled: bool,
|
||||
/// Why a disabled segment cannot be chosen.
|
||||
tooltip: Option<SharedString>,
|
||||
@@ -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(
|
||||
|
||||
+63
-4
@@ -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<usize>,
|
||||
/// How a 关键字高亮 rule shows the cell, when one matches it.
|
||||
pub highlight: Option<HighlightStyle>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -85,6 +91,10 @@ pub struct TerminalEngine {
|
||||
search: RefCell<Option<TerminalSearch>>,
|
||||
/// Finds the web addresses on screen, built once.
|
||||
url_search: RefCell<RegexSearch>,
|
||||
/// 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<TerminalUiEvent>,
|
||||
@@ -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::<TerminalHighlights>(|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<dyn super::TerminalTransport>,
|
||||
ui_events: mpsc::Sender<TerminalUiEvent>,
|
||||
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<FairMutex<AlacrittyTerm>>, message: &str) {
|
||||
@@ -1388,6 +1446,7 @@ mod tests {
|
||||
SearchMark::None
|
||||
},
|
||||
link: None,
|
||||
highlight: None,
|
||||
};
|
||||
let mut cells = vec![
|
||||
cell(Flags::WIDE_CHAR, true),
|
||||
|
||||
@@ -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<HighlightRule> {
|
||||
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<Regex, &'static str> {
|
||||
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<HighlightRule>,
|
||||
compiled: Vec<Result<Regex, &'static str>>,
|
||||
}
|
||||
|
||||
// Shared by the UI thread and every terminal's parser thread.
|
||||
const _: fn() = || {
|
||||
fn shared<T: Send + Sync>() {}
|
||||
shared::<HighlightSet>();
|
||||
};
|
||||
|
||||
impl HighlightSet {
|
||||
pub fn new(rules: Vec<HighlightRule>) -> 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<Item = (usize, &HighlightRule, &Regex)> {
|
||||
self.rules
|
||||
.iter()
|
||||
.zip(&self.compiled)
|
||||
.enumerate()
|
||||
.filter_map(|(ix, (rule, compiled))| Some((ix, rule, compiled.as_ref().ok()?)))
|
||||
}
|
||||
|
||||
fn notifying(&self) -> impl Iterator<Item = (usize, &HighlightRule, &Regex)> {
|
||||
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<T>(&self, term: &Term<T>) -> 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<HighlightSet>);
|
||||
|
||||
impl Global for TerminalHighlights {}
|
||||
|
||||
impl TerminalHighlights {
|
||||
pub fn new(rules: Vec<HighlightRule>) -> Self {
|
||||
Self(Arc::new(HighlightSet::new(rules)))
|
||||
}
|
||||
|
||||
/// The rules in effect; none before settings are applied.
|
||||
pub fn current(cx: &App) -> Arc<HighlightSet> {
|
||||
cx.try_global::<Self>()
|
||||
.map(|highlights| highlights.0.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn set(&self) -> &Arc<HighlightSet> {
|
||||
&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<Mutex<Arc<HighlightSet>>>;
|
||||
|
||||
pub(super) fn current(shared: &SharedHighlights) -> Arc<HighlightSet> {
|
||||
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<Option<HighlightStyle>>,
|
||||
}
|
||||
|
||||
impl ScreenHighlights {
|
||||
pub fn style(&self, row: usize, column: usize) -> Option<HighlightStyle> {
|
||||
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<usize>, 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<LineCell>,
|
||||
}
|
||||
|
||||
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<T>(term: &Term<T>, 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<T>(term: &Term<T>, 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<T>(term: &Term<T>, 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<T>(term: &Term<T>, 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<T>(term: &Term<T>) -> 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<T: EventListener>(
|
||||
processor: &mut Processor,
|
||||
term: &mut Term<T>,
|
||||
bytes: &[u8],
|
||||
watching: bool,
|
||||
) -> Vec<String> {
|
||||
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<HighlightSet>,
|
||||
/// The lines told of lately, by rule, and when.
|
||||
told: VecDeque<(usize, String, Instant)>,
|
||||
}
|
||||
|
||||
impl LineWatcher {
|
||||
pub fn new(set: Arc<HighlightSet>) -> 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<HighlightSet>) {
|
||||
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<String>, now: Instant) -> Option<KeywordHit> {
|
||||
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<VoidListener>,
|
||||
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<String> {
|
||||
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<HighlightRule>) -> 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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::{
|
||||
|
||||
+21
-1
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Self>) {
|
||||
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),
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ mod credentials;
|
||||
mod dock_skin;
|
||||
mod editors;
|
||||
mod forwards;
|
||||
mod highlights;
|
||||
mod links;
|
||||
mod notices;
|
||||
mod recent_hosts;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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))
|
||||
|
||||
+120
-1
@@ -126,7 +126,7 @@ fn open_external_cli_settings(cx: &mut TestAppContext, handle: WindowHandle<Root
|
||||
cx.run_until_parked();
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
window.within("settings").click("0-2", cx);
|
||||
window.within("settings").click("0-3", cx);
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
@@ -317,3 +317,122 @@ fn the_appearance_setting_drives_the_theme_and_the_title_bar_switch(cx: &mut Tes
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The patterns of the 关键字高亮 rules, in order.
|
||||
fn highlight_patterns(workspace: &Entity<Workspace>, cx: &App) -> Vec<String> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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<Workspace>,
|
||||
rules: Vec<shellrs::terminal::HighlightRule>,
|
||||
) {
|
||||
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");
|
||||
}
|
||||
+1
-1
@@ -230,7 +230,7 @@ fn serve_updates(
|
||||
fn open_about_settings(cx: &mut TestAppContext, handle: WindowHandle<Root>) {
|
||||
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)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user