关键字高亮设置改成可直接编辑的规则表,总开关默认关闭
- 设置页分三组:常规(启用关键字高亮,默认关闭)、预览(当前终端字体和 配色下的一段示例日志)、规则(N)。 - 规则表在行里直接改,不再弹对话框:拖动把手排序、启用、正则表达式、 备注、颜色(取色器加 #rrggbb 输入框)、通知、删除。规则都是正则,区分 大小写;颜色可任意取,不跟主题;去掉加粗。通知标题用备注。 - 预置 ERROR、WARN、IPv4 三条示例,各自启用、不通知,打开总开关就能用。 - 取色器自带一行与调色板不重复的常用色:gpui-kit 默认的常用色也在调色板 里,同色两格是重复的无障碍节点,调试版会因此崩溃。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7c9501e4cb
commit
2aa5d5be49
22 files changed
+1121
-968
No files matched your search
@@ -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
@@ -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 插件)直接进本机剪贴板。远程程序读不了本机剪贴板。
|
||||
|
||||
## 右侧栏
|
||||
|
||||
@@ -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
@@ -73,7 +73,8 @@ icon_assets!(
|
||||
ZoomOut,
|
||||
Zap,
|
||||
ExternalLink,
|
||||
Highlighter
|
||||
Highlighter,
|
||||
GripVertical
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
+10
-4
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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(¬e, 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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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(®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(_) => "正则表达式过于复杂",
|
||||
_ => "正则表达式写法有误",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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};
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ mod credentials;
|
||||
mod dock_skin;
|
||||
mod editors;
|
||||
mod forwards;
|
||||
mod highlights;
|
||||
mod links;
|
||||
mod notices;
|
||||
mod recent_hosts;
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user