2282 lines
82 KiB
Rust
2282 lines
82 KiB
Rust
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<usize>,
|
|
},
|
|
ToggleFileCollapsed {
|
|
directory_path: PathBuf,
|
|
file_path: PathBuf,
|
|
},
|
|
ToggleDirectoryCollapsed {
|
|
directory_path: PathBuf,
|
|
},
|
|
OpenMatch {
|
|
path: PathBuf,
|
|
line_number: u32,
|
|
column_num: Option<usize>,
|
|
},
|
|
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<RipgrepMatch>,
|
|
},
|
|
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<usize>,
|
|
},
|
|
}
|
|
|
|
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<MatchedPath>,
|
|
index_by_path: HashMap<PathBuf, usize>,
|
|
}
|
|
|
|
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<Match>,
|
|
}
|
|
|
|
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<Submatch>,
|
|
mouse_state: MouseStateHandle,
|
|
}
|
|
|
|
impl Match {
|
|
fn new(line_text: String, line_number: u32, submatches: Vec<Submatch>) -> Self {
|
|
Self {
|
|
line_text,
|
|
line_number,
|
|
submatches,
|
|
mouse_state: MouseStateHandle::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct GlobalSearchView {
|
|
find_model: ModelHandle<GlobalSearch>,
|
|
query_editor: ViewHandle<EditorView>,
|
|
query_change_tx: Sender<()>,
|
|
/// All terminal working directories for display grouping (preserved as-is)
|
|
root_directories: Vec<PathBuf>,
|
|
/// Deduplicated roots for ripgrep search (excludes nested subdirectories)
|
|
search_roots: Vec<PathBuf>,
|
|
last_searched_pattern: Option<String>,
|
|
directory_entries: Vec<DirectoryEntry>,
|
|
directory_path_to_directory_index_entry: HashMap<PathBuf, usize>,
|
|
selected_row: Option<RowIndex>,
|
|
total_match_count: usize,
|
|
is_search_in_progress: bool,
|
|
capped_matches: bool,
|
|
last_error: Option<String>,
|
|
scroll_state: ScrollStateHandle,
|
|
uniform_list_state: UniformListState,
|
|
handle: WeakViewHandle<GlobalSearchView>,
|
|
focus_mode: FocusMode,
|
|
enablement: CodingPanelEnablementState,
|
|
current_search_id: Option<u32>,
|
|
regex_search_enabled: bool,
|
|
regex_button: ViewHandle<ActionButton>,
|
|
case_sensitivity_enabled: bool,
|
|
case_sensitivity_button: ViewHandle<ActionButton>,
|
|
}
|
|
|
|
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<Self>) {
|
|
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<usize> {
|
|
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<usize> {
|
|
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<usize>,
|
|
) -> (String, Vec<usize>) {
|
|
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>) -> 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<Self>,
|
|
) {
|
|
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>) {
|
|
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<Item = &'a PathBuf> {
|
|
self.root_directories
|
|
.iter()
|
|
.filter(move |root| file_path.starts_with(root))
|
|
}
|
|
|
|
fn apply_progress_item(&mut self, result: RipgrepMatch, ctx: &mut ViewContext<Self>) {
|
|
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("<unknown>"));
|
|
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>) {
|
|
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>) {
|
|
self.rerun_search_from_query(ctx, false);
|
|
}
|
|
|
|
fn handle_query_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
|
|
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<Self>, 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<Self>) {
|
|
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<PathBuf>, _ctx: &mut ViewContext<Self>) {
|
|
// 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>) {
|
|
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<Self>,
|
|
) {
|
|
if self.enablement == enablement {
|
|
return;
|
|
}
|
|
self.enablement = enablement;
|
|
ctx.notify();
|
|
}
|
|
|
|
fn render_row_at_index(&self, index: usize, app: &AppContext) -> Box<dyn Element> {
|
|
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<dyn Element> {
|
|
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<dyn Element> {
|
|
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<RowIndex> {
|
|
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<RowIndex> {
|
|
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::<usize>()
|
|
}
|
|
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::<usize>()
|
|
+ 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<usize>,
|
|
) -> Option<RowIndex> {
|
|
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<Box<dyn Element>> {
|
|
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<usize>, 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::<Vec<_>>()
|
|
.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<RowIndex> {
|
|
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<usize> {
|
|
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<Self>) {
|
|
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<Self>) {
|
|
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<Self>) {
|
|
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<Self>) {
|
|
// 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<Self>) {
|
|
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<Self>) {
|
|
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<Self>) {
|
|
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<Self>,
|
|
) {
|
|
// 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>) {
|
|
self.set_query_mode_state(ctx);
|
|
ctx.focus(&self.query_editor);
|
|
}
|
|
|
|
fn enter_results_mode(&mut self, ctx: &mut ViewContext<Self>) {
|
|
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<dyn Element> {
|
|
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<dyn Element> {
|
|
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<Cow<'static, str>>,
|
|
body: impl Into<Cow<'static, str>>,
|
|
app: &AppContext,
|
|
) -> Box<dyn Element> {
|
|
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<dyn Element> {
|
|
self.render_zero_state(
|
|
Icon::Search,
|
|
"Global search",
|
|
"Search in files across your current directories.",
|
|
app,
|
|
)
|
|
}
|
|
|
|
fn render_unavailable_state(&self, app: &AppContext) -> Box<dyn Element> {
|
|
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<dyn Element> {
|
|
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<dyn Element> {
|
|
self.render_zero_state(
|
|
Icon::AlertTriangle,
|
|
"Global search unavailable",
|
|
"Global search doesn't currently work in Git Bash or WSL.",
|
|
app,
|
|
)
|
|
}
|
|
}
|