diff --git a/Cargo.lock b/Cargo.lock index 02d9036071e1b..7696c643b2dad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11390,6 +11390,7 @@ dependencies = [ "futures 0.3.31", "gpui", "language", + "log", "menu", "project", "serde", diff --git a/crates/language_tools/src/lsp_log.rs b/crates/language_tools/src/lsp_log.rs index b4883943335e7..8e0a03a8b58ed 100644 --- a/crates/language_tools/src/lsp_log.rs +++ b/crates/language_tools/src/lsp_log.rs @@ -1091,6 +1091,7 @@ impl SearchableItem for LspLogView { // LSP log is read-only. replacement: false, selection: false, + filters: false, } } fn active_match_index( diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index 18cdb36f169b6..f7ecf98e1c72c 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -29,6 +29,7 @@ editor.workspace = true futures.workspace = true gpui.workspace = true language.workspace = true +log.workspace = true menu.workspace = true project.workspace = true serde.workspace = true diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index a81ddc1a6a9c2..a424ba7da7c87 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -3,7 +3,8 @@ mod registrar; use crate::{ search_bar::render_nav_button, FocusSearch, NextHistoryQuery, PreviousHistoryQuery, ReplaceAll, ReplaceNext, SearchOptions, SelectAllMatches, SelectNextMatch, SelectPrevMatch, - ToggleCaseSensitive, ToggleRegex, ToggleReplace, ToggleSelection, ToggleWholeWord, + ToggleCaseSensitive, ToggleFilters, ToggleRegex, ToggleReplace, ToggleSelection, + ToggleWholeWord, }; use any_vec::AnyVec; use collections::HashMap; @@ -18,6 +19,8 @@ use gpui::{ Render, ScrollHandle, Styled, Subscription, Task, TextStyle, View, ViewContext, VisualContext as _, WindowContext, }; +use util::paths::PathMatcher; + use project::{ search::SearchQuery, search_history::{SearchHistory, SearchHistoryCursor}, @@ -81,6 +84,10 @@ pub struct BufferSearchBar { query_editor_focused: bool, replacement_editor: View, replacement_editor_focused: bool, + included_files_editor: View, + included_files_editor_focused: bool, + excluded_files_editor: View, + excluded_files_editor_focused: bool, active_searchable_item: Option>, active_match_index: Option, active_searchable_item_subscription: Option, @@ -96,6 +103,7 @@ pub struct BufferSearchBar { search_history_cursor: SearchHistoryCursor, replace_enabled: bool, selection_search_enabled: bool, + filters_enabled: bool, scroll_handle: ScrollHandle, editor_scroll_handle: ScrollHandle, editor_needed_width: Pixels, @@ -193,6 +201,8 @@ impl Render for BufferSearchBar { let should_show_replace_input = self.replace_enabled && supported_options.replacement; let in_replace = self.replacement_editor.focus_handle(cx).is_focused(cx); + let should_show_filters = self.filters_enabled && supported_options.filters; + let mut key_context = KeyContext::new_with_defaults(); key_context.add("BufferSearchBar"); if in_replace { @@ -265,6 +275,30 @@ impl Render for BufferSearchBar { h_flex() .gap_1() .min_w_64() + .when(supported_options.filters, |this| { + this.child( + IconButton::new("project-search-filter-button", IconName::Filter) + .shape(IconButtonShape::Square) + .tooltip(|cx| { + Tooltip::for_action("Toggle Filters", &ToggleFilters, cx) + }) + .on_click(cx.listener(|this, _, cx| { + this.toggle_filters(cx); + })) + .toggle_state(self.filters_enabled) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |cx| { + Tooltip::for_action_in( + "Toggle Filters", + &ToggleFilters, + &focus_handle, + cx, + ) + } + }), + ) + }) .when(supported_options.replacement, |this| { this.child( IconButton::new( @@ -422,6 +456,41 @@ impl Render for BufferSearchBar { ) }); + // TODO: replace with filter line + let filter_line = should_show_filters.then(|| { + h_flex() + .w_full() + .gap_2() + .child( + input_base_styles() + .on_action( + cx.listener(|this, action, cx| this.previous_history_query(action, cx)), + ) + .on_action( + cx.listener(|this, action, cx| this.next_history_query(action, cx)), + ) + .child(self.render_text_input( + &self.included_files_editor, + cx.theme().colors().text, + cx, + )), + ) + .child( + input_base_styles() + .on_action( + cx.listener(|this, action, cx| this.previous_history_query(action, cx)), + ) + .on_action( + cx.listener(|this, action, cx| this.next_history_query(action, cx)), + ) + .child(self.render_text_input( + &self.excluded_files_editor, + cx.theme().colors().text, + cx, + )), + ) + }); + v_flex() .id("buffer_search") .gap_2() @@ -474,6 +543,7 @@ impl Render for BufferSearchBar { }), ) .children(replace_line) + .children(filter_line) } } @@ -586,6 +656,22 @@ impl BufferSearchBar { let replacement_editor = cx.new_view(Editor::single_line); cx.subscribe(&replacement_editor, Self::on_replacement_editor_event) .detach(); + let included_files_editor = cx.new_view(|cx| { + let mut editor = Editor::single_line(cx); + editor.set_placeholder_text("Include: crates/**/*.toml", cx); + + editor + }); + cx.subscribe(&included_files_editor, Self::on_included_files_editor_event) + .detach(); + let excluded_files_editor = cx.new_view(|cx| { + let mut editor = Editor::single_line(cx); + editor.set_placeholder_text("Exclude: vendor/*, *.lock", cx); + + editor + }); + cx.subscribe(&excluded_files_editor, Self::on_excluded_files_editor_event) + .detach(); let search_options = SearchOptions::from_settings(&EditorSettings::get_global(cx).search); @@ -594,6 +680,10 @@ impl BufferSearchBar { query_editor_focused: false, replacement_editor, replacement_editor_focused: false, + included_files_editor, + included_files_editor_focused: false, + excluded_files_editor, + excluded_files_editor_focused: false, active_searchable_item: None, active_searchable_item_subscription: None, active_match_index: None, @@ -612,6 +702,7 @@ impl BufferSearchBar { active_search: None, replace_enabled: false, selection_search_enabled: false, + filters_enabled: false, scroll_handle: ScrollHandle::new(), editor_scroll_handle: ScrollHandle::new(), editor_needed_width: px(0.), @@ -960,6 +1051,32 @@ impl BufferSearchBar { } } + fn on_included_files_editor_event( + &mut self, + _: View, + event: &editor::EditorEvent, + _: &mut ViewContext, + ) { + match event { + editor::EditorEvent::Focused => self.included_files_editor_focused = true, + editor::EditorEvent::Blurred => self.included_files_editor_focused = false, + _ => {} + } + } + + fn on_excluded_files_editor_event( + &mut self, + _: View, + event: &editor::EditorEvent, + _: &mut ViewContext, + ) { + match event { + editor::EditorEvent::Focused => self.excluded_files_editor_focused = true, + editor::EditorEvent::Blurred => self.excluded_files_editor_focused = false, + _ => {} + } + } + fn on_active_searchable_item_event(&mut self, event: &SearchEvent, cx: &mut ViewContext) { match event { SearchEvent::MatchesInvalidated => { @@ -1032,6 +1149,18 @@ impl BufferSearchBar { if let Some(active_searchable_item) = self.active_searchable_item.as_ref() { self.query_contains_error = false; + + let included_files = + match Self::parse_path_matches(&self.included_files_editor.read(cx).text(cx)) { + Ok(included_files) => included_files, + Err(_e) => PathMatcher::default(), + }; + let excluded_files = + match Self::parse_path_matches(&self.excluded_files_editor.read(cx).text(cx)) { + Ok(excluded_files) => excluded_files, + Err(_e) => PathMatcher::default(), + }; + if query.is_empty() { self.clear_active_searchable_item_matches(cx); let _ = done_tx.send(()); @@ -1048,8 +1177,8 @@ impl BufferSearchBar { self.search_options.contains(SearchOptions::WHOLE_WORD), self.search_options.contains(SearchOptions::CASE_SENSITIVE), false, - Default::default(), - Default::default(), + included_files, + excluded_files, None, ) { Ok(query) => query.with_replacement(self.replacement(cx)), @@ -1066,8 +1195,8 @@ impl BufferSearchBar { self.search_options.contains(SearchOptions::WHOLE_WORD), self.search_options.contains(SearchOptions::CASE_SENSITIVE), false, - Default::default(), - Default::default(), + included_files, + excluded_files, None, ) { Ok(query) => query.with_replacement(self.replacement(cx)), @@ -1123,6 +1252,16 @@ impl BufferSearchBar { done_rx } + fn parse_path_matches(text: &str) -> anyhow::Result { + let queries = text + .split(',') + .map(str::trim) + .filter(|maybe_glob_str| !maybe_glob_str.is_empty()) + .map(str::to_owned) + .collect::>(); + Ok(PathMatcher::new(&queries)?) + } + pub fn update_match_index(&mut self, cx: &mut ViewContext) { let new_index = self .active_searchable_item @@ -1219,6 +1358,13 @@ impl BufferSearchBar { } } + fn toggle_filters(&mut self, cx: &mut ViewContext) -> bool { + self.filters_enabled = !self.filters_enabled; + cx.notify(); + + true + } + fn replace_next(&mut self, _: &ReplaceNext, cx: &mut ViewContext) { let mut should_propagate = true; if !self.dismissed && self.active_search.is_some() { diff --git a/crates/search/src/search.rs b/crates/search/src/search.rs index adca7bd049ae9..7eb458fd82eee 100644 --- a/crates/search/src/search.rs +++ b/crates/search/src/search.rs @@ -29,6 +29,7 @@ actions!( ToggleRegex, ToggleReplace, ToggleSelection, + ToggleFilters, SelectNextMatch, SelectPrevMatch, SelectAllMatches, diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 5a55cf6e81a90..e8aee3ec1e0df 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -1259,6 +1259,7 @@ impl SearchableItem for TerminalView { regex: true, replacement: false, selection: false, + filters: false, } } diff --git a/crates/workspace/src/searchable.rs b/crates/workspace/src/searchable.rs index d8a690bee4fb5..2666b35426ab4 100644 --- a/crates/workspace/src/searchable.rs +++ b/crates/workspace/src/searchable.rs @@ -42,6 +42,7 @@ pub struct SearchOptions { /// Specifies whether the supports search & replace. pub replacement: bool, pub selection: bool, + pub filters: bool, } pub trait SearchableItem: Item + EventEmitter { @@ -54,6 +55,7 @@ pub trait SearchableItem: Item + EventEmitter { regex: true, replacement: true, selection: true, + filters: true, // TODO: should only be true for multi-buffers } }