use std::borrow::Cow; use std::collections::HashMap; use std::ops::Range; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; use crate::coding_panel_enablement_state::CodingPanelEnablementState; use async_channel::Sender; use galaxy_editor::editor::NavigationKey; use galaxy_ripgrep::search::{Match as RipgrepMatch, Submatch}; use pathfinder_geometry::vector::vec2f; use string_offset::{ByteOffset, CharCounter}; use crate::code::icon_from_file_path; use crate::debounce::debounce; use crate::editor::{ EditorOptions, EditorView, Event as EditorEvent, InteractionState, PropagateAndNoOpNavigationKeys, PropagateHorizontalNavigationKeys, TextOptions, }; use crate::search::ItemHighlightState as SearchHighlightState; use crate::ui_components::blended_colors; use crate::ui_components::icons::Icon as UiIcon; use crate::ui_components::item_highlight::{ImageOrIcon, ItemHighlightState}; use crate::ui_components::render_file_search_row::{render_file_search_row, FileSearchRowOptions}; use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme}; use crate::workspace::view::global_search::model::GlobalSearch; use crate::workspace::view::global_search::SearchConfig; use crate::TelemetryEvent; use galaxy_core::send_telemetry_from_ctx; use galaxy_core::ui::appearance::Appearance; use galaxy_core::ui::theme::color::internal_colors; use galaxy_core::ui::theme::{AnsiColorIdentifier, Fill as ThemeFill}; use galaxy_core::ui::Icon; use galaxyui::elements::{ Border, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DispatchEventResult, Empty, EventHandler, Fill, Flex, FormattedTextElement, Highlight, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, Padding, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, ScrollStateHandle, Scrollable, ScrollableElement, ScrollbarWidth, Shrinkable, Stack, Text, UniformList, UniformListState, }; use galaxyui::fonts::{Properties, Weight}; use galaxyui::keymap::FixedBinding; use galaxyui::platform::Cursor; use galaxyui::text_layout::{TextAlignment, TextStyle}; use galaxyui::ui_components::components::{UiComponent as _, UiComponentStyles}; use galaxyui::ui_components::text::Span; use galaxyui::{ AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, WeakViewHandle, }; const BORDER_RADIUS: f32 = 6.; const BORDER_WIDTH: f32 = 1.; const DO_NOT_TRUNCATE_CHAR_COUNT: usize = 40; const DO_TRUNCATE_END_CHAR_COUNT: usize = 200; const PRE_MATCH_CHARS: usize = 15; const MAX_MATCH_COUNT: usize = 20000; const QUERY_EDITOR_MAX_LINES: usize = 6; const QUERY_DEBOUNCE_PERIOD: Duration = Duration::from_millis(300); #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum GlobalSearchEntryFocus { QueryEditor, Results, } enum FocusMode { QueryEditor, ResultsList, } #[derive(Debug, Clone)] pub enum GlobalSearchAction { SelectRow { directory_path: PathBuf, file_path: PathBuf, match_index: Option, }, ToggleFileCollapsed { directory_path: PathBuf, file_path: PathBuf, }, ToggleDirectoryCollapsed { directory_path: PathBuf, }, OpenMatch { path: PathBuf, line_number: u32, column_num: Option, }, ResultsUp, ResultsDown, ResultsLeft, ResultsRight, ResultsEnter, FocusQueryEditor, FocusResultsList, ToggleRegexSearch, ToggleCaseSensitivity, } #[cfg_attr(target_family = "wasm", allow(dead_code))] pub enum GlobalSearchEvent { Started { search_id: u32, }, Progress { search_id: u32, result: RipgrepMatch, }, ProgressBatch { search_id: u32, items: Vec, }, Completed { search_id: u32, total_match_count: usize, }, Failed { search_id: u32, error: String, }, } #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] pub enum Event { OpenMatch { path: PathBuf, line_number: u32, column_num: Option, }, } enum SelectionDirection { Up, Down, } /// Absolute row index in the flattened view. #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct GlobalIndex(usize); /// Hierarchical index that identifies a specific row by its position in the /// directory/file/match hierarchy. #[derive(Clone, Debug, PartialEq, Eq)] struct RowIndex { directory_index: usize, index_type: RowIndexType, } #[derive(Clone, Debug, PartialEq, Eq)] enum RowIndexType { DirectoryHeader, FileHeader { path_index: usize, }, Match { path_index: usize, match_index: usize, }, } /// A root directory containing matched files. struct DirectoryEntry { path: PathBuf, is_collapsed: bool, mouse_state: MouseStateHandle, matched_paths: MatchedPaths, } impl DirectoryEntry { fn new(path: PathBuf) -> Self { Self { path, is_collapsed: false, mouse_state: MouseStateHandle::default(), matched_paths: MatchedPaths::new(), } } /// Returns the number of visible rows for this directory entry. /// If collapsed, only the directory header is visible (1 row). /// Otherwise, includes directory header + all visible file rows. fn visible_count(&self) -> usize { if self.is_collapsed { 1 } else { 1 + self.matched_paths.visible_count() } } /// Returns the total number of matches across all files in this directory. fn total_match_count(&self) -> usize { self.matched_paths .paths .iter() .map(|p| p.matches.len()) .sum() } } /// Collection of matched files within a directory. struct MatchedPaths { paths: Vec, index_by_path: HashMap, } impl MatchedPaths { fn new() -> Self { Self { paths: Vec::new(), index_by_path: HashMap::new(), } } /// Returns the total number of visible rows across all files. fn visible_count(&self) -> usize { self.paths.iter().map(|p| p.visible_count()).sum() } /// Gets or creates a MatchedPath entry for the given file path. /// Returns a mutable reference to the entry and its index. fn get_or_create(&mut self, path: &Path) -> (&mut MatchedPath, usize) { if let Some(&index) = self.index_by_path.get(path) { (&mut self.paths[index], index) } else { let index = self.paths.len(); self.paths.push(MatchedPath::new(path.to_path_buf())); self.index_by_path.insert(path.to_path_buf(), index); (&mut self.paths[index], index) } } /// Gets a mutable MatchedPath entry by file path. fn get_mut(&mut self, path: &Path) -> Option<&mut MatchedPath> { self.index_by_path .get(path) .copied() .map(|idx| &mut self.paths[idx]) } } /// A file containing matches. struct MatchedPath { path: PathBuf, is_collapsed: bool, mouse_state: MouseStateHandle, matches: Vec, } impl MatchedPath { fn new(path: PathBuf) -> Self { Self { path, is_collapsed: false, mouse_state: MouseStateHandle::default(), matches: Vec::new(), } } /// Returns the number of visible rows for this file. /// If collapsed, only the file header is visible (1 row). /// Otherwise, includes file header + all matches. fn visible_count(&self) -> usize { if self.is_collapsed { 1 } else { 1 + self.matches.len() } } } /// A single match within a file. struct Match { line_text: String, line_number: u32, submatches: Vec, mouse_state: MouseStateHandle, } impl Match { fn new(line_text: String, line_number: u32, submatches: Vec) -> Self { Self { line_text, line_number, submatches, mouse_state: MouseStateHandle::default(), } } } pub struct GlobalSearchView { find_model: ModelHandle, query_editor: ViewHandle, query_change_tx: Sender<()>, /// All terminal working directories for display grouping (preserved as-is) root_directories: Vec, /// Deduplicated roots for ripgrep search (excludes nested subdirectories) search_roots: Vec, last_searched_pattern: Option, directory_entries: Vec, directory_path_to_directory_index_entry: HashMap, selected_row: Option, total_match_count: usize, is_search_in_progress: bool, capped_matches: bool, last_error: Option, scroll_state: ScrollStateHandle, uniform_list_state: UniformListState, handle: WeakViewHandle, focus_mode: FocusMode, enablement: CodingPanelEnablementState, current_search_id: Option, regex_search_enabled: bool, regex_button: ViewHandle, case_sensitivity_enabled: bool, case_sensitivity_button: ViewHandle, } impl Entity for GlobalSearchView { type Event = Event; } impl TypedActionView for GlobalSearchView { type Action = GlobalSearchAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { GlobalSearchAction::SelectRow { directory_path, file_path, match_index, } => { if let Some(row_index) = self.path_to_row_index(directory_path, file_path, *match_index) { self.set_selected_row(row_index, ctx); self.enter_results_mode(ctx); } } GlobalSearchAction::ToggleFileCollapsed { directory_path, file_path, } => { self.toggle_file_collapsed(directory_path, file_path, ctx); } GlobalSearchAction::ToggleDirectoryCollapsed { directory_path } => { self.toggle_directory_collapsed(directory_path, ctx); } GlobalSearchAction::OpenMatch { path, line_number, column_num, } => { ctx.emit(Event::OpenMatch { path: path.clone(), line_number: *line_number, column_num: *column_num, }); self.enter_query_mode(ctx); } GlobalSearchAction::ResultsDown => { if !matches!(self.focus_mode, FocusMode::ResultsList) { return; } if self.directory_entries.is_empty() { return; } self.move_selection(SelectionDirection::Down, ctx); } GlobalSearchAction::ResultsUp => { if !matches!(self.focus_mode, FocusMode::ResultsList) { return; } if self.directory_entries.is_empty() { return; } if self.is_first_row_selected() { self.enter_query_mode(ctx); } else { self.move_selection(SelectionDirection::Up, ctx); } } GlobalSearchAction::ResultsLeft => { if !matches!(self.focus_mode, FocusMode::ResultsList) { return; } let Some(selected) = self.selected_row.clone() else { return; }; match &selected.index_type { RowIndexType::DirectoryHeader => { let is_collapsed = self.is_directory_collapsed(&selected); if !is_collapsed { if let Some(dir_path) = self.directory_path_for_row_index(&selected).cloned() { self.toggle_directory_collapsed(&dir_path, ctx); } } } RowIndexType::FileHeader { .. } => { let is_collapsed = self.is_file_collapsed(&selected); if !is_collapsed { if let (Some(dir_path), Some(file_path)) = ( self.directory_path_for_row_index(&selected).cloned(), self.file_path_for_row_index(&selected).cloned(), ) { self.toggle_file_collapsed(&dir_path, &file_path, ctx); } } } RowIndexType::Match { .. } => {} } } GlobalSearchAction::ResultsRight => { if !matches!(self.focus_mode, FocusMode::ResultsList) { return; } let Some(selected) = self.selected_row.clone() else { return; }; match &selected.index_type { RowIndexType::DirectoryHeader => { let is_collapsed = self.is_directory_collapsed(&selected); if is_collapsed { if let Some(dir_path) = self.directory_path_for_row_index(&selected).cloned() { self.toggle_directory_collapsed(&dir_path, ctx); } } } RowIndexType::FileHeader { .. } => { let is_collapsed = self.is_file_collapsed(&selected); if is_collapsed { if let (Some(dir_path), Some(file_path)) = ( self.directory_path_for_row_index(&selected).cloned(), self.file_path_for_row_index(&selected).cloned(), ) { self.toggle_file_collapsed(&dir_path, &file_path, ctx); } } } RowIndexType::Match { .. } => {} } } GlobalSearchAction::ResultsEnter => { if !matches!(self.focus_mode, FocusMode::ResultsList) { return; } self.ensure_selection(ctx); if self.selected_row.is_some() { self.activate_selected_row(ctx); } } GlobalSearchAction::FocusQueryEditor => { self.enter_query_mode(ctx); } GlobalSearchAction::FocusResultsList => { self.enter_results_mode(ctx); } GlobalSearchAction::ToggleRegexSearch => { self.regex_search_enabled = !self.regex_search_enabled; self.regex_button.update(ctx, |button, ctx| { button.set_active(self.regex_search_enabled, ctx); }); self.rerun_search_from_query(ctx, true); } GlobalSearchAction::ToggleCaseSensitivity => { self.case_sensitivity_enabled = !self.case_sensitivity_enabled; self.case_sensitivity_button.update(ctx, |button, ctx| { button.set_active(self.case_sensitivity_enabled, ctx); }); self.rerun_search_from_query(ctx, true); } } } } impl GlobalSearchView { /// Calculate the 1-indexed column number from the first submatch. /// Returns None if there are no submatches or the line is empty. fn column_from_submatches(line_text: &str, submatches: &[Submatch]) -> Option { if line_text.is_empty() || submatches.is_empty() { return None; } let first_submatch = &submatches[0]; let max_byte = ByteOffset::from(line_text.len()); let start_b = first_submatch.byte_start.min(max_byte); let mut char_counter = CharCounter::new(line_text); let start_char = char_counter.char_offset(start_b)?; // Return 1-indexed column number Some(start_char.as_usize() + 1) } /// Convert submatch byte ranges into character indices for highlighting. fn highlight_indices_from_submatches(line_text: &str, submatches: &[Submatch]) -> Vec { if line_text.is_empty() || submatches.is_empty() { return Vec::new(); } let max_byte = ByteOffset::from(line_text.len()); let total_chars = line_text.chars().count(); let mut indices = Vec::new(); let mut char_counter = CharCounter::new(line_text); for submatch in submatches { let start_b = submatch.byte_start.min(max_byte); let end_b = submatch.byte_end.min(max_byte); if start_b >= end_b { continue; } // NOTE: Ripgrep submatch offsets are byte-based (and `end_b` is exclusive). If a match // ends at the end of the string, `end_b == line_text.len()` and there is no character // starting at that byte offset; `CharCounter::char_offset(end_b)` would return `None`. // Handle that case explicitly. let Some(start_char) = char_counter.char_offset(start_b) else { continue; }; let end_char = if end_b == max_byte { total_chars } else { let Some(end_char) = char_counter.char_offset(end_b) else { continue; }; end_char.as_usize() }; let start_char = start_char.as_usize(); if start_char >= end_char { continue; } indices.extend(start_char..end_char); } indices.sort_unstable(); indices } fn elide_match_text( line_text: &str, mut highlight_indices: Vec, ) -> (String, Vec) { if line_text.is_empty() || highlight_indices.is_empty() { return (line_text.to_owned(), highlight_indices); } let total_chars = line_text.chars().count(); if total_chars <= DO_NOT_TRUNCATE_CHAR_COUNT { return (line_text.to_owned(), highlight_indices); } highlight_indices.sort_unstable(); let first_highlight = highlight_indices[0]; let snippet_start_char = first_highlight.saturating_sub(PRE_MATCH_CHARS); let snippet_end_char = (snippet_start_char + DO_TRUNCATE_END_CHAR_COUNT).min(total_chars); let prefix_ellipsis = snippet_start_char > 0; let suffix_ellipsis = snippet_end_char < total_chars; let snippet_core: String = line_text .chars() .skip(snippet_start_char) .take(snippet_end_char.saturating_sub(snippet_start_char)) .collect(); let mut snippet = String::new(); if prefix_ellipsis { snippet.push('…'); } snippet.push_str(&snippet_core); if suffix_ellipsis { snippet.push('…'); } let prefix_offset = if prefix_ellipsis { 1 } else { 0 }; highlight_indices.retain(|&idx| idx >= snippet_start_char && idx < snippet_end_char); for idx in &mut highlight_indices { *idx = idx.saturating_sub(snippet_start_char) + prefix_offset; } (snippet, highlight_indices) } pub fn init(app: &mut AppContext) { use galaxyui::keymap::macros::*; app.register_fixed_bindings([ FixedBinding::new( "up", GlobalSearchAction::ResultsUp, id!(GlobalSearchView::ui_name()), ), FixedBinding::new( "down", GlobalSearchAction::ResultsDown, id!(GlobalSearchView::ui_name()), ), FixedBinding::new( "left", GlobalSearchAction::ResultsLeft, id!(GlobalSearchView::ui_name()), ), FixedBinding::new( "right", GlobalSearchAction::ResultsRight, id!(GlobalSearchView::ui_name()), ), FixedBinding::new( "enter", GlobalSearchAction::ResultsEnter, id!(GlobalSearchView::ui_name()), ), ]); } pub fn new(ctx: &mut ViewContext) -> Self { let find_model = ctx.add_model(|_| GlobalSearch::new()); let query_editor = ctx.add_typed_action_view(|ctx| { let appearance = Appearance::as_ref(ctx); let options = EditorOptions { text: TextOptions::ui_text(Some(14.), appearance), select_all_on_focus: true, clear_selections_on_blur: true, propagate_and_no_op_vertical_navigation_keys: PropagateAndNoOpNavigationKeys::AtBoundary, propagate_horizontal_navigation_keys: PropagateHorizontalNavigationKeys::Always, // Preserve newlines when pasting so users can search for multi-line patterns. convert_newline_to_space: false, single_line: false, autogrow: true, // Prefer explicit newlines (from paste or shift-enter) over soft wrapping. soft_wrap: false, ..Default::default() }; let mut editor = EditorView::new(options, ctx); editor.set_placeholder_text("Search in files", ctx); editor }); let (query_change_tx, query_change_rx) = async_channel::unbounded(); ctx.spawn_stream_local( debounce(QUERY_DEBOUNCE_PERIOD, query_change_rx), Self::handle_debounced_query_change, |_, _| {}, ); let case_sensitivity_button = ctx.add_typed_action_view(|_ctx| { ActionButton::new_with_boxed_theme(String::new(), Arc::new(NakedTheme)) .with_icon(UiIcon::CaseSensitivity) .with_tooltip("Toggle Case Sensitivity") .with_size(ButtonSize::Small) .on_click(|ctx| { ctx.dispatch_typed_action(GlobalSearchAction::ToggleCaseSensitivity); }) }); let regex_button = ctx.add_typed_action_view(|_ctx| { ActionButton::new_with_boxed_theme(String::new(), Arc::new(NakedTheme)) .with_icon(UiIcon::Regex) .with_tooltip("Toggle Regex") .with_size(ButtonSize::Small) .on_click(|ctx| { ctx.dispatch_typed_action(GlobalSearchAction::ToggleRegexSearch); }) }); ctx.subscribe_to_view(&query_editor, |me, _handle, event, ctx| { me.handle_query_editor_event(event, ctx); }); ctx.subscribe_to_model(&find_model, |me, _handle, event, ctx| { me.handle_find_model_event(event, ctx); }); let handle = ctx.handle(); GlobalSearchView { find_model, query_editor, query_change_tx, root_directories: Vec::new(), search_roots: Vec::new(), last_searched_pattern: None, directory_entries: Vec::new(), directory_path_to_directory_index_entry: HashMap::new(), selected_row: None, total_match_count: 0, is_search_in_progress: false, capped_matches: false, last_error: None, scroll_state: ScrollStateHandle::default(), uniform_list_state: UniformListState::new(), handle, focus_mode: FocusMode::QueryEditor, enablement: CodingPanelEnablementState::Enabled, current_search_id: None, regex_search_enabled: false, regex_button, case_sensitivity_enabled: false, case_sensitivity_button, } } pub fn on_left_panel_focused( &mut self, entry_focus: GlobalSearchEntryFocus, ctx: &mut ViewContext, ) { match entry_focus { GlobalSearchEntryFocus::QueryEditor => { self.enter_query_mode(ctx); } GlobalSearchEntryFocus::Results => { if self.directory_entries.is_empty() { self.enter_query_mode(ctx); } else { self.enter_results_mode(ctx); } } } } fn set_query_mode_state(&mut self, ctx: &mut ViewContext) { self.focus_mode = FocusMode::QueryEditor; self.query_editor.update(ctx, |editor, ctx| { editor.set_interaction_state(InteractionState::Editable, ctx); }); self.selected_row = None; ctx.notify(); } /// Returns an iterator over all directory paths where the given file path should appear. /// A file matches a directory if the file path starts with that directory. fn find_matching_directories<'a>( &'a self, file_path: &'a Path, ) -> impl Iterator { self.root_directories .iter() .filter(move |root| file_path.starts_with(root)) } fn apply_progress_item(&mut self, result: RipgrepMatch, ctx: &mut ViewContext) { if self.total_match_count >= MAX_MATCH_COUNT { return; } let file_path = result.file_path.clone(); // Find all directories that this file belongs to let mut matching_directories = self.find_matching_directories(&file_path).peekable(); if matching_directories.peek().is_none() { // File doesn't match any root directory, skip it let file_path_name = file_path .file_name() .map(|name| name.to_string_lossy()) .unwrap_or_else(|| std::borrow::Cow::Borrowed("")); log::warn!("[Global search] file {file_path_name} was not found in directories"); return; } let matching_directories: Vec<_> = matching_directories.cloned().collect(); // Populate hierarchical data model (directory_entries) let (directory_entries, directory_path_to_directory_index_entry) = ( &mut self.directory_entries, &mut self.directory_path_to_directory_index_entry, ); for directory_path in &matching_directories { // Get or create the directory entry let dir_index = *directory_path_to_directory_index_entry .entry(directory_path.clone()) .or_insert_with(|| { let idx = directory_entries.len(); directory_entries.push(DirectoryEntry::new(directory_path.clone())); idx }); // Get or create the matched path entry within this directory let dir_entry = &mut directory_entries[dir_index]; let (matched_path, _path_index) = dir_entry.matched_paths.get_or_create(&file_path); // Add the match matched_path.matches.push(Match::new( result.line_text.clone(), result.line_number, result.submatches.clone(), )); } self.total_match_count += 1; if self.total_match_count == MAX_MATCH_COUNT { self.abort_search(ctx); } } fn abort_search(&mut self, ctx: &mut ViewContext) { self.capped_matches = true; self.is_search_in_progress = false; self.current_search_id = None; self.find_model.update(ctx, |model, _| { model.abort_search(); }); } fn handle_debounced_query_change(&mut self, _event: (), ctx: &mut ViewContext) { self.rerun_search_from_query(ctx, false); } fn handle_query_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext) { match event { EditorEvent::Navigate(NavigationKey::Down) => { if !matches!(self.focus_mode, FocusMode::QueryEditor) { return; } if self.directory_entries.is_empty() { return; } self.enter_results_mode(ctx); } EditorEvent::Edited(_) | EditorEvent::BufferReplaced | EditorEvent::BufferReinitialized => { let query_text = self.query_editor.as_ref(ctx).buffer_text(ctx); if query_text.is_empty() { self.current_search_id = None; self.is_search_in_progress = false; self.reset_search_state(true); ctx.notify(); return; } self.notify_query_changed(); } EditorEvent::Enter => { self.rerun_search_from_query(ctx, true); } _ => {} } } fn notify_query_changed(&self) { let _ = self.query_change_tx.try_send(()); } fn reset_search_state(&mut self, clear_last_pattern: bool) { self.directory_entries.clear(); self.directory_path_to_directory_index_entry.clear(); self.selected_row = None; self.total_match_count = 0; self.capped_matches = false; self.last_error = None; self.uniform_list_state = UniformListState::new(); if clear_last_pattern { self.last_searched_pattern = None; } } fn rerun_search_from_query(&mut self, ctx: &mut ViewContext, force: bool) { if !matches!(self.focus_mode, FocusMode::QueryEditor) && !force { return; } let pattern = self.query_editor.as_ref(ctx).buffer_text(ctx); if pattern.is_empty() { self.current_search_id = None; self.is_search_in_progress = false; self.reset_search_state(true); ctx.notify(); return; } if self.search_roots.is_empty() { log::warn!("GlobalSearch: no search roots; skipping search"); return; } let should_run_search = if force { true } else { match self.last_searched_pattern.as_deref() { Some(last) => last != pattern, None => true, } }; if !should_run_search { return; } self.last_searched_pattern = Some(pattern.clone()); let roots = self.search_roots.clone(); self.find_model.update(ctx, |model, model_ctx| { model.run_search( pattern.clone(), roots, SearchConfig { use_regex: self.regex_search_enabled, use_case_sensitivity: self.case_sensitivity_enabled, }, model_ctx, ); }); } fn handle_find_model_event(&mut self, event: &GlobalSearchEvent, ctx: &mut ViewContext) { match event { GlobalSearchEvent::Started { search_id } => { send_telemetry_from_ctx!(TelemetryEvent::GlobalSearchQueryStarted, ctx); self.current_search_id = Some(*search_id); self.is_search_in_progress = true; self.reset_search_state(false); ctx.notify(); } GlobalSearchEvent::Progress { search_id, result } => { if Some(*search_id) != self.current_search_id { return; } self.apply_progress_item(result.clone(), ctx); ctx.notify(); } GlobalSearchEvent::ProgressBatch { search_id, items } => { if Some(*search_id) != self.current_search_id { return; } for item in items { self.apply_progress_item(item.clone(), ctx); if self.capped_matches { break; } } ctx.notify(); } GlobalSearchEvent::Completed { search_id, total_match_count, } => { if Some(*search_id) != self.current_search_id { return; } self.is_search_in_progress = false; self.total_match_count = *total_match_count; ctx.notify(); } GlobalSearchEvent::Failed { search_id, error } => { if Some(*search_id) != self.current_search_id { return; } self.is_search_in_progress = false; self.reset_search_state(false); self.last_error = Some(error.clone()); ctx.notify(); } } } pub fn set_root_directories(&mut self, roots: Vec, _ctx: &mut ViewContext) { // Ancestor-dedup search roots so we don't search the same file twice // when terminal directories are nested (e.g. `~/code` + `~/code/a`). // Shared with `FileTreeView` for consistency. self.search_roots = galaxy_util::path::group_roots_by_common_ancestor(&roots).roots; self.root_directories = roots; } /// Pre-populates the search query with the given text. /// Selects all text so the user can easily overwrite or keep it, then triggers a search. pub fn set_initial_query(&mut self, text: String, ctx: &mut ViewContext) { self.query_editor.update(ctx, |editor, ctx| { editor.set_buffer_text(&text, ctx); editor.select_all(ctx); }); // Trigger the debounced search self.notify_query_changed(); ctx.notify(); } pub(crate) fn set_enablement_state( &mut self, enablement: CodingPanelEnablementState, ctx: &mut ViewContext, ) { if self.enablement == enablement { return; } self.enablement = enablement; ctx.notify(); } fn render_row_at_index(&self, index: usize, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); // Convert global index to hierarchical RowIndex let Some(row_index) = self.to_row_index(GlobalIndex(index)) else { return Empty::new().finish(); }; // Get the directory entry let Some(dir_entry) = self.directory_entries.get(row_index.directory_index) else { debug_assert!(self.to_row_index(GlobalIndex(index)).is_some()); return Empty::new().finish(); }; match &row_index.index_type { RowIndexType::DirectoryHeader => { self.render_directory_header_from_entry(index, dir_entry, appearance, theme) } RowIndexType::FileHeader { path_index } => { let Some(matched_path) = dir_entry.matched_paths.paths.get(*path_index) else { return Empty::new().finish(); }; self.render_file_header( index, &dir_entry.path, matched_path, appearance, theme, app, ) } RowIndexType::Match { path_index, match_index, } => { let Some(matched_path) = dir_entry.matched_paths.paths.get(*path_index) else { return Empty::new().finish(); }; let Some(matched) = matched_path.matches.get(*match_index) else { return Empty::new().finish(); }; self.render_match_row( index, &dir_entry.path, matched_path, matched, *match_index, appearance, theme, ) } } } fn render_file_header( &self, index: usize, directory_path: &Path, matched_path: &MatchedPath, appearance: &Appearance, theme: &galaxy_core::ui::theme::WarpTheme, app: &AppContext, ) -> Box { let is_selected = self.is_row_at_index_selected(index); let is_collapsed = matched_path.is_collapsed; let file_mouse_state = matched_path.mouse_state.clone(); let file_path = matched_path.path.clone(); let match_count = matched_path.matches.len(); let directory_path_for_select = directory_path.to_path_buf(); let file_path_clone = file_path.clone(); let directory_path_for_toggle = directory_path.to_path_buf(); let display_path = file_path .strip_prefix(directory_path) .unwrap_or(file_path.as_path()); let display_path = display_path.to_path_buf(); Hoverable::new(file_mouse_state, move |mouse_state| { let item_highlight_state = ItemHighlightState::new(is_selected, mouse_state); let search_highlight_state = SearchHighlightState::new(is_selected, mouse_state); let list_highlight_state = ItemHighlightState::new(is_selected, mouse_state); let chevron_icon_enum = if is_collapsed { Icon::ChevronRight } else { Icon::ChevronDown }; let icon_size = 16.0; let chevron_color = item_highlight_state.text_and_icon_color(appearance); let chevron_icon = chevron_icon_enum .to_galaxyui_icon(ThemeFill::from(chevron_color)) .finish(); let chevron_icon = ConstrainedBox::new(chevron_icon) .with_width(icon_size) .with_height(icon_size) .finish(); let chevron_container = Container::new(chevron_icon).with_margin_right(8.).finish(); let tooltip_text = file_path.to_string_lossy().to_string(); let header_text_fill = match list_highlight_state { ItemHighlightState::None => { ThemeFill::Solid(blended_colors::text_sub(theme, theme.background())) } ItemHighlightState::Hovered => { ThemeFill::Solid(blended_colors::text_main(theme, theme.background())) } ItemHighlightState::Selected => ThemeFill::Solid(theme.foreground().into()), }; let left = render_file_search_row( &display_path, FileSearchRowOptions { item_font_size: Some(14.), path_font_size: Some(12.), highlight_state: search_highlight_state, text_color_override: Some(header_text_fill), max_combined_length: None, ..Default::default() }, app, ); let count_text = Text::new_inline(match_count.to_string(), appearance.ui_font_family(), 12.0) .with_color(appearance.theme().background().into()) .finish(); let count_badge = Container::new(count_text) .with_horizontal_padding(4.) .with_vertical_padding(1.) .with_background_color(theme.terminal_colors().normal.yellow.into()) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) .finish(); let icon_from_file_path = icon_from_file_path(&file_path.to_string_lossy(), appearance) .map(ImageOrIcon::Image) .unwrap_or(ImageOrIcon::Icon(Icon::File)); let icon_color = list_highlight_state.text_and_icon_color(appearance); let file_icon = match icon_from_file_path { ImageOrIcon::Icon(icon) => { icon.to_galaxyui_icon(ThemeFill::from(icon_color)).finish() } ImageOrIcon::Image(image) => image, }; let file_icon_element = Container::new( ConstrainedBox::new(file_icon) .with_width(icon_size) .with_height(icon_size) .finish(), ) .with_padding_right(8.) .finish(); let left_container = Container::new(left).with_margin_right(8.).finish(); let left_row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Start) .with_child(chevron_container) .with_child(file_icon_element) .with_child(Shrinkable::new(1.0, left_container).finish()) .finish(); let header_row = Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(Shrinkable::new(1.0, left_row).finish()) .with_child(count_badge) .finish(); let mut row_with_padding = Container::new(header_row).with_padding(Padding::uniform(4.).with_left(8.)); // 8px indent under directory if let Some(background_fill) = list_highlight_state.background_color(appearance) { let corner_radius = list_highlight_state .corner_radius() .unwrap_or_else(|| CornerRadius::with_all(Radius::Pixels(4.))); row_with_padding = row_with_padding .with_background(background_fill) .with_corner_radius(corner_radius); } let base_row = row_with_padding.finish(); // Add a tooltip on hover that shows the full (untruncated) file path. if mouse_state.is_hovered() { let tooltip = appearance .ui_builder() .tool_tip(tooltip_text) .build() .finish(); let mut stack = Stack::new().with_child(base_row); stack.add_positioned_overlay_child( tooltip, OffsetPositioning::offset_from_parent( vec2f(0., 4.), ParentOffsetBounds::WindowByPosition, ParentAnchor::BottomMiddle, ChildAnchor::TopLeft, ), ); stack.finish() } else { base_row } }) .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(GlobalSearchAction::SelectRow { directory_path: directory_path_for_select.clone(), file_path: file_path_clone.clone(), match_index: None, }); ctx.dispatch_typed_action(GlobalSearchAction::ToggleFileCollapsed { directory_path: directory_path_for_toggle.clone(), file_path: file_path_clone.clone(), }); }) .finish() } #[allow(clippy::too_many_arguments)] fn render_match_row( &self, index: usize, directory_path: &Path, matched_path: &MatchedPath, matched: &Match, match_index: usize, appearance: &Appearance, theme: &galaxy_core::ui::theme::WarpTheme, ) -> Box { let is_selected = self.is_row_at_index_selected(index); let line_number = matched.line_number; let line_text = matched.line_text.clone(); let submatches = matched.submatches.clone(); let mouse_state = matched.mouse_state.clone(); let directory_path_for_select = directory_path.to_path_buf(); let file_path_for_select = matched_path.path.clone(); let path_for_click = matched_path.path.clone(); // Clone for the on_click closure since line_text and submatches are moved into Hoverable let line_text_for_click = line_text.clone(); let submatches_for_click = submatches.clone(); Hoverable::new(mouse_state, move |mouse_state| { let list_highlight_state = ItemHighlightState::new(is_selected, mouse_state); let text_color = match list_highlight_state { ItemHighlightState::None => blended_colors::text_sub(theme, theme.background()), ItemHighlightState::Hovered => blended_colors::text_main(theme, theme.background()), ItemHighlightState::Selected => theme.foreground().into(), }; let highlight_indices = GlobalSearchView::highlight_indices_from_submatches(&line_text, &submatches); let (display_text, display_highlight_indices) = GlobalSearchView::elide_match_text(&line_text, highlight_indices); let mut match_text = Text::new_inline( display_text, appearance.ui_font_family(), appearance.ui_font_size() + 2., ) .with_color(text_color); if !display_highlight_indices.is_empty() { let yellow_overlay_2 = theme.ansi_overlay_2( AnsiColorIdentifier::Yellow.to_ansi_color(&theme.terminal_colors().normal), ); let highlight_style = TextStyle::new().with_background_color(yellow_overlay_2); let highlight = Highlight::new().with_text_style(highlight_style); match_text = match_text.with_single_highlight(highlight, display_highlight_indices); } let mut row = Container::new(match_text.finish()) .with_vertical_padding(4.) .with_padding_left(32.) // 8px file indent + 24px (chevron + margin) to align with file icon .with_padding_right(12.); if let Some(background_fill) = list_highlight_state.background_color(appearance) { let corner_radius = list_highlight_state .corner_radius() .unwrap_or_else(|| CornerRadius::with_all(Radius::Pixels(4.))); row = row .with_background(background_fill) .with_corner_radius(corner_radius); } row.finish() }) .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(GlobalSearchAction::SelectRow { directory_path: directory_path_for_select.clone(), file_path: file_path_for_select.clone(), match_index: Some(match_index), }); let column_num = GlobalSearchView::column_from_submatches( &line_text_for_click, &submatches_for_click, ); ctx.dispatch_typed_action(GlobalSearchAction::OpenMatch { path: path_for_click.clone(), line_number, column_num, }); }) .finish() } fn items_count(&self) -> usize { self.items_count_from_entries() } /// Converts a global (flat) index to a hierarchical RowIndex. /// Returns None if the index is out of bounds. fn to_row_index(&self, global: GlobalIndex) -> Option { let mut running_count = 0; for (dir_idx, dir_entry) in self.directory_entries.iter().enumerate() { let dir_visible = dir_entry.visible_count(); if running_count + dir_visible > global.0 { // The target row is within this directory let offset_within_dir = global.0 - running_count; return self.find_row_within_directory(dir_idx, offset_within_dir); } running_count += dir_visible; } None } /// Helper to find a specific row within a directory given an offset. /// offset=0 means the directory header itself. fn find_row_within_directory(&self, dir_idx: usize, offset: usize) -> Option { let dir_entry = self.directory_entries.get(dir_idx)?; // offset 0 is the directory header if offset == 0 { return Some(RowIndex { directory_index: dir_idx, index_type: RowIndexType::DirectoryHeader, }); } // If directory is collapsed, only the header is visible if dir_entry.is_collapsed { return None; } // Skip the directory header let mut remaining = offset - 1; for (path_idx, matched_path) in dir_entry.matched_paths.paths.iter().enumerate() { let path_visible = matched_path.visible_count(); if remaining < path_visible { // The target row is within this file if remaining == 0 { // It's the file header return Some(RowIndex { directory_index: dir_idx, index_type: RowIndexType::FileHeader { path_index: path_idx, }, }); } // It's a match row (remaining - 1 because we skip the file header) let match_index = remaining - 1; if match_index < matched_path.matches.len() { return Some(RowIndex { directory_index: dir_idx, index_type: RowIndexType::Match { path_index: path_idx, match_index, }, }); } return None; } remaining -= path_visible; } None } /// Converts a hierarchical RowIndex to a global (flat) index. fn to_global_index(&self, row: &RowIndex) -> usize { let mut running_count = 0; // Sum up all visible rows from directories before the target for dir_entry in self.directory_entries.iter().take(row.directory_index) { running_count += dir_entry.visible_count(); } // Add the offset within the target directory running_count + self.offset_within_directory(row) } /// Calculates the offset of a RowIndex within its directory. fn offset_within_directory(&self, row: &RowIndex) -> usize { match &row.index_type { RowIndexType::DirectoryHeader => 0, RowIndexType::FileHeader { path_index } => { let Some(dir_entry) = self.directory_entries.get(row.directory_index) else { debug_assert!( false, "RowIndex.directory_index {} out of bounds", row.directory_index ); return 0; }; // 1 for directory header + sum of visible counts before this file 1 + dir_entry .matched_paths .paths .iter() .take(*path_index) .map(|p| p.visible_count()) .sum::() } RowIndexType::Match { path_index, match_index, } => { let Some(dir_entry) = self.directory_entries.get(row.directory_index) else { debug_assert!( false, "RowIndex.directory_index {} out of bounds", row.directory_index ); return 0; }; // 1 for directory header + sum of visible counts before this file // + 1 for file header + match_index 1 + dir_entry .matched_paths .paths .iter() .take(*path_index) .map(|p| p.visible_count()) .sum::() + 1 + match_index } } } /// Returns the total number of visible rows fn items_count_from_entries(&self) -> usize { self.directory_entries .iter() .map(|d| d.visible_count()) .sum() } /// Returns the total number of unique matches across all directories. fn unique_match_count(&self) -> usize { self.directory_entries .iter() .map(|d| d.matched_paths.paths.len()) .sum() } /// Gets or creates a DirectoryEntry for the given path. /// Returns a mutable reference to the entry and its index. #[allow(dead_code)] // Will be used in later PRs fn get_or_create_directory_entry(&mut self, path: &Path) -> (&mut DirectoryEntry, usize) { if let Some(&index) = self.directory_path_to_directory_index_entry.get(path) { (&mut self.directory_entries[index], index) } else { let index = self.directory_entries.len(); self.directory_entries .push(DirectoryEntry::new(path.to_path_buf())); self.directory_path_to_directory_index_entry .insert(path.to_path_buf(), index); (&mut self.directory_entries[index], index) } } /// Converts directory_path + file_path + optional match_index to a RowIndex. /// Returns None if the paths are not found fn path_to_row_index( &self, directory_path: &Path, file_path: &Path, match_index: Option, ) -> Option { let &directory_index = self .directory_path_to_directory_index_entry .get(directory_path)?; let dir_entry = self.directory_entries.get(directory_index)?; let &path_index = dir_entry.matched_paths.index_by_path.get(file_path)?; let index_type = match match_index { None => RowIndexType::FileHeader { path_index }, Some(match_idx) => RowIndexType::Match { path_index, match_index: match_idx, }, }; Some(RowIndex { directory_index, index_type, }) } /// Gets the directory path for a given RowIndex. fn directory_path_for_row_index(&self, row: &RowIndex) -> Option<&PathBuf> { self.directory_entries .get(row.directory_index) .map(|e| &e.path) } /// Gets the file path for a given RowIndex (if it refers to a file or match). fn file_path_for_row_index(&self, row: &RowIndex) -> Option<&PathBuf> { let dir_entry = self.directory_entries.get(row.directory_index)?; match &row.index_type { RowIndexType::DirectoryHeader => None, RowIndexType::FileHeader { path_index } | RowIndexType::Match { path_index, .. } => { dir_entry .matched_paths .paths .get(*path_index) .map(|p| &p.path) } } } fn is_file_collapsed(&self, row: &RowIndex) -> bool { let Some(dir_entry) = self.directory_entries.get(row.directory_index) else { return false; }; match &row.index_type { RowIndexType::DirectoryHeader => false, RowIndexType::FileHeader { path_index } | RowIndexType::Match { path_index, .. } => { dir_entry .matched_paths .paths .get(*path_index) .map(|p| p.is_collapsed) .unwrap_or(false) } } } /// Checks if a directory is collapsed. fn is_directory_collapsed(&self, row: &RowIndex) -> bool { self.directory_entries .get(row.directory_index) .map(|d| d.is_collapsed) .unwrap_or(false) } fn render_results(&self, app: &AppContext) -> Option> { if self.directory_entries.is_empty() { return None; } let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let handle = self.handle.clone(); let item_count = self.items_count(); let build_items = move |range: Range, app: &AppContext| { { let view_handle = handle .upgrade(app) .expect("GlobalSearchView handle should be upgradeable"); let view = view_handle.as_ref(app); (range.start..range.end).map(|idx| view.render_row_at_index(idx, app)) } .collect::>() .into_iter() }; let list = UniformList::new(self.uniform_list_state.clone(), item_count, build_items); let scrollable = Scrollable::vertical( self.scroll_state.clone(), list.finish_scrollable(), ScrollbarWidth::Auto, theme.nonactive_ui_detail().into(), theme.active_ui_detail().into(), Fill::None, ) .with_overlayed_scrollbar() .finish(); Some(scrollable) } /// Converts a global index to a RowIndex fn row_index_for_global_index(&self, index: usize) -> Option { self.to_row_index(GlobalIndex(index)) } /// Returns the global index for the currently selected row. /// Uses to_global_index for O(directories) lookup. fn selected_row_index(&self) -> Option { let selected_row = self.selected_row.as_ref()?; // Validate the selection still exists in the data model let dir_entry = self.directory_entries.get(selected_row.directory_index)?; match &selected_row.index_type { RowIndexType::DirectoryHeader => {} RowIndexType::FileHeader { path_index } | RowIndexType::Match { path_index, .. } => { // Check path exists dir_entry.matched_paths.paths.get(*path_index)?; } } Some(self.to_global_index(selected_row)) } fn set_selected_row(&mut self, selected_row: RowIndex, ctx: &mut ViewContext) { let global_index = self.to_global_index(&selected_row); self.selected_row = Some(selected_row); self.uniform_list_state.scroll_to(global_index); ctx.notify(); } fn set_selected_row_by_index(&mut self, index: usize, ctx: &mut ViewContext) { let Some(row_index) = self.row_index_for_global_index(index) else { return; }; self.selected_row = Some(row_index); self.uniform_list_state.scroll_to(index); ctx.notify(); } /// Selects the first row (directory header at index 0). fn select_first_row(&mut self, ctx: &mut ViewContext) { if !self.directory_entries.is_empty() { let row_index = RowIndex { directory_index: 0, index_type: RowIndexType::DirectoryHeader, }; self.set_selected_row(row_index, ctx); } } fn ensure_selection(&mut self, ctx: &mut ViewContext) { // Validate current selection still exists if self.selected_row.is_some() && self.selected_row_index().is_none() { self.selected_row = None; } if self.selected_row.is_none() && !self.directory_entries.is_empty() { self.select_first_row(ctx); } } fn move_selection(&mut self, direction: SelectionDirection, ctx: &mut ViewContext) { let total_items = self.items_count_from_entries(); if total_items == 0 { return; } self.ensure_selection(ctx); let current_index = self .selected_row_index() .unwrap_or_else(|| total_items.saturating_sub(1)); let next_index = match direction { SelectionDirection::Up => current_index.saturating_sub(1), SelectionDirection::Down => current_index + 1, }; let max_index = total_items.saturating_sub(1); let clamped_next = next_index.min(max_index); self.set_selected_row_by_index(clamped_next, ctx); } fn activate_selected_row(&mut self, ctx: &mut ViewContext) { let Some(selected_row) = self.selected_row.clone() else { return; }; match &selected_row.index_type { RowIndexType::FileHeader { .. } => { if let (Some(dir_path), Some(file_path)) = ( self.directory_path_for_row_index(&selected_row).cloned(), self.file_path_for_row_index(&selected_row).cloned(), ) { self.toggle_file_collapsed(&dir_path, &file_path, ctx); } } RowIndexType::Match { path_index, match_index, } => { let Some(dir_entry) = self.directory_entries.get(selected_row.directory_index) else { return; }; let Some(matched_path) = dir_entry.matched_paths.paths.get(*path_index) else { return; }; let Some(matched) = matched_path.matches.get(*match_index) else { return; }; let column_num = Self::column_from_submatches(&matched.line_text, &matched.submatches); ctx.emit(Event::OpenMatch { path: matched_path.path.clone(), line_number: matched.line_number, column_num, }); } RowIndexType::DirectoryHeader => { if let Some(dir_path) = self.directory_path_for_row_index(&selected_row).cloned() { self.toggle_directory_collapsed(&dir_path, ctx); } } } } fn toggle_directory_collapsed(&mut self, directory_path: &Path, ctx: &mut ViewContext) { let Some(&dir_idx) = self .directory_path_to_directory_index_entry .get(directory_path) else { return; }; let Some(dir_entry) = self.directory_entries.get_mut(dir_idx) else { return; }; let was_collapsed = dir_entry.is_collapsed; dir_entry.is_collapsed = !was_collapsed; // If collapsing and selection was inside this directory, move to directory header if !was_collapsed { if let Some(selected_row) = self.selected_row.as_ref() { if selected_row.directory_index == dir_idx && !matches!(selected_row.index_type, RowIndexType::DirectoryHeader) { self.selected_row = Some(RowIndex { directory_index: dir_idx, index_type: RowIndexType::DirectoryHeader, }); } } } self.ensure_selection(ctx); ctx.notify(); } fn toggle_file_collapsed( &mut self, directory_path: &Path, file_path: &PathBuf, ctx: &mut ViewContext, ) { // Get directory index let Some(&dir_idx) = self .directory_path_to_directory_index_entry .get(directory_path) else { return; }; let Some(dir_entry) = self.directory_entries.get_mut(dir_idx) else { return; }; let Some(matched_path) = dir_entry.matched_paths.get_mut(file_path) else { return; }; let was_collapsed = matched_path.is_collapsed; matched_path.is_collapsed = !was_collapsed; // If collapsing and selection was on a match in this file, move to file header if !was_collapsed { if let Some(selected_row) = self.selected_row.as_ref() { if let RowIndexType::Match { path_index, .. } = &selected_row.index_type { // Check if the selection is in this file if let Some(dir_path) = self.directory_path_for_row_index(selected_row) { if let Some(sel_file_path) = self.file_path_for_row_index(selected_row) { if dir_path == directory_path && sel_file_path == file_path { self.selected_row = Some(RowIndex { directory_index: selected_row.directory_index, index_type: RowIndexType::FileHeader { path_index: *path_index, }, }); } } } } } } self.ensure_selection(ctx); ctx.notify(); } fn enter_query_mode(&mut self, ctx: &mut ViewContext) { self.set_query_mode_state(ctx); ctx.focus(&self.query_editor); } fn enter_results_mode(&mut self, ctx: &mut ViewContext) { if self.directory_entries.is_empty() { return; } self.focus_mode = FocusMode::ResultsList; self.query_editor.update(ctx, |editor, ctx| { editor.clear_selections(ctx); editor.set_interaction_state(InteractionState::Disabled, ctx); }); self.ensure_selection(ctx); ctx.focus_self(); ctx.notify(); } fn is_first_row_selected(&self) -> bool { matches!(self.selected_row_index(), Some(0)) } /// Checks if the row at the given global index is currently selected. fn is_row_at_index_selected(&self, global_index: usize) -> bool { self.selected_row_index() == Some(global_index) } fn render_directory_header_from_entry( &self, index: usize, dir_entry: &DirectoryEntry, appearance: &Appearance, theme: &galaxy_core::ui::theme::WarpTheme, ) -> Box { let is_selected = self.is_row_at_index_selected(index); let mouse_state = dir_entry.mouse_state.clone(); let match_count = dir_entry.total_match_count(); let is_collapsed = dir_entry.is_collapsed; let directory_path = &dir_entry.path; // Get the display name (last component of the path) let display_name = directory_path .file_name() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| directory_path.to_string_lossy().to_string()); let directory_path = directory_path.clone(); let directory_path_for_click = directory_path.clone(); let tooltip_text = directory_path.to_string_lossy().to_string(); Hoverable::new(mouse_state, move |mouse_state| { let list_highlight_state = ItemHighlightState::new(is_selected, mouse_state); // Chevron - dynamic based on collapse state let chevron_icon_enum = if is_collapsed { Icon::ChevronRight } else { Icon::ChevronDown }; let icon_size = 16.0; let chevron_color = list_highlight_state.text_and_icon_color(appearance); let chevron_icon = chevron_icon_enum .to_galaxyui_icon(ThemeFill::from(chevron_color)) .finish(); let chevron_icon = ConstrainedBox::new(chevron_icon) .with_width(icon_size) .with_height(icon_size) .finish(); let chevron_container = Container::new(chevron_icon).with_margin_right(8.).finish(); // Folder icon let icon_color = list_highlight_state.text_and_icon_color(appearance); let folder_icon = Icon::Folder .to_galaxyui_icon(ThemeFill::from(icon_color)) .finish(); let folder_icon_element = Container::new( ConstrainedBox::new(folder_icon) .with_width(icon_size) .with_height(icon_size) .finish(), ) .with_padding_right(8.) .finish(); // Directory name let text_color = list_highlight_state.text_and_icon_color(appearance); let directory_text = Text::new_inline(display_name.clone(), appearance.ui_font_family(), 14.) .with_color(text_color) .with_style(Properties::default()) .finish(); // Match count badge let count_text = Text::new_inline(match_count.to_string(), appearance.ui_font_family(), 12.0) .with_color(appearance.theme().background().into()) .finish(); let count_badge = Container::new(count_text) .with_horizontal_padding(4.) .with_vertical_padding(1.) .with_background_color(theme.terminal_colors().normal.yellow.into()) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) .finish(); let left_row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(chevron_container) .with_child(folder_icon_element) .with_child(Shrinkable::new(1.0, directory_text).finish()) .finish(); let header_row = Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(Shrinkable::new(1.0, left_row).finish()) .with_child(count_badge) .finish(); let mut row_with_padding = Container::new(header_row).with_padding(Padding::uniform(4.).with_left(0.)); // Align chevron with header text if let Some(background_fill) = list_highlight_state.background_color(appearance) { let corner_radius = list_highlight_state .corner_radius() .unwrap_or_else(|| CornerRadius::with_all(Radius::Pixels(4.))); row_with_padding = row_with_padding .with_background(background_fill) .with_corner_radius(corner_radius); } let base_row = row_with_padding.finish(); // Add tooltip on hover if mouse_state.is_hovered() { let tooltip = appearance .ui_builder() .tool_tip(tooltip_text.clone()) .build() .finish(); let mut stack = Stack::new().with_child(base_row); stack.add_positioned_overlay_child( tooltip, OffsetPositioning::offset_from_parent( vec2f(0., 4.), ParentOffsetBounds::WindowByPosition, ParentAnchor::BottomMiddle, ChildAnchor::TopLeft, ), ); stack.finish() } else { base_row } }) .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(GlobalSearchAction::ToggleDirectoryCollapsed { directory_path: directory_path_for_click.clone(), }); }) .finish() } } impl View for GlobalSearchView { fn ui_name() -> &'static str { "GlobalSearchView" } fn render(&self, app: &AppContext) -> Box { match self.enablement { CodingPanelEnablementState::RemoteSession { .. } => { return self.render_remote_state(app); } CodingPanelEnablementState::UnsupportedSession => { return self.render_unsupported_session_state(app); } CodingPanelEnablementState::Disabled => { return self.render_unavailable_state(app); } CodingPanelEnablementState::Enabled => {} } let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let search_label = Text::new_inline("Search", appearance.ui_font_family(), 14.) .with_color(blended_colors::text_sub(theme, theme.background())) .finish(); let editor_line_height = self .query_editor .as_ref(app) .line_height(app.font_cache(), appearance); let max_editor_height = QUERY_EDITOR_MAX_LINES as f32 * editor_line_height; let case_sensitivity_button = Container::new(ChildView::new(&self.case_sensitivity_button).finish()).finish(); let regex_button = Container::new(ChildView::new(&self.regex_button).finish()).finish(); let mut editor_row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( Shrinkable::new( 1.0, ConstrainedBox::new( Clipped::new(ChildView::new(&self.query_editor).finish()).finish(), ) .with_max_height(max_editor_height) .finish(), ) .finish(), ); editor_row.add_child(case_sensitivity_button); editor_row.add_child(regex_button); let editor_row = editor_row.finish(); let query_row_container = Container::new(editor_row) .with_padding(Padding::uniform(6.)) .with_border(Border::all(BORDER_WIDTH).with_border_fill(theme.surface_3())) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(BORDER_RADIUS))) .with_margin_top(4.) .with_margin_bottom(4.) .finish(); let query_row = EventHandler::new(query_row_container) .on_left_mouse_down(|ctx, _, _| { ctx.dispatch_typed_action(GlobalSearchAction::FocusQueryEditor); DispatchEventResult::StopPropagation }) .finish(); let mut header_column = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Start) .with_child(search_label) .with_child(query_row); let files = self.unique_match_count(); let file_word = if files == 1 { "file" } else { "files" }; let message = if self.is_search_in_progress && self.total_match_count == 0 { "".to_string() } else if !self.is_search_in_progress && self.total_match_count == 0 { "No results found. Review your gitignore files.".to_string() } else { match self.total_match_count { 1 => format!("1 result in {files} {file_word}"), n => format!("{n} results in {files} {file_word}"), } }; let match_text_styles = UiComponentStyles { font_family_id: Some(appearance.ui_font_family()), font_size: Some(12.), font_color: Some(blended_colors::text_disabled(theme, theme.background())), ..Default::default() }; let match_text = Span::new(message, match_text_styles) .with_soft_wrap() .build() .finish(); let capped_text_styles = UiComponentStyles { font_family_id: Some(appearance.ui_font_family()), font_size: Some(12.), font_color: Some(blended_colors::text_sub(theme, theme.background())), ..Default::default() }; let capped_message = "The result set only contains a subset of all matches. Be more specific in your search to narrow down results.".to_string(); let capped_text = Span::new(capped_message, capped_text_styles) .with_soft_wrap() .build() .finish(); if self.last_searched_pattern.is_some() { header_column = header_column.with_child(match_text); } if self.capped_matches { let alert_icon = ConstrainedBox::new( Icon::AlertTriangle .to_galaxyui_icon(ThemeFill::Solid( theme.terminal_colors().normal.yellow.into(), )) .finish(), ) .with_width(appearance.ui_font_size()) .with_height(appearance.ui_font_size()) .finish(); let alert_icon = Container::new(alert_icon).with_margin_right(4.).finish(); let capped_row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Start) .with_child(alert_icon) .with_child(Shrinkable::new(1.0, capped_text).finish()) .finish(); header_column = header_column.with_child(Container::new(capped_row).with_margin_top(4.0).finish()); } let header_section = Container::new(header_column.finish()) .with_padding(Padding::uniform(12.).with_top(4.).with_bottom(8.)) .finish(); let mut body = Flex::column() .with_main_axis_size(MainAxisSize::Max) .with_cross_axis_alignment(CrossAxisAlignment::Start) .with_child(header_section); let should_render_pre_search_zero_state = self.last_searched_pattern.is_none() && self.query_editor.as_ref(app).is_empty(app); if should_render_pre_search_zero_state { body = body.with_child(Shrinkable::new(1.0, self.render_pre_search_state(app)).finish()); } else if let Some(results) = self.render_results(app) { let results_section = Container::new(results) .with_horizontal_padding(12.) .finish(); body = body.with_child(Shrinkable::new(1.0, results_section).finish()); } let has_results = !self.directory_entries.is_empty(); EventHandler::new(body.finish()) .on_left_mouse_down(move |ctx, _, _| { if has_results { ctx.dispatch_typed_action(GlobalSearchAction::FocusResultsList); } else { ctx.dispatch_typed_action(GlobalSearchAction::FocusQueryEditor); } DispatchEventResult::StopPropagation }) .finish() } } impl GlobalSearchView { fn render_zero_state( &self, icon: Icon, title: impl Into>, body: impl Into>, app: &AppContext, ) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let main_column = Flex::column() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::Center) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( Container::new( ConstrainedBox::new( icon.to_galaxyui_icon(ThemeFill::Solid(internal_colors::neutral_6(theme))) .finish(), ) .with_width(24.) .with_height(24.) .finish(), ) .with_margin_bottom(12.) .finish(), ) .with_child( Text::new( title, appearance.ui_font_family(), appearance.ui_font_size() + 2., ) .with_style(Properties::default().weight(Weight::Semibold)) .with_color(theme.sub_text_color(theme.background()).into()) .finish(), ) .with_child( ConstrainedBox::new( Shrinkable::new( 1., Container::new( Shrinkable::new( 1., FormattedTextElement::from_str( body, appearance.ui_font_family(), appearance.ui_font_size() + 2., ) .with_alignment(TextAlignment::Center) .with_color(theme.disabled_text_color(theme.background()).into()) .finish(), ) .finish(), ) .finish(), ) .finish(), ) .with_max_width(425.) .finish(), ) .finish(); Container::new( Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::Center) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(Shrinkable::new(1., main_column).finish()) .finish(), ) .with_horizontal_margin(16.) .finish() } fn render_pre_search_state(&self, app: &AppContext) -> Box { self.render_zero_state( Icon::Search, "Global search", "Search in files across your current directories.", app, ) } fn render_unavailable_state(&self, app: &AppContext) -> Box { self.render_zero_state( Icon::AlertTriangle, "Global search unavailable", "Global search requries access to your local workspace. Open a new session or navigate to an active session to view.", app, ) } fn render_remote_state(&self, app: &AppContext) -> Box { self.render_zero_state( Icon::AlertTriangle, "Global search unavailable", "Global search requires access to your local workspace, which isn't supported in remote sessions", app, ) } fn render_unsupported_session_state(&self, app: &AppContext) -> Box { self.render_zero_state( Icon::AlertTriangle, "Global search unavailable", "Global search doesn't currently work in Git Bash or WSL.", app, ) } }