关键字高亮设置改成可直接编辑的规则表,总开关默认关闭

- 设置页分三组:常规(启用关键字高亮,默认关闭)、预览(当前终端字体和
  配色下的一段示例日志)、规则(N)。
- 规则表在行里直接改,不再弹对话框:拖动把手排序、启用、正则表达式、
  备注、颜色(取色器加 #rrggbb 输入框)、通知、删除。规则都是正则,区分
  大小写;颜色可任意取,不跟主题;去掉加粗。通知标题用备注。
- 预置 ERROR、WARN、IPv4 三条示例,各自启用、不通知,打开总开关就能用。
- 取色器自带一行与调色板不重复的常用色:gpui-kit 默认的常用色也在调色板
  里,同色两格是重复的无障碍节点,调试版会因此崩溃。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XuzandClaude Opus 5.5 committed 2026-10-06 19:46:18 +08:00
1 parent 7c9501e4cb
commit 2aa5d5be49
22 files changed
+1121 -968

No files matched your search

+6 -4
View File
@@ -57,10 +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 秒一次。全屏程序(备用屏)里不高亮也不通知。
- **关键字高亮**:设置左栏单独一个分类,排在「终端」下面,三组:常规(总开关)、预览(当前终端主题下的示例输出)、规则(N)。规则存在 `settings.json`,所有终端共用。
- 规则表在行里直接改,不弹对话框:拖动把手(一直显示,只有把手能拖)、启用、正则表达式、备注、颜色(gpui-kit `ColorPicker` 加 `#rrggbb` 输入框)、通知、删除(不确认)。规则都是正则,区分大小写;颜色是用户数据,任意 RGB,不跟主题;不做加粗。重叠时靠前的优先。
- 总开关默认关闭。首次(文件里没有这一项)预置三条示例:ERROR 红、WARN 琥珀、IPv4 蓝,各自启用、都不通知,打开总开关就能用;用户删光后保持为空。空的新行照样保存。
- 通知同响铃:看不到那个终端才提醒,同一终端 10 秒一次,标题用备注。全屏程序(备用屏)里不高亮也不通知。
- **预览**:图片和 Markdown(不做 HTML),显示在快速查看式的大对话框里,不是标签。
- 双击图片预览;Markdown 和 SVG 是文本,双击编辑,右键另有「预览」。
- 图片默认适合窗口居中,可放大、缩小、看原图、滚动;比例只写百分比。
@@ -166,6 +166,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- **按上一帧的尺寸重排**(路径标签、监控的竖条、图片预览的画框):render 时拿不到布局尺寸。放一个 `canvas`,在 prepaint 记下尺寸,变了就 `on_next_frame` 通知重画。绝对定位的 `canvas` 必须 `top_0().left_0()`。
- **`img` 会给自己的盒子加上图片的宽高比**,压过百分比高度。要控制尺寸就给宽和高都写绝对值。
- **`Select` 必须放进有尺寸的容器**,否则会盖住整行。`ListItem` 里会增长的文字要 `w_0().flex_grow(1.).overflow_x_hidden()`,否则会把 `suffix` 挤出去。
- **`ColorPicker` 要自己给常用色**(`featured_colors`):默认的第一行是主题色,它们也在下面的调色板里。gpui-kit 按十六进制给色块起 id,同一颜色出现两格就是重复的无障碍节点,调试版在辅助技术(或借助它的窗口管理工具)连着时直接 panic;测试平台不建无障碍树,测不出来。常用色不能取调色板(shadcn 的 stone、red … pink)里的颜色。
- **要彩色就不能用 `Icon`**:它把 SVG 当成单色遮罩画。系统徽章是「主题色圆底 + 单色 logo」。
- **`px(..)` 只用在 API 要 `Pixels` 的地方**,其余用 rem 助手和 `cx.theme()` 的 token。
- **标签页全部可关。** 关闭走 `DockArea::remove_panel`(`TabGroup::close_panel` 拒绝关最后一个)。中间区面板的 `closable()` 返回 `false`,否则「…」菜单会多一个绕过确认的「关闭」。标题用 `ClosableTabTitle`,它带标签菜单,双击切换侧栏。`panel_name()` 是持久化键,不能改。
@@ -259,6 +260,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- 通知在解析线程上:按 `\n` 切开输出,在 LF **之前**读光标所在的逻辑行(这时 `\r` 和颜色都已生效),锁外匹配。备用屏和同步更新进行中不读。
- 规则由 `settings::apply` 编译成全局量 `TerminalHighlights`,引擎 `observe_global` 后换进与解析线程共用的槽;重启运行时也要把槽传过去。
- `settings.json` 里读不了的规则单条丢掉(`readable_rules`),否则整个设置文件会回退成默认值。
- 规则表的输入框和取色器状态归 `HighlightRulesEditor`(行 id 不随位置变,拖动后字段跟着走),每次改动整表写回设置;设置被别处改了才重建行。
- **同步更新(DEC 2026)要解析线程自己收尾**:vte 只管攒住 `\e[?2026h` 之后的输出,超时(150 ms)后调 `stop_sync` 是调用方的事,alacritty 自己的事件循环就是这么做的。解析线程等输出时带着截止时间(`receive`,用 `async_io` 的定时器),到点就画出来;输出一直不停时在 `advance` 前补查;退出、出错追加提示前也先 `stop_sync`。否则程序死在一帧中间、连接断在一帧中间时,屏幕会一直冻住。
- **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,看不到系统通知。
+2 -2
View File
@@ -105,7 +105,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
- **外观 → 常规 → 界面语言**:跟随系统、简体中文、English,默认简体中文。英文界面尚在翻译中,目前只有 GPUI Kit 组件自带的文字(如搜索框提示)会切换。
- **外观 → 常规 → 应用外观**:深色、浅色、跟随系统,默认跟随系统;选「跟随系统」时随系统的深浅色切换。标题栏的主题按钮也会改写这一项。
- **终端 → 字体配置**:字体(只列出本机安装的等宽字体,默认是系统的等宽字体,macOS 上为 Menlo)、字号(8–32 像素,默认 13)、行高(字号的 1.0–2.0 倍,默认 1.54,即原来的 20 像素行高)。改动立即作用于所有已打开的终端。下方的预览按终端的排法显示字母、数字、易混字符、符号、中文、制表符和一行命令。设置文件里的字体在本机没有安装时,使用默认字体。
- **关键字高亮**:终端里按规则给文字上色、匹配时提醒,见「终端」一节。
- **关键字高亮**:总开关、预览和规则表,终端里按规则给文字上色、匹配时提醒,见「终端」一节。
- **外部 CLI**:见下一节。
- **关于**:「应用更新」一组:当前版本和检查更新、更新渠道、自动升级,见「下载与更新」。
@@ -177,7 +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 这类全屏程序里不高亮,也不提醒。**尚未实现:调整规则顺序、按主机区分规则、背景色样式。**
- **关键字高亮**:设置 › 关键字高亮 里的规则把匹配的文字按规则的颜色显示在所有终端里,回滚历史里的也一样。只改显示:服务器的输出不变,复制出的文字也不变;窗口缩放、长行折行后照样匹配,被折成两行的词两段都上色。这个功能默认关闭,打开页面上方的「启用关键字高亮」后才生效,关掉它就一次停用全部规则;「预览」用当前终端的字体和配色显示一段示例输出,规则改动立即反映在预览和已打开的终端里。规则表一行一条,直接在表里改:「启用」、「正则表达式」(区分大小写,开头加 `(?i)` 不区分)、「备注」、颜色(点色块用取色器,或在旁边填 `#rrggbb`)、「通知」,行尾的垃圾桶删除这一条。正则写错时在框下方提示,这条规则暂不生效。规则从上到下应用,重叠处前面的规则优先;按住行首的把手拖动可调整顺序。初始有三条示例:`ERROR` 红色、`WARN` 琥珀色、IPv4 地址蓝色,都已启用、不通知,打开总开关就能看到效果,可以修改或删除。勾选「通知」的规则在看不到那个终端时提醒,和响铃一样:ShellRS 不在前台时发系统通知,终端在别的标签时在窗口右上角提示;标题是「标签名:备注」(没填备注时用正则),正文是匹配的那一行。只检查换了行的新输出,同一终端 10 秒内只提醒一次,反复重画的同一行 5 分钟内只提醒一次。vim、less、top、tmux 这类全屏程序里不高亮,也不提醒。**尚未实现:按主机区分规则、加粗等文字样式、用键盘调整顺序。**
- **远程复制**:远程程序用 OSC 52 复制的内容(如 tmux 的 `set -g set-clipboard on`、vim 的 OSC 52 插件)直接进本机剪贴板。远程程序读不了本机剪贴板。
## 右侧栏
-8
View File
@@ -107,8 +107,6 @@ 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).
@@ -275,12 +273,6 @@ 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
View File
@@ -73,7 +73,8 @@ icon_assets!(
ZoomOut,
Zap,
ExternalLink,
Highlighter
Highlighter,
GripVertical
]
);
+10 -4
View File
@@ -44,10 +44,16 @@ pub fn apply(settings: AppSettings, window: &mut Window, cx: &mut App) {
// 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));
let highlight = settings.terminal_highlight;
let unchanged = cx
.try_global::<TerminalHighlights>()
.is_some_and(|current| {
current
.set()
.is_made_of(highlight.enabled, &highlight.rules)
});
if !unchanged {
cx.set_global(TerminalHighlights::new(highlight.enabled, highlight.rules));
}
if cx.try_global::<FileSizeFormat>() != Some(&settings.file_size_format) {
-277
View File
@@ -1,277 +0,0 @@
//! 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));
}
+498
View File
@@ -0,0 +1,498 @@
//! 设置 › 关键字高亮 › 规则: the rules as a table edited in place. Every
//! change goes to the settings at once, so the preview above and the
//! terminals follow as the user types.
use gpui_kit::component::{
ActiveTheme as _, Icon, IconName, Sizable as _,
button::{Button, ButtonVariants as _},
checkbox::Checkbox,
color_picker::{ColorPicker, ColorPickerEvent, ColorPickerState},
h_flex,
input::{Input, InputEvent, InputState},
tooltip::Tooltip,
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::app::CatalogIcon;
use crate::terminal::{HighlightColor, HighlightRule};
use super::SettingsStore;
/// The colors the picker offers first, the examples' among them.
///
/// None may be a color of the picker's own palette (shadcn's stone, red,
/// orange, yellow, green, cyan, blue, purple and pink): gpui-kit keys each
/// swatch by its hex, so one color in both rows is one accessibility node
/// twice, which a debug build stops on while assistive technology (or a
/// window manager using it) is connected. Its default first row, the
/// theme's colors, is just such a duplicate.
const FEATURED_COLORS: [HighlightColor; 10] = [
HighlightColor::RED,
HighlightColor::from_rgb(0xf76b15),
HighlightColor::AMBER,
HighlightColor::from_rgb(0x2fa86b),
HighlightColor::from_rgb(0x0fa3a3),
HighlightColor::BLUE,
HighlightColor::from_rgb(0x5b5bd6),
HighlightColor::from_rgb(0xa855d6),
HighlightColor::from_rgb(0xe0489c),
HighlightColor::from_rgb(0x8a8f98),
];
/// The rule table. The rows' text fields and color pickers live here, so
/// they keep the caret and an open picker while the settings change under
/// them; the settings hold the rules themselves.
pub struct HighlightRulesEditor {
store: Entity<SettingsStore>,
rows: Vec<RuleRow>,
/// The next row's id: a row keeps it, whatever its place, so a dragged
/// row keeps its fields.
next_id: usize,
_store_changes: Subscription,
}
struct RuleRow {
id: usize,
rule: HighlightRule,
/// Why the pattern matches nothing, when it does not compile.
error: Option<&'static str>,
pattern: Entity<InputState>,
note: Entity<InputState>,
hex: Entity<InputState>,
picker: Entity<ColorPickerState>,
_subscriptions: [Subscription; 4],
}
/// A row on its way to another place.
#[derive(Clone)]
struct DraggedRule {
id: usize,
/// Its place when the drag began; nothing moves until it ends.
ix: usize,
pattern: SharedString,
}
impl Render for DraggedRule {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.px_2()
.py_1()
.rounded(cx.theme().radius)
.bg(cx.theme().popover)
.text_color(cx.theme().popover_foreground)
.border_1()
.border_color(cx.theme().border)
.font_family(cx.theme().mono_font_family.clone())
.text_sm()
.child(self.pattern.clone())
}
}
impl HighlightRulesEditor {
pub fn new(store: Entity<SettingsStore>, window: &mut Window, cx: &mut Context<Self>) -> Self {
// The rows' own edits come back as the same rules; anything else
// (the settings file read anew) lays the rows out again.
let store_changes = cx.observe_in(&store, window, |this, store, window, cx| {
let rules = store.read(cx).settings().terminal_highlight.rules;
if rules != this.rules() {
this.rows = Vec::new();
for rule in rules {
let row = this.new_row(rule, window, cx);
this.rows.push(row);
}
cx.notify();
}
});
let mut this = Self {
store: store.clone(),
rows: Vec::new(),
next_id: 0,
_store_changes: store_changes,
};
for rule in store.read(cx).settings().terminal_highlight.rules {
let row = this.new_row(rule, window, cx);
this.rows.push(row);
}
this
}
fn rules(&self) -> Vec<HighlightRule> {
self.rows.iter().map(|row| row.rule.clone()).collect()
}
fn new_row(
&mut self,
rule: HighlightRule,
window: &mut Window,
cx: &mut Context<Self>,
) -> RuleRow {
let id = self.next_id;
self.next_id += 1;
let pattern = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(r"例如:ERROR|WARN 或 failed\s+\d+")
.default_value(rule.pattern.clone())
});
let note = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("例如:错误告警")
.default_value(rule.note.clone())
});
let hex = cx.new(|cx| InputState::new(window, cx).default_value(rule.color.to_hex()));
let picker =
cx.new(|cx| ColorPickerState::new(window, cx).default_value(rule.color.hsla()));
let subscriptions = [
cx.subscribe(&pattern, move |this, input, event: &InputEvent, cx| {
if matches!(event, InputEvent::Change) {
let pattern = input.read(cx).value().to_string();
this.edit(id, |rule| rule.pattern = pattern, cx);
}
}),
cx.subscribe(&note, move |this, input, event: &InputEvent, cx| {
if matches!(event, InputEvent::Change) {
let note = input.read(cx).value().to_string();
this.edit(id, |rule| rule.note = note, cx);
}
}),
// A color typed in whole takes effect; left half typed, the
// field shows the rule's color again.
cx.subscribe_in(
&hex,
window,
move |this, input, event: &InputEvent, window, cx| match event {
InputEvent::Change => {
if let Some(color) = HighlightColor::from_hex(&input.read(cx).value()) {
this.set_color(id, color, ColorSource::Hex, window, cx);
}
}
InputEvent::Blur => {
if let Some(row) = this.row(id) {
let hex = row.rule.color.to_hex();
input.update(cx, |input, cx| input.set_value(hex, window, cx));
}
}
_ => {}
},
),
cx.subscribe_in(
&picker,
window,
move |this, _, event: &ColorPickerEvent, window, cx| {
if let ColorPickerEvent::Change(Some(color)) = event {
let color = HighlightColor::from_hsla(*color);
this.set_color(id, color, ColorSource::Picker, window, cx);
}
},
),
];
RuleRow {
id,
error: rule.compile().err(),
rule,
pattern,
note,
hex,
picker,
_subscriptions: subscriptions,
}
}
fn row(&self, id: usize) -> Option<&RuleRow> {
self.rows.iter().find(|row| row.id == id)
}
/// Change the rule of row `id` and write the rules.
fn edit(&mut self, id: usize, change: impl FnOnce(&mut HighlightRule), cx: &mut Context<Self>) {
let Some(row) = self.rows.iter_mut().find(|row| row.id == id) else {
return;
};
change(&mut row.rule);
row.error = row.rule.compile().err();
self.write(cx);
}
/// Give row `id` `color`, and show it in whichever of its color field
/// and picker did not set it.
fn set_color(
&mut self,
id: usize,
color: HighlightColor,
source: ColorSource,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(row) = self.row(id) else {
return;
};
match source {
ColorSource::Hex => row.picker.update(cx, |picker, cx| {
picker.set_value(color.hsla(), window, cx);
}),
ColorSource::Picker => row.hex.update(cx, |hex, cx| {
hex.set_value(color.to_hex(), window, cx);
}),
}
self.edit(id, |rule| rule.color = color, cx);
}
fn write(&mut self, cx: &mut Context<Self>) {
let rules = self.rules();
self.store.update(cx, |store, cx| {
store.update(|settings| settings.terminal_highlight.rules = rules, cx)
});
cx.notify();
}
/// A rule at the end, enabled and empty, its pattern taking the keyboard.
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let row = self.new_row(HighlightRule::default(), window, cx);
let pattern = row.pattern.clone();
self.rows.push(row);
self.write(cx);
pattern.update(cx, |pattern, cx| pattern.focus(window, cx));
}
fn remove(&mut self, id: usize, cx: &mut Context<Self>) {
self.rows.retain(|row| row.id != id);
self.write(cx);
}
/// Put row `id` where row `target` is: after it when it came from
/// above, before it when from below.
fn move_row(&mut self, id: usize, target: usize, cx: &mut Context<Self>) {
let from = self.rows.iter().position(|row| row.id == id);
let to = self.rows.iter().position(|row| row.id == target);
let (Some(from), Some(to)) = (from, to) else {
return;
};
if from != to {
let row = self.rows.remove(from);
self.rows.insert(to, row);
self.write(cx);
}
}
fn render_toolbar(&self, cx: &mut Context<Self>) -> impl IntoElement {
h_flex().w_full().justify_end().child(
Button::new("add-highlight-rule")
.outline()
.small()
.icon(Icon::new(IconName::Plus))
.label("添加规则")
.on_click(cx.listener(|this, _, window, cx| this.add(window, cx))),
)
}
fn render_header(&self, cx: &App) -> impl IntoElement {
h_flex()
.w_full()
.gap_3()
.pb_2()
.text_xs()
.text_color(cx.theme().muted_foreground)
// Over the drag handles.
.child(div().w_4().flex_shrink_0())
.child(div().w_8().flex_shrink_0().child("启用"))
.child(div().flex_1().min_w_0().child("正则表达式"))
.child(div().w_40().flex_shrink_0().child("备注"))
.child(div().w_32().flex_shrink_0().child("颜色"))
.child(div().w_8().flex_shrink_0().child("通知"))
.child(div().w_6().flex_shrink_0())
}
fn render_row(&self, ix: usize, row: &RuleRow, cx: &mut Context<Self>) -> impl IntoElement {
let id = row.id;
let mono = cx.theme().mono_font_family.clone();
h_flex()
.id(("highlight-rule", id))
.test_support()
.w_full()
.gap_3()
.py_2()
.border_t_1()
.border_color(cx.theme().border)
// Where a dragged row lands: after this one when it came from
// above, before it when from below.
.drag_over::<DraggedRule>(move |style, dragged, _, cx| {
if dragged.id == id {
style
} else if dragged.ix < ix {
style.border_b_1().border_color(cx.theme().primary)
} else {
style.border_color(cx.theme().primary)
}
})
.on_drop(cx.listener(move |this, dragged: &DraggedRule, _, cx| {
this.move_row(dragged.id, id, cx);
}))
// Only the handle starts a drag: elsewhere a press selects text.
.child(
div()
.id(("highlight-rule-handle", id))
.test_support()
.w_4()
.flex_shrink_0()
.cursor_grab()
.text_color(cx.theme().muted_foreground)
.tooltip(|window, cx| Tooltip::new("拖动调整顺序").build(window, cx))
.child(Icon::new(CatalogIcon::GripVertical).small())
.on_drag(
DraggedRule {
id,
ix,
pattern: row.rule.pattern.clone().into(),
},
|dragged, _, _, cx| cx.new(|_| dragged.clone()),
),
)
.child(
div().w_8().flex_shrink_0().child(
Checkbox::new(("highlight-rule-enabled", id))
.checked(row.rule.enabled)
.small()
.on_click(cx.listener(move |this, checked: &bool, _, cx| {
let enabled = *checked;
this.edit(id, |rule| rule.enabled = enabled, cx);
})),
),
)
.child(
v_flex()
.flex_1()
.min_w_0()
.gap_1()
.child(
Input::new(&row.pattern)
.id(("highlight-rule-pattern", id))
.small()
.font_family(mono.clone()),
)
.when_some(row.error, |cell, error| {
cell.child(
div()
.id(("highlight-rule-error", id))
.test_support()
.aria_label(error)
.text_xs()
.text_color(cx.theme().danger)
.child(error),
)
}),
)
.child(
div().w_40().flex_shrink_0().child(
Input::new(&row.note)
.id(("highlight-rule-note", id))
.small(),
),
)
.child(
h_flex()
.w_32()
.flex_shrink_0()
.gap_2()
.child(
div()
.id(("highlight-rule-picker", id))
.test_support()
.flex_shrink_0()
.child(
ColorPicker::new(&row.picker)
.featured_colors(
FEATURED_COLORS.iter().map(|color| color.hsla()).collect(),
)
.small()
.accessibility_label("颜色"),
),
)
.child(
div().flex_1().min_w_0().child(
Input::new(&row.hex)
.id(("highlight-rule-color", id))
.small()
.font_family(mono),
),
),
)
.child(
div().w_8().flex_shrink_0().child(
Checkbox::new(("highlight-rule-notify", id))
.checked(row.rule.notify)
.small()
.on_click(cx.listener(move |this, checked: &bool, _, cx| {
let notify = *checked;
this.edit(id, |rule| rule.notify = notify, cx);
})),
),
)
.child(
div().w_6().flex_shrink_0().child(
Button::new(("delete-highlight-rule", id))
.ghost()
.xsmall()
.icon(Icon::new(CatalogIcon::Trash))
.tooltip("删除")
.on_click(cx.listener(move |this, _, _, cx| this.remove(id, cx))),
),
)
}
}
/// Which of a row's two color controls the user changed.
#[derive(Clone, Copy)]
enum ColorSource {
Hex,
Picker,
}
impl Render for HighlightRulesEditor {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let table = if self.rows.is_empty() {
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("还没有规则")
.into_any_element()
} else {
let rows: Vec<_> = self
.rows
.iter()
.enumerate()
.map(|(ix, row)| self.render_row(ix, row, cx).into_any_element())
.collect();
v_flex()
.w_full()
.child(self.render_header(cx))
.children(rows)
.into_any_element()
};
v_flex()
.id("highlight-rules")
.test_support()
.w_full()
.gap_3()
.child(self.render_toolbar(cx))
.child(table)
}
}
#[cfg(test)]
mod tests {
use gpui_kit::component::Colorize as _;
use super::FEATURED_COLORS;
#[test]
fn featured_colors_are_each_one_swatch() {
// The picker keys its swatches by hex.
let mut hexes: Vec<_> = FEATURED_COLORS
.iter()
.map(|color| color.hsla().to_hex())
.collect();
hexes.sort();
hexes.dedup();
assert_eq!(hexes.len(), FEATURED_COLORS.len());
}
}
+1 -2
View File
@@ -1,13 +1,12 @@
//! 设置: the settings tab, the settings it edits and how they are applied.
mod apply;
mod highlight_dialog;
mod highlight_rules;
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, TerminalHighlightSettings, UpdateSettings,
+28 -31
View File
@@ -32,12 +32,15 @@ pub struct AppSettings {
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.
/// 关键字高亮: whether terminals color their text by the rules, and the
/// rules, in the order they win where they overlap. Off until the user turns
/// it on. A file without the rules gets the examples a new installation
/// starts with, ready once it is on; one the user emptied stays empty.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct TerminalHighlightSettings {
/// 启用关键字高亮. Off, no rule colors anything or notifies.
pub enabled: bool,
#[serde(deserialize_with = "readable_rules")]
pub rules: Vec<HighlightRule>,
}
@@ -45,6 +48,7 @@ pub struct TerminalHighlightSettings {
impl Default for TerminalHighlightSettings {
fn default() -> Self {
Self {
enabled: false,
rules: default_rules(),
}
}
@@ -133,10 +137,6 @@ 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
}
}
@@ -353,57 +353,54 @@ mod tests {
#[test]
fn highlight_rules_start_as_examples_and_stay_as_the_user_leaves_them() {
// Off, with the examples each on and quiet, ready for when it is.
let settings: AppSettings = serde_json::from_str("{}").unwrap();
assert!(!settings.terminal_highlight.enabled);
assert_eq!(settings.terminal_highlight.rules, default_rules());
assert!(
default_rules()
.iter()
.all(|rule| rule.enabled && !rule.notify)
);
let settings: AppSettings =
serde_json::from_str(r#"{"terminal_highlight":{"rules":[]}}"#).unwrap();
serde_json::from_str(r#"{"terminal_highlight":{"enabled":true,"rules":[]}}"#).unwrap();
assert!(settings.terminal_highlight.enabled);
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!({
"enabled": true,
"pattern": "ERROR",
"kind": "keyword",
"color": "red",
"bold": true,
"note": "错误",
"color": "#e5484d",
"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#"{
r##"{
"appearance": "dark",
"terminal_highlight": {"rules": [
{"pattern": "FATAL", "color": "orange"},
{"pattern": "OOM", "color": "magenta", "notify": true},
{"pattern": "OOM", "color": "#D946EF", "notify": true},
{"pattern": ""},
7
]}
}"#,
}"##,
)
.unwrap();
assert_eq!(settings.appearance, Appearance::Dark);
let rules = &settings.terminal_highlight.rules;
assert_eq!(rules.len(), 1);
assert_eq!(rules.len(), 2);
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"]
);
assert_eq!(rules[0].color.to_hex(), "#d946ef");
assert!(rules[0].enabled && rules[0].notify);
// A row added and not filled in yet stays, to be filled in.
assert_eq!(rules[1], HighlightRule::default());
}
#[test]
+94 -164
View File
@@ -11,24 +11,23 @@ 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, DeleteHighlightRule,
DownloadUpdate, EditHighlightRule, InstallAgentSkill, InstallCliCommand, NewHighlightRule,
OpenDownloadPage, RefreshCliIntegration, RemoveAgentSkill, RemoveCliCommand, ShowUpdate,
CatalogIcon, CenterTab, CheckForUpdates, CloseSettings, CopyAgentSkill, DownloadUpdate,
InstallAgentSkill, InstallCliCommand, 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, HighlightSet, LINE_HEIGHT_RANGE, PatternKind, TerminalFontPreview,
TerminalHighlights, is_font_installed, monospace_font_families,
FONT_SIZE_RANGE, LINE_HEIGHT_RANGE, TerminalFont, TerminalFontPreview, TerminalHighlights,
is_font_installed, monospace_font_families,
};
use crate::update::{Phase, Tone, UpdateSnapshot, UpdateStep, Updater, build_info, platform};
use super::highlight_rules::HighlightRulesEditor;
use super::{AppSettings, Choice, NotificationSettings, SettingsStore};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -50,6 +49,9 @@ pub struct SettingsPanel {
integration: Entity<CliIntegration>,
/// Where updating stands, for 关于.
updater: Entity<Updater>,
/// The rule table of 关键字高亮, whose fields keep their state across
/// the page's renders.
highlight_rules: Entity<HighlightRulesEditor>,
/// The monospace families to choose the terminal font from; empty until
/// the scan in the background comes back.
font_families: &'static [SharedString],
@@ -64,8 +66,10 @@ impl SettingsPanel {
store: Entity<SettingsStore>,
integration: Entity<CliIntegration>,
updater: Entity<Updater>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let highlight_rules = cx.new(|cx| HighlightRulesEditor::new(store.clone(), window, cx));
let subscriptions = [
cx.observe(&store, |_, _, cx| cx.notify()),
cx.observe(&integration, |_, _, cx| cx.notify()),
@@ -87,6 +91,7 @@ impl SettingsPanel {
store,
integration,
updater,
highlight_rules,
font_families: &[],
focus_handle: cx.focus_handle(),
tab_group: None,
@@ -364,173 +369,98 @@ 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.
/// What the 关键字高亮 preview shows: a service's log followed on a server,
/// with what the example rules color (levels and addresses) and lines they
/// leave alone.
const HIGHLIGHT_PREVIEW_LINES: &[&str] = &[
"root@web-01:~# tail -f /var/log/orders/app.log",
"09:12:03 INFO orders listening on 0.0.0.0:8080",
"09:12:41 WARN slow query: 2.31s on table order_items",
"09:13:07 ERROR upstream 10.20.3.15:5432 refused connection",
"09:13:08 INFO retrying in 5s (attempt 2/5)",
];
/// 关键字高亮: the switch, a preview of the rules in the terminal's font and
/// colors, and the rules.
fn highlight_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
let (reader, writer) = (panel.store.clone(), panel.store.clone());
let count = panel
.store
.read(cx)
.settings()
.terminal_highlight
.rules
.len();
let editor = panel.highlight_rules.clone();
vec![
SettingGroup::new()
.title("规则")
.description(
"匹配的文字在所有终端里按所选样式显示:只改显示,不改服务器的输出,\
复制出的文字也不变。几条规则匹配到同一处时,靠前的优先。\
全屏程序(vim、less、tmux 等)里不高亮。",
SettingGroup::new().title("常规").items([SettingItem::new(
"启用关键字高亮",
SettingField::switch(
move |cx| reader.read(cx).settings().terminal_highlight.enabled,
move |enabled, cx| {
writer.update(cx, |store, cx| {
store.update(|settings| settings.terminal_highlight.enabled = enabled, cx)
});
},
)
.items([highlight_rules_item(
TerminalHighlights::current(cx),
panel.focus_handle.clone(),
)]),
.default_value(true),
)
.description("全屏程序(vim、less、tmux 等)里不高亮。")]),
SettingGroup::new()
.title("预览")
.description("使用当前终端主题")
.items([highlight_preview_item()]),
SettingGroup::new()
.title(format!("规则({count})"))
.description("规则按从上到下应用,勾选「通知」的规则在看不到终端时弹出提醒。")
.items([
SettingItem::render(move |_, _, _| editor.clone()).keywords([
"规则",
"正则",
"关键字",
"高亮",
"颜色",
"备注",
"通知",
"排序",
]),
]),
]
}
/// 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")
/// The preview: sample output in the terminal's font and colors, colored by
/// the rules in effect, as a terminal would show it. The group's outline is
/// its frame.
fn highlight_preview_item() -> SettingItem {
SettingItem::render(|_, window, cx| {
let rules = TerminalHighlights::current(cx);
let font = TerminalFont::current(cx);
div()
.id("highlight-preview")
.test_support()
.w_full()
.gap_3()
.child(if rules.rules().is_empty() {
.bg(cx.theme().background)
.text_color(cx.theme().foreground)
.font_family(font.family(cx))
.text_size(font.size)
.line_height(font.row_height(window))
.overflow_hidden()
.children(HIGHLIGHT_PREVIEW_LINES.iter().map(|line| {
let highlights = rules.spans(line).into_iter().map(|(range, color)| {
(
range,
HighlightStyle {
color: Some(color.hsla()),
..HighlightStyle::default()
},
)
});
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)
}),
),
)
.whitespace_nowrap()
.child(StyledText::new(*line).with_highlights(highlights))
}))
})
.keywords(["高亮", "关键字", "正则", "规则", "颜色", "通知"])
.keywords(["预览", "高亮"])
}
/// 外部 CLI: whether the `shellrs` command may use the saved hosts, the
-20
View File
@@ -32,9 +32,6 @@ 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>,
@@ -45,7 +42,6 @@ impl Segment {
Self {
label: label.into(),
count: None,
swatch: None,
disabled: false,
tooltip: None,
}
@@ -57,12 +53,6 @@ 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
@@ -203,16 +193,6 @@ 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(
+5 -6
View File
@@ -23,7 +23,7 @@ use crate::connection::{ConnectionPrompt, ConnectionPromptReply, Latency};
use crate::host::HostOs;
use super::highlight::{
HighlightStyle, KeywordHit, LineWatcher, SharedHighlights, TerminalHighlights,
HighlightColor, KeywordHit, LineWatcher, SharedHighlights, TerminalHighlights,
advance_watching, current,
};
use super::links::{LinkMarker, url_search, visible_links};
@@ -46,8 +46,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>,
/// The color a 关键字高亮 rule shows the cell in, when one matches it.
pub highlight: Option<HighlightColor>,
}
#[derive(Clone, Debug)]
@@ -224,8 +224,7 @@ impl TerminalEngine {
}
TerminalUiEventKind::Keyword(hit) => {
return Some(TerminalEvent::Notice(TerminalNotice::keyword(
&hit.pattern,
&hit.line,
&hit.rule, &hit.line,
)));
}
}
@@ -537,7 +536,7 @@ impl TerminalEngine {
}),
search: marker.mark(indexed.point),
link: links.mark(indexed.point, indexed.cell.hyperlink()),
highlight: highlights.style(
highlight: highlights.color(
(indexed.point.line.0 + display_offset) as usize,
indexed.point.column.0,
),
+288 -233
View File
@@ -26,9 +26,8 @@ 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 gpui_kit::{App, Global, Hsla, Rgba};
use regex::Regex;
use serde::{Deserialize, Serialize};
use super::search::MAX_WRAPPED_LINES;
@@ -43,85 +42,90 @@ 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)]
/// One rule: a regular expression, the color its matches show in, and
/// whether to tell of them.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct HighlightRule {
pub enabled: bool,
/// A regular expression, case and all; `(?i)` ignores case. Empty in a
/// row just added, when it matches nothing.
pub pattern: String,
pub kind: PatternKind,
/// What the rule is for, in the user's words; a notification's title.
pub note: String,
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,
impl Default for HighlightRule {
fn default() -> Self {
Self {
enabled: true,
pattern: String::new(),
note: String::new(),
color: HighlightColor::RED,
notify: false,
}
}
}
/// 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,
}
/// The color a rule shows its matches in, as the user picked it: data, not
/// a theme role, so it is the same in a light theme and a dark one. Written
/// `#rrggbb` in the settings file.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct HighlightColor(u32);
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,
];
/// A new rule's color, and the examples'.
pub const RED: Self = Self(0xe5484d);
pub const AMBER: Self = Self(0xf5b51c);
pub const BLUE: Self = Self(0x2d8ce6);
pub fn label(self) -> &'static str {
match self {
Self::Red => "红",
Self::Yellow => "黄",
Self::Green => "绿",
Self::Cyan => "青",
Self::Blue => "蓝",
Self::Magenta => "紫",
}
/// `0xrrggbb`.
pub const fn from_rgb(rgb: u32) -> Self {
Self(rgb & 0xffffff)
}
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,
}
/// `#rrggbb` (or `rrggbb`), in either case.
pub fn from_hex(text: &str) -> Option<Self> {
let digits = text.trim().strip_prefix('#').unwrap_or(text.trim());
(digits.len() == 6 && digits.chars().all(|digit| digit.is_ascii_hexdigit()))
.then(|| u32::from_str_radix(digits, 16).ok().map(Self))
.flatten()
}
/// `#rrggbb`, in lower case.
pub fn to_hex(self) -> String {
format!("#{:06x}", self.0)
}
/// The nearest color to `color` this can hold: its alpha is dropped.
pub fn from_hsla(color: Hsla) -> Self {
let Rgba { r, g, b, .. } = color.to_rgb();
let channel = |value: f32| (value.clamp(0., 1.) * 255.).round() as u32;
Self((channel(r) << 16) | (channel(g) << 8) | channel(b))
}
pub fn hsla(self) -> Hsla {
gpui_kit::rgb(self.0).into()
}
}
/// How a match shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HighlightStyle {
pub color: HighlightColor,
pub bold: bool,
impl TryFrom<String> for HighlightColor {
type Error = String;
fn try_from(text: String) -> Result<Self, String> {
Self::from_hex(&text).ok_or_else(|| format!("not a #rrggbb color: {text}"))
}
}
impl From<HighlightColor> for String {
fn from(color: HighlightColor) -> Self {
color.to_hex()
}
}
/// The rules a new installation starts with, none of them notifying.
@@ -129,55 +133,49 @@ pub fn default_rules() -> Vec<HighlightRule> {
vec![
HighlightRule {
pattern: "ERROR".into(),
kind: PatternKind::Keyword,
color: HighlightColor::Red,
bold: true,
notify: false,
note: "错误".into(),
..HighlightRule::default()
},
HighlightRule {
pattern: "WARN".into(),
kind: PatternKind::Keyword,
color: HighlightColor::Yellow,
bold: false,
notify: false,
note: "警告".into(),
color: HighlightColor::AMBER,
..HighlightRule::default()
},
HighlightRule {
pattern: r"\b\d{1,3}(\.\d{1,3}){3}\b".into(),
kind: PatternKind::Regex,
color: HighlightColor::Blue,
bold: false,
notify: false,
note: "IPv4 地址".into(),
color: HighlightColor::BLUE,
..HighlightRule::default()
},
]
}
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("请输入要匹配的内容");
/// The pattern, ready to match: `None` while it is empty, and so
/// matches nothing; or why it cannot match, as the rule's row says it.
pub fn compile(&self) -> Result<Option<Regex>, &'static str> {
if self.pattern.is_empty() {
return Ok(None);
}
if self.pattern.chars().count() > PATTERN_LIMIT {
return Err("内容不能超过 500 个字符");
return Err("不能超过 500 个字符");
}
Regex::new(&self.pattern)
.map(Some)
.map_err(|error| match error {
regex::Error::CompiledTooBig(_) => "正则表达式过于复杂",
_ => "正则表达式写法有误",
})
}
/// What a notification calls the rule: its note, or its pattern.
fn title(&self) -> &str {
if self.note.trim().is_empty() {
&self.pattern
} else {
&self.note
}
let built = match self.kind {
PatternKind::Keyword => RegexBuilder::new(&regex::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(_) => "正则表达式过于复杂",
_ => "正则表达式写法有误",
})
}
}
@@ -187,12 +185,14 @@ 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.
/// The rules in effect, compiled once for every terminal: none while
/// 启用关键字高亮 is off. A rule turned off, empty or that does not compile
/// (only a hand-edited settings file has one) is left out.
#[derive(Debug, Default)]
pub struct HighlightSet {
enabled: bool,
rules: Vec<HighlightRule>,
compiled: Vec<Result<Regex, &'static str>>,
compiled: Vec<Option<Regex>>,
}
// Shared by the UI thread and every terminal's parser thread.
@@ -202,19 +202,27 @@ const _: fn() = || {
};
impl HighlightSet {
pub fn new(rules: Vec<HighlightRule>) -> Self {
let compiled = rules.iter().map(HighlightRule::compile).collect();
Self { rules, compiled }
pub fn new(enabled: bool, rules: Vec<HighlightRule>) -> Self {
let compiled = rules
.iter()
.map(|rule| {
if enabled && rule.enabled {
rule.compile().ok().flatten()
} else {
None
}
})
.collect();
Self {
enabled,
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 this was made from `rules` with highlighting `enabled`.
pub fn is_made_of(&self, enabled: bool, rules: &[HighlightRule]) -> bool {
self.enabled == enabled && self.rules == rules
}
/// Whether any rule tells of the lines it matches.
@@ -222,33 +230,59 @@ impl HighlightSet {
self.notifying().next().is_some()
}
/// The rules that compiled, by their index.
/// The rules that match, 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()?)))
.filter_map(|(ix, (rule, compiled))| Some((ix, rule, compiled.as_ref()?)))
}
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`
/// The runs of `text` the rules color, in order, by byte range. Where
/// rules overlap, the earlier one keeps its color.
pub fn spans(&self, text: &str) -> Vec<(Range<usize>, HighlightColor)> {
if self.active().next().is_none() {
return Vec::new();
}
let mut colors = vec![None; text.len()];
// The last rule first, so an earlier one paints over it.
for (_, rule, regex) in self.active().rev() {
for found in regex.find_iter(text) {
colors[found.range()].fill(Some(rule.color));
}
}
let mut spans: Vec<(Range<usize>, HighlightColor)> = Vec::new();
for (offset, color) in colors.into_iter().enumerate() {
let Some(color) = color else { continue };
match spans.last_mut() {
Some((range, last)) if range.end == offset && *last == color => {
range.end = offset + 1;
}
_ => spans.push((offset..offset + 1, color)),
}
}
spans
}
/// The color of each cell in view. 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(),
colors: Vec::new(),
};
if self.active().next().is_none() || term.mode().contains(TermMode::ALT_SCREEN) {
return screen;
}
screen.styles = vec![None; columns * rows];
screen.colors = 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
@@ -257,12 +291,8 @@ impl HighlightSet {
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);
}
for (span, color) in self.spans(&text.text) {
screen.mark(&text, span, color, top);
}
line = Line(end.0 + 1);
}
@@ -278,8 +308,8 @@ pub struct TerminalHighlights(Arc<HighlightSet>);
impl Global for TerminalHighlights {}
impl TerminalHighlights {
pub fn new(rules: Vec<HighlightRule>) -> Self {
Self(Arc::new(HighlightSet::new(rules)))
pub fn new(enabled: bool, rules: Vec<HighlightRule>) -> Self {
Self(Arc::new(HighlightSet::new(enabled, rules)))
}
/// The rules in effect; none before settings are applied.
@@ -305,24 +335,24 @@ pub(super) fn current(shared: &SharedHighlights) -> Arc<HighlightSet> {
.clone()
}
/// How each cell in view shows, by its row on screen and column.
/// The color of each cell in view, by its row on screen and column.
pub struct ScreenHighlights {
columns: usize,
/// Row by row; empty when nothing is highlighted.
styles: Vec<Option<HighlightStyle>>,
colors: Vec<Option<HighlightColor>>,
}
impl ScreenHighlights {
pub fn style(&self, row: usize, column: usize) -> Option<HighlightStyle> {
self.styles
pub fn color(&self, row: usize, column: usize) -> Option<HighlightColor> {
self.colors
.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) {
/// Show the characters of `text` in the byte range `found` in `color`,
/// those in view: the view's top row is `top`.
fn mark(&mut self, text: &LineText, found: Range<usize>, color: HighlightColor, 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] {
@@ -332,8 +362,8 @@ impl ScreenHighlights {
if row < 0 || column >= self.columns {
continue;
}
if let Some(slot) = self.styles.get_mut(row as usize * self.columns + column) {
*slot = Some(style);
if let Some(slot) = self.colors.get_mut(row as usize * self.columns + column) {
*slot = Some(color);
}
}
}
@@ -464,10 +494,11 @@ pub(super) fn advance_watching<T: EventListener>(
finished
}
/// A finished line of new output that a notifying rule matched.
/// A finished line of new output that a notifying rule matched: the rule,
/// by its note or pattern, and the line.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeywordHit {
pub pattern: String,
pub rule: String,
pub line: String,
}
@@ -521,7 +552,7 @@ impl LineWatcher {
}
self.told.push_back((ix, line.clone(), now));
return Some(KeywordHit {
pattern: rule.pattern.clone(),
rule: rule.title().to_string(),
line,
});
}
@@ -539,7 +570,11 @@ mod tests {
use super::*;
use crate::terminal::TerminalSize;
fn keyword(pattern: &str, color: HighlightColor) -> HighlightRule {
const RED: HighlightColor = HighlightColor(0xff0000);
const AMBER: HighlightColor = HighlightColor(0xffaa00);
const BLUE: HighlightColor = HighlightColor(0x0000ff);
fn rule(pattern: &str, color: HighlightColor) -> HighlightRule {
HighlightRule {
pattern: pattern.into(),
color,
@@ -547,13 +582,6 @@ mod tests {
}
}
fn regex(pattern: &str, color: HighlightColor) -> HighlightRule {
HighlightRule {
kind: PatternKind::Regex,
..keyword(pattern, color)
}
}
fn notifying(rule: HighlightRule) -> HighlightRule {
HighlightRule {
notify: true,
@@ -561,6 +589,10 @@ mod tests {
}
}
fn set(rules: Vec<HighlightRule>) -> HighlightSet {
HighlightSet::new(true, rules)
}
/// A terminal fed by one parser, as a terminal's parser thread feeds it.
struct Screen {
term: Term<VoidListener>,
@@ -597,7 +629,7 @@ mod tests {
/// 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 screen = set(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;
@@ -605,11 +637,10 @@ mod tests {
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));
match screen.color(row, column) {
Some(color) => {
if !open || runs.last().is_some_and(|(_, last)| *last != color) {
runs.push((String::new(), color));
}
open = true;
if !cell.flags.contains(Flags::WIDE_CHAR_SPACER) {
@@ -631,46 +662,77 @@ mod tests {
.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"));
fn colors_are_written_as_hex() {
assert_eq!(
HighlightColor::from_hex("#D0021B"),
Some(HighlightColor(0xd0021b))
);
assert_eq!(
HighlightColor::from_hex("f59e0b"),
Some(HighlightColor(0xf59e0b))
);
assert_eq!(HighlightColor(0xd0021b).to_hex(), "#d0021b");
for text in ["", "#fff", "#12345g", "#1234567"] {
assert_eq!(HighlightColor::from_hex(text), None, "{text}");
}
let color = HighlightColor(0x3b82f6);
assert_eq!(HighlightColor::from_hsla(color.hsla()), color);
}
#[test]
fn a_rule_that_cannot_match_says_why() {
fn a_pattern_is_a_regular_expression_as_written() {
let compile = |pattern: &str| rule(pattern, RED).compile();
let regex = compile("a.b").unwrap().unwrap();
assert!(regex.is_match("axb"));
assert!(!compile("error").unwrap().unwrap().is_match("ERROR"));
assert!(compile("(?i)error").unwrap().unwrap().is_match("ERROR"));
let address = default_rules().remove(2).compile().unwrap().unwrap();
assert!(address.is_match("from 10.0.0.12 port 22"));
assert!(!address.is_match("1234.5.6.7"));
// A row just added matches nothing, and says nothing of it.
assert!(matches!(compile(""), Ok(None)));
assert_eq!(compile("(ERROR").err(), Some("正则表达式写法有误"));
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(),
compile(r"(\w{1000}){1000}").err(),
Some("正则表达式过于复杂")
);
let long = "x".repeat(PATTERN_LIMIT + 1);
assert_eq!(
keyword(&long, Red).compile().err(),
Some("内容不能超过 500 个字符")
assert_eq!(compile(&long).err(), Some("不能超过 500 个字符"));
}
#[test]
fn rules_turned_off_or_unfinished_match_nothing() {
let rules = vec![
HighlightRule {
enabled: false,
..rule("ERROR", RED)
},
rule("", AMBER),
rule("(", AMBER),
rule("WARN", BLUE),
];
assert_eq!(set(rules.clone()).spans("ERROR WARN"), [(6..10, BLUE)]);
// 启用关键字高亮 off, nothing at all.
assert!(
HighlightSet::new(false, rules)
.spans("ERROR WARN")
.is_empty()
);
}
#[test]
fn spans_keep_the_earlier_rule_where_rules_overlap() {
let rules = set(vec![rule("Memory", BLUE), rule(r"\w+Error", RED)]);
assert_eq!(
rules.spans("OutOfMemoryError!"),
[(0..5, RED), (5..11, BLUE), (11..16, RED)]
);
// Byte ranges, whole characters.
assert_eq!(
set(vec![rule("错误", RED)]).spans("连接错误"),
[(6..12, RED)]
);
let set = HighlightSet::new(vec![regex("(", Red), keyword("ok", Red)]);
assert_eq!(set.error(0), Some("正则表达式写法有误"));
assert_eq!(set.error(1), None);
}
#[test]
@@ -678,14 +740,14 @@ mod tests {
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)])
screen.marks(vec![rule("ERROR", RED), rule("WARN", AMBER)]),
runs(&[("ERROR", RED), ("WARN", AMBER)])
);
// 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)])
screen.marks(vec![rule("ERROR", RED)]),
runs(&[("ERROR", RED), ("ERROR", RED)])
);
}
@@ -694,22 +756,19 @@ mod tests {
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)])
screen.marks(vec![rule("^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)])
screen.marks(vec![rule("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()
screen.marks(vec![rule(r"^\d+", BLUE)]),
runs(&[("2024", BLUE)])
);
assert!(screen.marks(vec![rule(r"\bRR", RED)]).is_empty());
}
#[test]
@@ -717,18 +776,18 @@ mod tests {
// `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)]));
let rules = || vec![rule("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)]));
assert_eq!(screen.marks(rules()), runs(&[("RROR", RED)]));
screen.term.scroll_display(Scroll::Delta(1));
assert_eq!(screen.marks(rules()), runs(&[("ERROR", Red)]));
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)]));
assert_eq!(screen.marks(rules()), runs(&[("ERROR", RED)]));
}
#[test]
@@ -736,12 +795,12 @@ mod tests {
// `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());
assert!(screen.marks(vec![rule("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)])
screen.marks(vec![rule("ERROR", RED)]),
runs(&[("RROR", RED)])
);
}
@@ -750,18 +809,8 @@ mod tests {
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)])
screen.marks(vec![rule("错误", RED)]),
runs(&[("错误", RED)])
);
}
@@ -769,12 +818,12 @@ mod tests {
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());
assert!(screen.marks(vec![rule("ERROR", RED)]).is_empty());
screen.feed("\x1b[?1049l");
screen.feed("ERROR");
assert_eq!(
screen.marks(vec![keyword("ERROR", Red)]),
runs(&[("ERROR", Red)])
screen.marks(vec![rule("ERROR", RED)]),
runs(&[("ERROR", RED)])
);
}
@@ -811,19 +860,23 @@ mod tests {
#[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 rules = Arc::new(set(vec![
rule("WARN", AMBER),
notifying(HighlightRule {
note: "错误".into(),
..rule("ERROR", RED)
}),
]));
let mut watcher = LineWatcher::new(set);
let mut watcher = LineWatcher::new(rules);
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);
// Named by its note.
assert_eq!(
watcher.check(lines(&["ok", "1 ERROR a", "2 ERROR b"]), start),
Some(KeywordHit {
pattern: "ERROR".into(),
rule: "错误".into(),
line: "1 ERROR a".into()
})
);
@@ -832,19 +885,21 @@ mod tests {
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)])));
// New rules start afresh; without a note, the pattern names it.
watcher.sync(Arc::new(set(vec![notifying(rule("ERROR", RED))])));
assert_eq!(
watcher
.check(lines(&["1 ERROR a"]), later)
.map(|hit| hit.rule),
Some("ERROR".into())
);
watcher.sync(Arc::new(set(vec![rule("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);
let mut watcher = LineWatcher::new(Arc::new(set(vec![notifying(rule(r"\b", RED))])));
assert_eq!(
watcher.check(vec!["any words".into()], Instant::now()),
None
+1 -2
View File
@@ -22,8 +22,7 @@ pub use font::{
TerminalFontPreview, is_font_installed, monospace_font_families,
};
pub use highlight::{
HighlightColor, HighlightRule, HighlightSet, HighlightStyle, PATTERN_LIMIT, PatternKind,
TerminalHighlights, default_rules,
HighlightColor, HighlightRule, HighlightSet, TerminalHighlights, default_rules,
};
pub use local_pty::LocalPtyTransportFactory;
pub use local_terminal_panel::{LocalTerminalPanel, LocalTerminalPanelEvent};
+8 -8
View File
@@ -10,8 +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;
/// The longest rule name a notification's title has, in characters.
const RULE_LIMIT: usize = 40;
/// Something a terminal asks the user to look at.
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -22,8 +22,8 @@ pub enum TerminalNotice {
/// 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 },
/// rule, by its note or pattern, and the line.
Keyword { rule: String, line: String },
}
impl TerminalNotice {
@@ -34,10 +34,10 @@ impl TerminalNotice {
}
}
/// A rule with `pattern` matched `line`.
pub fn keyword(pattern: &str, line: &str) -> Self {
/// The rule called `rule` matched `line`.
pub fn keyword(rule: &str, line: &str) -> Self {
TerminalNotice::Keyword {
pattern: clean_text(pattern, PATTERN_LIMIT),
rule: clean_text(rule, RULE_LIMIT),
line: clean_text(line, BODY_LIMIT),
}
}
@@ -207,7 +207,7 @@ mod tests {
assert_eq!(
TerminalNotice::keyword(" ERROR ", "12:00\tERROR\x1b boom"),
TerminalNotice::Keyword {
pattern: "ERROR".into(),
rule: "ERROR".into(),
line: "12:00 ERROR boom".into()
}
);
+2 -7
View File
@@ -1228,17 +1228,12 @@ impl Element for TerminalElement {
}
let character = painted_cell_text(&cell.character, 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 font = terminal_font(text_style.font(), cell.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);
let color = highlight.hsla();
if cell.flags.contains(Flags::DIM) {
color.opacity(0.66)
} else {
-84
View File
@@ -1,84 +0,0 @@
//! 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,
);
}
}
-1
View File
@@ -6,7 +6,6 @@ mod credentials;
mod dock_skin;
mod editors;
mod forwards;
mod highlights;
mod links;
mod notices;
mod recent_hosts;
+3 -3
View File
@@ -68,7 +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()),
TerminalNotice::Keyword { rule, line } => (format!("{tab}:{rule}"), line.clone()),
}
}
@@ -225,7 +225,7 @@ mod tests {
}
fn keyword() -> TerminalNotice {
TerminalNotice::keyword("ERROR", "12:00 ERROR boom")
TerminalNotice::keyword("错误", "12:00 ERROR boom")
}
#[test]
@@ -265,7 +265,7 @@ mod tests {
);
assert_eq!(
notice_text("web-01", &keyword()),
("web-01:ERROR".to_string(), "12:00 ERROR boom".to_string())
("web-01:错误".to_string(), "12:00 ERROR boom".to_string())
);
}
+1 -4
View File
@@ -1140,7 +1140,7 @@ impl Workspace {
// Something may have been installed or removed since last time.
integration.update(cx, |integration, cx| integration.refresh(cx));
let updater = self.updater.clone();
let panel = cx.new(|cx| SettingsPanel::new(store, integration, updater, cx));
let panel = cx.new(|cx| SettingsPanel::new(store, integration, updater, window, cx));
let subscription = cx.subscribe_in(
&panel,
window,
@@ -2470,9 +2470,6 @@ 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))
+163 -105
View File
@@ -318,70 +318,12 @@ 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| {
/// The 关键字高亮 rules, as the settings hold them.
fn highlight_rules(
workspace: &Entity<Workspace>,
cx: &mut TestAppContext,
) -> Vec<shellrs::terminal::HighlightRule> {
cx.update(|cx| {
workspace
.read(cx)
.settings()
@@ -389,50 +331,166 @@ async fn highlight_rules_are_added_edited_and_deleted_on_their_page(cx: &mut Tes
.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");
fn patterns(rules: &[shellrs::terminal::HighlightRule]) -> Vec<&str> {
rules.iter().map(|rule| rule.pattern.as_str()).collect()
}
// Deleted once confirmed; the terminals follow.
/// Scroll the settings page down to the rules, below the window's fold.
fn scroll_to_highlight_rules(cx: &mut TestAppContext, handle: WindowHandle<Root>) {
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 position = window.find("highlight-preview").bounds().center();
window.dispatch_event(
gpui_kit::ScrollWheelEvent {
position,
delta: gpui_kit::ScrollDelta::Pixels(point(px(0.), px(-600.))),
modifiers: gpui_kit::Modifiers::default(),
touch_phase: gpui_kit::TouchPhase::Moved,
}
.to_platform_input(),
cx,
);
});
}
#[gpui_kit::test]
async fn highlight_rules_are_edited_in_place_on_their_page(cx: &mut TestAppContext) {
use shellrs::terminal::{HighlightColor, 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: rows 0 to 2.
in_frame(cx, handle, |window, cx| {
window.within("settings").click("0-2", cx)
});
let ip = r"\b\d{1,3}(\.\d{1,3}){3}\b";
assert_eq!(
patterns(&highlight_rules(&workspace, cx)),
["ERROR", "WARN", ip]
);
in_frame(cx, handle, |window, _| {
assert!(window.find(("highlight-rule", 2usize)).visible());
assert!(window.find("highlight-preview").visible());
});
// Off until turned on: the examples color nothing before.
let colored = |cx: &mut TestAppContext| {
cx.update(|cx| !TerminalHighlights::current(cx).spans("ERROR").is_empty())
};
assert!(!colored(cx));
in_frame(cx, handle, |window, cx| {
// 常规 › 启用关键字高亮.
let switch = window
.within("settings")
.within("group-0")
.within("item-0")
.find("check");
assert_eq!(switch.checked(), Some(false));
window
.within("settings")
.within("group-0")
.within("item-0")
.click("check", cx);
});
cx.run_until_parked();
assert!(colored(cx));
// A new row takes the keyboard at once; what does not compile says so
// under it, and the settings have it as typed.
in_frame(cx, handle, |window, cx| {
window.click("add-highlight-rule", cx)
});
scroll_to_highlight_rules(cx, handle);
in_frame(cx, handle, |window, cx| {
assert_eq!(
window.find(("highlight-rule-pattern", 3usize)).focused(),
Some(true)
);
window.input("OutOf(Memory", cx);
});
in_frame(cx, handle, |window, cx| {
assert_eq!(
window.find(("highlight-rule-error", 3usize)).label(),
Some("正则表达式写法有误")
);
window.press("cmd-a", cx);
window.input(r"OutOfMemory\w*", cx);
});
in_frame(cx, handle, |window, cx| {
assert!(window.try_find(("highlight-rule-error", 3usize)).is_none());
window.click(("highlight-rule-note", 3usize), cx);
window.input("内存溢出", cx);
window.click(("highlight-rule-color", 3usize), cx);
window.press("cmd-a", cx);
window.input("#D946EF", cx);
window.click(("highlight-rule-notify", 3usize), cx);
window.click(("highlight-rule-enabled", 0usize), cx);
});
cx.run_until_parked();
let rules = highlight_rules(&workspace, cx);
assert_eq!(rules[3].pattern, r"OutOfMemory\w*");
assert_eq!(rules[3].note, "内存溢出");
assert_eq!(rules[3].color, HighlightColor::from_hex("#d946ef").unwrap());
assert!(rules[3].enabled && rules[3].notify);
assert!(!rules[0].enabled);
// The color picker opens on its palette. (Its swatches are keyed by
// color; the a11y tree that would catch two of one color is not built
// in tests, see FEATURED_COLORS.)
in_frame(cx, handle, |window, cx| {
window.click(("highlight-rule-picker", 3usize), cx)
});
in_frame(cx, handle, |window, cx| window.press("escape", cx));
// Dragged by its handle, the new row goes first.
in_frame(cx, handle, |window, cx| {
window.drag_to(
("highlight-rule-handle", 3usize),
("highlight-rule", 0usize),
cx,
)
});
cx.run_until_parked();
assert_eq!(
patterns(&highlight_rules(&workspace, cx)),
[r"OutOfMemory\w*", "ERROR", "WARN", ip]
);
// Deleted at once, WARN goes; the terminals follow every change.
in_frame(cx, handle, |window, cx| {
window.click(("delete-highlight-rule", 1usize), cx)
});
cx.run_until_parked();
assert_eq!(
patterns(&highlight_rules(&workspace, cx)),
[r"OutOfMemory\w*", "ERROR", ip]
);
cx.update(|cx| {
let colored = TerminalHighlights::current(cx).spans("OutOfMemoryError ERROR 10.0.0.1");
// ERROR is off; the new rule and the address color.
assert_eq!(
colored
.iter()
.map(|(range, _)| range.clone())
.collect::<Vec<_>>(),
[0..16, 23..31]
);
});
// 启用关键字高亮 off, nothing colors.
let settings = cx.update(|cx| workspace.read(cx).settings().clone());
settings.update(cx, |settings, cx| {
settings.update(|settings| settings.terminal_highlight.enabled = false, cx)
});
cx.run_until_parked();
cx.update(|cx| {
assert!(
TerminalHighlights::current(cx)
.spans("OutOfMemoryError")
.is_empty()
);
let in_effect: Vec<_> = TerminalHighlights::current(cx)
.rules()
.iter()
.map(|rule| rule.pattern.clone())
.collect();
assert_eq!(in_effect, patterns);
});
}
+9 -2
View File
@@ -1001,7 +1001,8 @@ async fn terminals_notify_where_the_user_will_see_it(cx: &mut TestAppContext) {
});
}
/// Have the settings hold `rules`, as the 关键字高亮 page writes them.
/// Have the settings hold `rules`, as the 关键字高亮 page writes them, with
/// highlighting on.
fn set_highlight_rules(
cx: &mut TestAppContext,
workspace: &Entity<Workspace>,
@@ -1009,7 +1010,13 @@ fn set_highlight_rules(
) {
let settings = cx.update(|cx| workspace.read(cx).settings().clone());
settings.update(cx, |settings, cx| {
settings.update(|settings| settings.terminal_highlight.rules = rules, cx)
settings.update(
|settings| {
settings.terminal_highlight.enabled = true;
settings.terminal_highlight.rules = rules;
},
cx,
)
});
cx.run_until_parked();
}