mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
feat(terminal): richer buffer search — case/regex toggles, ⌘G, persistence
Bring the Cmd+F search bar up to par with mainstream terminals:
- Case toggle ("Aa"): smart-case stays the default (insensitive unless the
query has an uppercase char); pressing it forces case-sensitivity via a
`(?-i)` prefix.
- Regex toggle (".*"): the query is now matched literally by default
(metacharacters escaped), so searching for `.`/`*`/`(` behaves; toggle on
for a real regex pattern.
- Invalid-regex feedback: a pattern that fails to compile (only possible in
regex mode) turns the field border red instead of silently showing zero
matches.
- ⌘G / ⌘⇧G step to the next / previous match while the bar is open, alongside
Enter / Shift+Enter.
- Query + toggle state persist across close/reopen; opening prefills the field
from a single-line terminal selection.
- Navigation and live re-search only scroll when the focused match is
off-screen, so refining the query no longer jerks the viewport.
Also fix a click-through bug the new toggle buttons surfaced: the terminal
registers a pane-wide mouse handler, so a click on the floating search bar fell
through and started a text selection underneath. The bar now `.occlude()`s its
area and the terminal's mouse handlers gate on `Hitbox::is_hovered` (occlusion-
aware) instead of raw bounds containment.
Covered by a new end-to-end test that drives the real search path against a
seeded grid (case/regex toggles, invalid-regex flag, and persistence).
This commit is contained in:
+17
-7
@@ -11,7 +11,8 @@ use alacritty_terminal::term::cell::{Cell, Flags};
|
||||
use alacritty_terminal::vte::ansi::{Color as AnsiColor, NamedColor, Rgb};
|
||||
use gpui::{
|
||||
App, BorderStyle, Bounds, ContentMask, CursorStyle, Element, ElementId, Font, FontStyle,
|
||||
FontWeight, GlobalElementId, Hitbox, HitboxBehavior, Hsla, IntoElement, LayoutId, MouseButton,
|
||||
FontWeight, GlobalElementId, Hitbox, HitboxBehavior, HitboxId, Hsla, IntoElement, LayoutId,
|
||||
MouseButton,
|
||||
MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, Rgba, SharedString, Style,
|
||||
TextAlign, TextRun, Window, fill, outline, point, px, relative, size,
|
||||
};
|
||||
@@ -1165,10 +1166,19 @@ impl TerminalElement {
|
||||
/// Register the per-frame mouse listeners (press / drag / release) over our
|
||||
/// bounds, translating pixel positions to grid cells and routing to the view
|
||||
/// (selection, link opening, or mouse-tracking reports).
|
||||
fn register_mouse_handlers(&self, geom: CellGeom, bounds: Bounds<Pixels>, window: &mut Window) {
|
||||
fn register_mouse_handlers(
|
||||
&self,
|
||||
geom: CellGeom,
|
||||
bounds: Bounds<Pixels>,
|
||||
hitbox: HitboxId,
|
||||
window: &mut Window,
|
||||
) {
|
||||
let view = self.view.clone();
|
||||
window.on_mouse_event(move |ev: &MouseDownEvent, phase, _window, cx| {
|
||||
if !phase.bubble() || !bounds.contains(&ev.position) {
|
||||
window.on_mouse_event(move |ev: &MouseDownEvent, phase, window, cx| {
|
||||
// `is_hovered` (not `bounds.contains`) so a click on an overlay that
|
||||
// sits above the terminal — the Cmd+F search bar, which `.occlude()`s
|
||||
// its area — doesn't fall through and start a terminal selection.
|
||||
if !phase.bubble() || !hitbox.is_hovered(window) {
|
||||
return;
|
||||
}
|
||||
let (col, row, left) = geom.pos_to_cell(ev.position);
|
||||
@@ -1209,8 +1219,8 @@ impl TerminalElement {
|
||||
// local (button-less motion is never forwarded to the app), and
|
||||
// ⌘-click opens links inside mouse-mode TUIs as well, so the
|
||||
// underline affordance must match. Skipped only when the pointer
|
||||
// is outside our bounds.
|
||||
let inside = bounds.contains(&ev.position);
|
||||
// is outside our bounds (or under the search-bar overlay).
|
||||
let inside = hitbox.is_hovered(window);
|
||||
// Focus-follows-mouse: hovering an unfocused pane focuses it, no
|
||||
// click needed. Guarded on `inside` and the config flag.
|
||||
if inside && cx.global::<Config>().focus_follows_mouse {
|
||||
@@ -1539,7 +1549,7 @@ impl Element for TerminalElement {
|
||||
// Hand the snapshot buffer back for the next paint (any pane).
|
||||
GRID_BUF.with(|b| *b.borrow_mut() = buf);
|
||||
|
||||
self.register_mouse_handlers(geom, bounds, window);
|
||||
self.register_mouse_handlers(geom, bounds, prepaint.hitbox.id, window);
|
||||
|
||||
// A pointing-hand cursor over a hovered link reinforces that it's
|
||||
// clickable (Cmd+click opens it).
|
||||
|
||||
+199
-13
@@ -5,13 +5,17 @@
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use alacritty_terminal::event::EventListener;
|
||||
use alacritty_terminal::grid::Dimensions;
|
||||
use alacritty_terminal::index::{Boundary, Column, Direction, Line, Point, Side};
|
||||
use alacritty_terminal::term::Term;
|
||||
use alacritty_terminal::term::search::{Match, RegexSearch};
|
||||
use gpui::{Context, Entity, Subscription, Window, div, prelude::*, px};
|
||||
use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui_component::input::{Input, InputEvent, InputState};
|
||||
use gpui_component::{ActiveTheme as _, Disableable as _, IconName, Sizable as _, Size};
|
||||
use gpui_component::{
|
||||
ActiveTheme as _, Disableable as _, IconName, Selectable as _, Sizable as _, Size,
|
||||
};
|
||||
|
||||
use super::view::TerminalView;
|
||||
|
||||
@@ -67,8 +71,16 @@ impl TerminalView {
|
||||
pub fn open_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Build the field on first open (Cmd+F again just refocuses it). The
|
||||
// InputState owns the query text, caret, selection, Cmd+A and IME.
|
||||
if self.search.is_none() {
|
||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("Find"));
|
||||
let fresh = self.search.is_none();
|
||||
if fresh {
|
||||
// Seed the query: a single-line terminal selection is the strongest
|
||||
// signal of intent (select-then-⌘F), otherwise fall back to the last
|
||||
// query so reopening resumes where the user left off.
|
||||
let seed = self
|
||||
.selected_search_seed()
|
||||
.unwrap_or_else(|| self.search_last_query.clone());
|
||||
let input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("Find").default_value(seed));
|
||||
let subs = vec![cx.subscribe_in(&input, window, Self::on_search_event)];
|
||||
self.search = Some(SearchState {
|
||||
input,
|
||||
@@ -80,12 +92,35 @@ impl TerminalView {
|
||||
if let Some(input) = self.search.as_ref().map(|s| s.input.clone()) {
|
||||
input.update(cx, |state, cx| state.focus(window, cx));
|
||||
}
|
||||
// A freshly seeded (or restored) query has matches to compute right away;
|
||||
// Cmd+F on an already-open bar just refocuses and keeps the current list.
|
||||
if fresh {
|
||||
self.recompute_matches(cx);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The current terminal selection as a search seed: a non-empty, single-line
|
||||
/// selection with no newline. Multi-line selections aren't useful as a query.
|
||||
fn selected_search_seed(&self) -> Option<String> {
|
||||
let text = self.terminal.term.lock().selection_to_string()?;
|
||||
let trimmed = text.trim_matches(['\n', '\r']);
|
||||
if trimmed.is_empty() || trimmed.contains('\n') {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Remember the query so the next open resumes it (toggles persist on the
|
||||
// view already). Then tear down the field and any error state.
|
||||
if let Some(s) = self.search.as_ref() {
|
||||
self.search_last_query = s.input.read(cx).value().to_string();
|
||||
}
|
||||
self.search = None;
|
||||
self.search_focused = false;
|
||||
self.search_regex_error = false;
|
||||
self.terminal.term.lock().selection = None;
|
||||
// Return focus to the terminal so typing resumes feeding the PTY.
|
||||
window.focus(&self.focus_handle, cx);
|
||||
@@ -131,7 +166,7 @@ impl TerminalView {
|
||||
/// nearest the bottom of the viewport (mirroring the old "search up from the
|
||||
/// newest content" behavior), falling back to the first match, or `None`
|
||||
/// when there are no matches / the query is empty.
|
||||
fn recompute_matches(&mut self, cx: &mut Context<Self>) {
|
||||
pub(super) fn recompute_matches(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(query) = self
|
||||
.search
|
||||
.as_ref()
|
||||
@@ -142,9 +177,17 @@ impl TerminalView {
|
||||
|
||||
let mut matches: Vec<Match> = Vec::new();
|
||||
let mut current_index: Option<usize> = None;
|
||||
let mut regex_error = false;
|
||||
|
||||
if !query.is_empty() {
|
||||
if let Ok(mut regex) = RegexSearch::new(&query) {
|
||||
let pattern = self.effective_search_pattern(&query);
|
||||
let compiled = RegexSearch::new(&pattern);
|
||||
// A pattern only fails to compile in regex mode (a literal query is
|
||||
// escaped, and the `(?-i)` case prefix is always valid), so a failure
|
||||
// means the user typed a broken regex — flag it instead of silently
|
||||
// showing zero matches.
|
||||
regex_error = compiled.is_err();
|
||||
if let Ok(mut regex) = compiled {
|
||||
let term = self.terminal.term.lock();
|
||||
let grid = term.grid();
|
||||
let mut origin = Point::new(grid.topmost_line(), Column(0));
|
||||
@@ -194,13 +237,16 @@ impl TerminalView {
|
||||
s.matches = matches;
|
||||
s.current_index = current_index;
|
||||
}
|
||||
self.search_regex_error = regex_error;
|
||||
|
||||
// Clear any stray selection and bring the focused match into view.
|
||||
// Clear any stray selection and bring the focused match into view, but
|
||||
// only when it's off-screen so an in-viewport match doesn't jerk the
|
||||
// scroll position around as the user refines the query.
|
||||
let current = self.search.as_ref().and_then(|s| s.current().cloned());
|
||||
let mut term = self.terminal.term.lock();
|
||||
term.selection = None;
|
||||
if let Some(m) = current {
|
||||
term.scroll_to_point(*m.start());
|
||||
scroll_match_into_view(&mut term, &m);
|
||||
}
|
||||
drop(term);
|
||||
cx.notify();
|
||||
@@ -209,7 +255,7 @@ impl TerminalView {
|
||||
/// Move to the next (`Direction::Right`, toward the bottom) or previous
|
||||
/// (`Direction::Left`, toward the top) match, wrapping around, and scroll the
|
||||
/// new current match into view. Never recomputes the match list.
|
||||
fn step_match(&mut self, direction: Direction, cx: &mut Context<Self>) {
|
||||
pub(super) fn step_match(&mut self, direction: Direction, cx: &mut Context<Self>) {
|
||||
let current = {
|
||||
let Some(s) = self.search.as_mut() else {
|
||||
return;
|
||||
@@ -226,10 +272,50 @@ impl TerminalView {
|
||||
s.current_index = Some(next);
|
||||
s.matches[next].clone()
|
||||
};
|
||||
self.terminal.term.lock().scroll_to_point(*current.start());
|
||||
// Explicit navigation always reveals the target: unlike a live query
|
||||
// change, stepping past a match already on screen should still recenter
|
||||
// it if it sits off-screen, but leave the viewport alone when it's visible.
|
||||
scroll_match_into_view(&mut self.terminal.term.lock(), ¤t);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Toggle the "Aa" (force case-sensitive) option and re-search. Does nothing
|
||||
/// when the bar is closed.
|
||||
fn toggle_search_case(&mut self, cx: &mut Context<Self>) {
|
||||
if self.search.is_none() {
|
||||
return;
|
||||
}
|
||||
self.search_case_sensitive = !self.search_case_sensitive;
|
||||
self.recompute_matches(cx);
|
||||
}
|
||||
|
||||
/// Toggle the ".*" (regex vs literal) option and re-search.
|
||||
fn toggle_search_regex(&mut self, cx: &mut Context<Self>) {
|
||||
if self.search.is_none() {
|
||||
return;
|
||||
}
|
||||
self.search_regex = !self.search_regex;
|
||||
self.recompute_matches(cx);
|
||||
}
|
||||
|
||||
/// Turn the user's query into the pattern fed to alacritty's `RegexSearch`,
|
||||
/// applying the two toggles. In literal mode the query is regex-escaped so
|
||||
/// metacharacters (`.`, `*`, `(`, …) match themselves. A `(?-i)` prefix forces
|
||||
/// case sensitivity when "Aa" is on; when off, alacritty's smart-case default
|
||||
/// applies (insensitive unless the query already contains an uppercase char).
|
||||
fn effective_search_pattern(&self, query: &str) -> String {
|
||||
let base = if self.search_regex {
|
||||
query.to_string()
|
||||
} else {
|
||||
regex_escape(query)
|
||||
};
|
||||
if self.search_case_sensitive {
|
||||
format!("(?-i){base}")
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn render_search_bar(
|
||||
&self,
|
||||
state: &SearchState,
|
||||
@@ -243,14 +329,19 @@ impl TerminalView {
|
||||
let border = theme.border;
|
||||
let popover = theme.popover;
|
||||
let accent = theme.accent;
|
||||
let danger = theme.red;
|
||||
// (The `theme` borrow of `cx` ends here, before the `cx.listener` calls below.)
|
||||
|
||||
let total = state.matches.len();
|
||||
let has_query = !state.input.read(cx).value().is_empty();
|
||||
let has_matches = !state.matches.is_empty();
|
||||
// Highlight the border while the field is focused so the bar reads as the
|
||||
// active input. Caret/selection/IME all live inside the field itself.
|
||||
// active input. Caret/selection/IME all live inside the field itself. A
|
||||
// broken regex (only possible in regex mode) turns the border red instead.
|
||||
let focused = self.search_focused;
|
||||
let regex_error = self.search_regex_error;
|
||||
let case_on = self.search_case_sensitive;
|
||||
let regex_on = self.search_regex;
|
||||
|
||||
// The query field — a gpui-component InputState. It owns focus, the
|
||||
// blinking caret, text selection, Cmd+A, arrow keys and IME composition.
|
||||
@@ -274,6 +365,28 @@ impl TerminalView {
|
||||
.child(format!("{current}/{total}"))
|
||||
});
|
||||
|
||||
// Option toggles: "Aa" forces case-sensitive matching, ".*" switches the
|
||||
// query between literal and regex. Both read as pressed (accent fill) when
|
||||
// active and re-search on click.
|
||||
let case_toggle = Button::new("search-case")
|
||||
.label("Aa")
|
||||
.ghost()
|
||||
.small()
|
||||
.selected(case_on)
|
||||
.tooltip("Match case")
|
||||
.on_click(cx.listener(|this, _, _window, cx| {
|
||||
this.toggle_search_case(cx);
|
||||
}));
|
||||
let regex_toggle = Button::new("search-regex")
|
||||
.label(".*")
|
||||
.ghost()
|
||||
.small()
|
||||
.selected(regex_on)
|
||||
.tooltip("Use regular expression")
|
||||
.on_click(cx.listener(|this, _, _window, cx| {
|
||||
this.toggle_search_regex(cx);
|
||||
}));
|
||||
|
||||
// Thin rule separating the query zone from the action buttons.
|
||||
let divider = div().flex_none().w(px(1.)).h(px(16.)).bg(border);
|
||||
|
||||
@@ -308,21 +421,35 @@ impl TerminalView {
|
||||
.absolute()
|
||||
.top_2()
|
||||
.right_4()
|
||||
// Block mouse events over the bar so a click (or drag) on it doesn't
|
||||
// fall through to the terminal surface and start a selection — the
|
||||
// terminal's mouse handlers gate on `Hitbox::is_hovered`, which this
|
||||
// occluding hitbox turns off for the cells beneath the bar.
|
||||
.occlude()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.w(px(340.))
|
||||
.w(px(400.))
|
||||
.h(px(34.))
|
||||
.pl_3()
|
||||
.pr_1()
|
||||
.rounded_lg()
|
||||
.border_1()
|
||||
.border_color(if focused { accent } else { border })
|
||||
.border_color(if regex_error {
|
||||
danger
|
||||
} else if focused {
|
||||
accent
|
||||
} else {
|
||||
border
|
||||
})
|
||||
.bg(popover)
|
||||
.shadow_md()
|
||||
// The field fills the remaining width; count + buttons keep fixed size.
|
||||
// The field fills the remaining width; count + toggles + buttons keep
|
||||
// fixed size.
|
||||
.child(div().flex_1().min_w_0().child(field))
|
||||
.children(count)
|
||||
.child(case_toggle)
|
||||
.child(regex_toggle)
|
||||
.child(divider)
|
||||
.child(prev)
|
||||
.child(next)
|
||||
@@ -330,6 +457,55 @@ impl TerminalView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll `term` so `m`'s start is on screen, but only when it isn't already —
|
||||
/// an in-viewport match keeps the current scroll position so refining the query
|
||||
/// or stepping between nearby matches doesn't jerk the view around. The visible
|
||||
/// line range for the current `display_offset` is `[-offset, screen_lines-1-offset]`
|
||||
/// (the same arithmetic `recompute_matches` uses to pick the initial match).
|
||||
fn scroll_match_into_view<T: EventListener>(term: &mut Term<T>, m: &Match) {
|
||||
let grid = term.grid();
|
||||
let display_offset = grid.display_offset() as i32;
|
||||
let top = -display_offset;
|
||||
let bottom = grid.screen_lines() as i32 - 1 - display_offset;
|
||||
let line = m.start().line.0;
|
||||
if line < top || line > bottom {
|
||||
term.scroll_to_point(*m.start());
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape regex metacharacters so a literal-mode query matches itself. Mirrors
|
||||
/// `regex::escape` (which isn't a direct dependency): backslash-prefix every
|
||||
/// character the regex parser treats as special.
|
||||
fn regex_escape(query: &str) -> String {
|
||||
let mut out = String::with_capacity(query.len());
|
||||
for c in query.chars() {
|
||||
if matches!(
|
||||
c,
|
||||
'\\' | '.'
|
||||
| '+'
|
||||
| '*'
|
||||
| '?'
|
||||
| '('
|
||||
| ')'
|
||||
| '|'
|
||||
| '['
|
||||
| ']'
|
||||
| '{'
|
||||
| '}'
|
||||
| '^'
|
||||
| '$'
|
||||
| '#'
|
||||
| '&'
|
||||
| '-'
|
||||
| '~'
|
||||
) {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Test-only convenience over [`url_span_at`]: just the resolved address.
|
||||
#[cfg(test)]
|
||||
pub(super) fn url_at(text: &str, col: usize) -> Option<String> {
|
||||
@@ -715,6 +891,16 @@ fn is_url_char(c: char) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn regex_escape_neutralizes_metacharacters() {
|
||||
// A literal query for regex metacharacters must match them verbatim.
|
||||
assert_eq!(regex_escape("a.b*c"), r"a\.b\*c");
|
||||
assert_eq!(regex_escape("foo(bar)"), r"foo\(bar\)");
|
||||
assert_eq!(regex_escape("1+1=2"), r"1\+1=2");
|
||||
// Plain alphanumerics are left untouched.
|
||||
assert_eq!(regex_escape("hello"), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_at_detects_http_and_strips_trailing_punct() {
|
||||
let line = "go https://example.com, now";
|
||||
|
||||
+115
-1
@@ -3,7 +3,7 @@
|
||||
|
||||
use alacritty_terminal::event::Event as AlacEvent;
|
||||
use alacritty_terminal::grid::{Dimensions, Scroll};
|
||||
use alacritty_terminal::index::{Column, Line, Point, Side};
|
||||
use alacritty_terminal::index::{Column, Direction, Line, Point, Side};
|
||||
use alacritty_terminal::selection::{Selection, SelectionType};
|
||||
use alacritty_terminal::term::TermMode;
|
||||
use gpui::{
|
||||
@@ -162,6 +162,22 @@ pub struct TerminalView {
|
||||
/// keeps Escape feeding the PTY when the terminal is focused.
|
||||
/// `pub(super)` so the search code in `terminal::search` can mirror focus.
|
||||
pub(super) search_focused: bool,
|
||||
/// Force case-sensitive matching (the "Aa" toggle). When `false` the query
|
||||
/// keeps alacritty's smart-case default (insensitive unless it contains an
|
||||
/// uppercase char); when `true` a `(?-i)` prefix forces sensitivity. Persists
|
||||
/// across close/reopen of the bar.
|
||||
pub(super) search_case_sensitive: bool,
|
||||
/// Treat the query as a regex (the ".*" toggle). When `false` (default) the
|
||||
/// query is matched literally (metacharacters escaped); when `true` it is a
|
||||
/// regex pattern. Persists across close/reopen.
|
||||
pub(super) search_regex: bool,
|
||||
/// Set when the current query is regex mode and fails to compile — drives the
|
||||
/// error styling on the search field so an invalid pattern isn't a silent
|
||||
/// zero-match. Only ever true while `search_regex` is on.
|
||||
pub(super) search_regex_error: bool,
|
||||
/// The last query text, remembered when the bar closes so reopening restores
|
||||
/// it (unless a selection prefills instead).
|
||||
pub(super) search_last_query: String,
|
||||
/// True for a brief window after a bell event; drives a momentary visual
|
||||
/// flash painted in place of an audible beep.
|
||||
pub bell_flash: bool,
|
||||
@@ -797,6 +813,10 @@ impl TerminalView {
|
||||
cursor_visible: true,
|
||||
focused: true,
|
||||
search_focused: false,
|
||||
search_case_sensitive: false,
|
||||
search_regex: false,
|
||||
search_regex_error: false,
|
||||
search_last_query: String::new(),
|
||||
bell_flash: false,
|
||||
last_at_prompt: false,
|
||||
running_since: None,
|
||||
@@ -1184,6 +1204,17 @@ impl TerminalView {
|
||||
self.open_search(window, cx);
|
||||
CmdKey::Consumed
|
||||
}
|
||||
// ⌘G / ⌘⇧G step to the next / previous match while the bar is open
|
||||
// (macOS's standard "find again" keys), mirroring Enter / ⇧Enter.
|
||||
"g" if self.search.is_some() => {
|
||||
let dir = if m.shift {
|
||||
Direction::Left
|
||||
} else {
|
||||
Direction::Right
|
||||
};
|
||||
self.step_match(dir, cx);
|
||||
CmdKey::Consumed
|
||||
}
|
||||
"a" => {
|
||||
// At the prompt, ⌘A selects the whole edited line; otherwise it
|
||||
// selects the whole terminal buffer (scrollback included).
|
||||
@@ -5523,6 +5554,89 @@ mod gpui_tests {
|
||||
assert_eq!(next_input(&mut daemon), b"ping".to_vec());
|
||||
}
|
||||
|
||||
/// Buffer search (Cmd+F) end-to-end: the case ("Aa") and regex (".*")
|
||||
/// toggles change the match set, a broken regex flags an error instead of a
|
||||
/// silent zero-match, and closing persists the query. Drives the real
|
||||
/// `open_search` / `recompute_matches` / `close_search` path against a grid
|
||||
/// seeded through the reader thread.
|
||||
#[gpui::test]
|
||||
fn buffer_search_honors_case_and_regex_toggles(cx: &mut TestAppContext) {
|
||||
let (window, mut daemon) = harness(cx);
|
||||
|
||||
// Three lines differing only by case, so the case toggle is observable.
|
||||
DaemonMsg::Output(b"Hello World\r\nhello world\r\nWORLD wide\r\n".to_vec())
|
||||
.encode(&mut daemon)
|
||||
.unwrap();
|
||||
|
||||
// Wait for the reader thread to parse the output into the grid.
|
||||
for _ in 0..200 {
|
||||
let ready = window
|
||||
.update(cx, |v, _, _| {
|
||||
let term = v.terminal.term.lock();
|
||||
let grid = term.grid();
|
||||
(0..grid.screen_lines() as i32)
|
||||
.any(|l| (0..grid.columns()).any(|c| grid[Line(l)][Column(c)].c == 'W'))
|
||||
})
|
||||
.unwrap();
|
||||
if ready {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
|
||||
window
|
||||
.update(cx, |view, window, cx| {
|
||||
fn set_query(
|
||||
view: &mut TerminalView,
|
||||
q: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<TerminalView>,
|
||||
) {
|
||||
let input = view.search.as_ref().unwrap().input.clone();
|
||||
input.update(cx, |s, cx| s.set_value(q, window, cx));
|
||||
view.recompute_matches(cx);
|
||||
}
|
||||
|
||||
view.open_search(window, cx);
|
||||
assert!(view.search.is_some(), "Cmd+F opens the bar");
|
||||
|
||||
// Smart-case default: a lowercase query matches all three casings.
|
||||
set_query(view, "world", window, cx);
|
||||
assert_eq!(view.search.as_ref().unwrap().matches.len(), 3);
|
||||
assert!(!view.search_regex_error);
|
||||
|
||||
// Force case-sensitive: only the exact-lowercase line matches.
|
||||
view.search_case_sensitive = true;
|
||||
view.recompute_matches(cx);
|
||||
assert_eq!(view.search.as_ref().unwrap().matches.len(), 1);
|
||||
view.search_case_sensitive = false;
|
||||
|
||||
// Literal mode: "wor.d" (a literal dot) matches nothing; regex
|
||||
// mode turns "." into a wildcard so all three lines match.
|
||||
set_query(view, "wor.d", window, cx);
|
||||
assert_eq!(view.search.as_ref().unwrap().matches.len(), 0);
|
||||
view.search_regex = true;
|
||||
view.recompute_matches(cx);
|
||||
assert_eq!(view.search.as_ref().unwrap().matches.len(), 3);
|
||||
|
||||
// A broken regex flags an error rather than a silent zero-match.
|
||||
view.search_regex = true;
|
||||
set_query(view, "(", window, cx);
|
||||
assert!(view.search_regex_error);
|
||||
assert_eq!(view.search.as_ref().unwrap().matches.len(), 0);
|
||||
// The same query is a valid literal once regex mode is off.
|
||||
view.search_regex = false;
|
||||
view.recompute_matches(cx);
|
||||
assert!(!view.search_regex_error);
|
||||
|
||||
// Closing remembers the query for the next open.
|
||||
view.close_search(window, cx);
|
||||
assert_eq!(view.search_last_query, "(");
|
||||
assert!(view.search.is_none());
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn child_exit_marks_the_view_exited(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
|
||||
Reference in New Issue
Block a user