diff --git a/Cargo.lock b/Cargo.lock index 5b30819007b3688ee50e92d4f7c1a6e63ec9b44b..edc6d7248d356404db79c66dcb360a0cf7677a6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7302,6 +7302,7 @@ dependencies = [ "anyhow", "collections", "db", + "editor", "feature_flags", "fs", "git", @@ -7311,9 +7312,11 @@ dependencies = [ "menu", "project", "rand 0.9.2", + "search", "serde_json", "settings", "smallvec", + "smol", "theme", "theme_settings", "time", diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 9c218c8e53f9a2135ee09fadc78f627e3960da54..38cb1e6b3c467dba4430767c2f4d6705c1d8b2aa 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -8,7 +8,7 @@ use git::{ repository::{ AskPassDelegate, Branch, CommitDataReader, CommitDetails, CommitOptions, FetchOptions, GRAPH_CHUNK_SIZE, GitRepository, GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, - LogSource, PushOptions, Remote, RepoPath, ResetMode, Worktree, + LogSource, PushOptions, Remote, RepoPath, ResetMode, SearchCommitArgs, Worktree, }, status::{ DiffTreeType, FileStatus, GitStatus, StatusCode, TrackedStatus, TreeDiff, TreeDiffStatus, @@ -1017,6 +1017,15 @@ impl GitRepository for FakeGitRepository { .boxed() } + fn search_commits( + &self, + _log_source: LogSource, + _search_args: SearchCommitArgs, + _request_tx: Sender, + ) -> BoxFuture<'_, Result<()>> { + async { bail!("search_commits not supported for FakeGitRepository") }.boxed() + } + fn commit_data_reader(&self) -> Result { anyhow::bail!("commit_data_reader not supported for FakeGitRepository") } diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 13745c1fdfc0523d850b95e45a81cae286a77a00..766378bf2e514d8a50348b608d52e9e764072f21 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -161,6 +161,14 @@ impl Oid { } } +impl TryFrom<&str> for Oid { + type Error = anyhow::Error; + + fn try_from(value: &str) -> std::prelude::v1::Result { + Oid::from_str(value) + } +} + impl FromStr for Oid { type Err = anyhow::Error; diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 728d84e34d59626585fcec72d023bcef4dc79249..036ceeb620e1aa0345b6f9a296c16069c0fa09bf 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -50,6 +50,10 @@ pub const REMOTE_CANCELLED_BY_USER: &str = "Operation cancelled by user"; /// %x00 - Null byte separator, used to split up commit data static GRAPH_COMMIT_FORMAT: &str = "--format=%H%x00%P%x00%D"; +/// Used to get commits that match with a search +/// %H - Full commit hash +static SEARCH_COMMIT_FORMAT: &str = "--format=%H"; + /// Number of commits to load per chunk for the git graph. pub const GRAPH_CHUNK_SIZE: usize = 1000; @@ -623,6 +627,11 @@ impl LogSource { } } +pub struct SearchCommitArgs { + pub query: SharedString, + pub case_sensitive: bool, +} + pub trait GitRepository: Send + Sync { fn reload_index(&self); @@ -875,6 +884,13 @@ pub trait GitRepository: Send + Sync { request_tx: Sender>>, ) -> BoxFuture<'_, Result<()>>; + fn search_commits( + &self, + log_source: LogSource, + search_args: SearchCommitArgs, + request_tx: Sender, + ) -> BoxFuture<'_, Result<()>>; + fn commit_data_reader(&self) -> Result; fn set_trusted(&self, trusted: bool); @@ -2696,6 +2712,61 @@ impl GitRepository for RealGitRepository { .boxed() } + fn search_commits( + &self, + log_source: LogSource, + search_args: SearchCommitArgs, + request_tx: Sender, + ) -> BoxFuture<'_, Result<()>> { + let git_binary = self.git_binary(); + + async move { + let git = git_binary?; + + let mut args = vec!["log", SEARCH_COMMIT_FORMAT, log_source.get_arg()?]; + + args.push("--fixed-strings"); + + if !search_args.case_sensitive { + args.push("--regexp-ignore-case"); + } + + args.push("--grep"); + args.push(search_args.query.as_str()); + + let mut command = git.build_command(&args); + command.stdout(Stdio::piped()); + command.stderr(Stdio::null()); + + let mut child = command.spawn()?; + let stdout = child.stdout.take().context("failed to get stdout")?; + let mut reader = BufReader::new(stdout); + + let mut line_buffer = String::new(); + + loop { + line_buffer.clear(); + let bytes_read = reader.read_line(&mut line_buffer).await?; + + if bytes_read == 0 { + break; + } + + let sha = line_buffer.trim_end_matches('\n'); + + if let Ok(oid) = Oid::from_str(sha) + && request_tx.send(oid).await.is_err() + { + break; + } + } + + child.status().await?; + Ok(()) + } + .boxed() + } + fn commit_data_reader(&self) -> Result { let git_binary = self.git_binary()?; diff --git a/crates/git_graph/Cargo.toml b/crates/git_graph/Cargo.toml index 24f2a02fe3679b947fdd0c5328da45cb2d8f8ae1..6aeaefe7e9b32ab01b19e6f9747f9128f3718edf 100644 --- a/crates/git_graph/Cargo.toml +++ b/crates/git_graph/Cargo.toml @@ -22,6 +22,7 @@ test-support = [ anyhow.workspace = true collections.workspace = true db.workspace = true +editor.workspace = true feature_flags.workspace = true git.workspace = true git_ui.workspace = true @@ -29,8 +30,10 @@ gpui.workspace = true language.workspace = true menu.workspace = true project.workspace = true +search.workspace = true settings.workspace = true smallvec.workspace = true +smol.workspace = true theme.workspace = true theme_settings.workspace = true time.workspace = true diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index be813848db4268be17b5bb0bc4ae3e59bfa4ff3c..1c2bdae6a193a8dce6e7e9e2d894fabdb9274b8b 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -1,16 +1,20 @@ -use collections::{BTreeMap, HashMap}; +use collections::{BTreeMap, HashMap, IndexSet}; +use editor::Editor; use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag}; use git::{ BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote, parse_git_remote_url, - repository::{CommitDiff, CommitFile, InitialGraphCommitData, LogOrder, LogSource, RepoPath}, + repository::{ + CommitDiff, CommitFile, InitialGraphCommitData, LogOrder, LogSource, RepoPath, + SearchCommitArgs, + }, status::{FileStatus, StatusCode, TrackedStatus}, }; use git_ui::{commit_tooltip::CommitAvatar, commit_view::CommitView, git_status_icon}; use gpui::{ AnyElement, App, Bounds, ClickEvent, ClipboardItem, Corner, DefiniteLength, DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, PathBuilder, Pixels, - Point, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task, + Point, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task, TextStyleRefinement, UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, point, prelude::*, px, uniform_list, }; @@ -23,6 +27,10 @@ use project::{ RepositoryEvent, RepositoryId, }, }; +use search::{ + SearchOption, SearchOptions, SearchSource, SelectNextMatch, SelectPreviousMatch, + ToggleCaseSensitive, +}; use settings::Settings; use smallvec::{SmallVec, smallvec}; use std::{ @@ -37,9 +45,9 @@ use theme::AccentColors; use theme_settings::ThemeSettings; use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem}; use ui::{ - ButtonLike, Chip, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, ScrollableHandle, - Table, TableColumnWidths, TableInteractionState, TableResizeBehavior, Tooltip, WithScrollbar, - prelude::*, + ButtonLike, Chip, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, HighlightedLabel, + ScrollableHandle, Table, TableColumnWidths, TableInteractionState, TableResizeBehavior, + Tooltip, WithScrollbar, prelude::*, }; use workspace::{ Workspace, @@ -198,6 +206,29 @@ impl ChangedFileEntry { } } +enum QueryState { + Pending(SharedString), + Confirmed((SharedString, Task<()>)), + Empty, +} + +impl QueryState { + fn next_state(&mut self) { + match self { + Self::Confirmed((query, _)) => *self = Self::Pending(std::mem::take(query)), + _ => {} + }; + } +} + +struct SearchState { + case_sensitive: bool, + editor: Entity, + state: QueryState, + pub matches: IndexSet, + pub selected_index: Option, +} + pub struct SplitState { left_ratio: f32, visible_left_ratio: f32, @@ -743,7 +774,7 @@ pub fn init(cx: &mut App) { let existing = workspace.items_of_type::(cx).next(); if let Some(existing) = existing { existing.update(cx, |graph, cx| { - graph.select_commit_by_sha(&sha, cx); + graph.select_commit_by_sha(sha.as_str(), cx); }); workspace.activate_item(&existing, true, true, window, cx); return; @@ -754,7 +785,7 @@ pub fn init(cx: &mut App) { let git_graph = cx.new(|cx| { let mut graph = GitGraph::new(project, workspace_handle, window, cx); - graph.select_commit_by_sha(&sha, cx); + graph.select_commit_by_sha(sha.as_str(), cx); graph }); workspace.add_item_to_active_pane( @@ -836,6 +867,7 @@ fn compute_diff_stats(diff: &CommitDiff) -> (usize, usize) { pub struct GitGraph { focus_handle: FocusHandle, + search_state: SearchState, graph_data: GraphData, project: Entity, workspace: WeakEntity, @@ -860,6 +892,14 @@ pub struct GitGraph { } impl GitGraph { + fn invalidate_state(&mut self, cx: &mut Context) { + self.graph_data.clear(); + self.search_state.matches.clear(); + self.search_state.selected_index = None; + self.search_state.state.next_state(); + cx.notify(); + } + fn row_height(cx: &App) -> Pixels { let settings = ThemeSettings::get_global(cx); let font_size = settings.buffer_font_size(cx); @@ -902,8 +942,7 @@ impl GitGraph { // todo(git_graph): Make this selectable from UI so we don't have to always use active repository if this.selected_repo_id != *changed_repo_id { this.selected_repo_id = *changed_repo_id; - this.graph_data.clear(); - cx.notify(); + this.invalidate_state(cx); } } _ => {} @@ -915,6 +954,12 @@ impl GitGraph { .active_repository(cx) .map(|repo| repo.read(cx).id); + let search_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Search commits…", window, cx); + editor + }); + let table_interaction_state = cx.new(|cx| TableInteractionState::new(cx)); let table_column_widths = cx.new(|cx| TableColumnWidths::new(4, cx)); let mut row_height = Self::row_height(cx); @@ -934,6 +979,13 @@ impl GitGraph { let mut this = GitGraph { focus_handle, + search_state: SearchState { + case_sensitive: false, + editor: search_editor, + matches: IndexSet::default(), + selected_index: None, + state: QueryState::Empty, + }, project, workspace, graph_data: graph, @@ -981,7 +1033,7 @@ impl GitGraph { .and_then(|data| data.commit_oid_to_index.get(&oid).copied()) }) { - self.select_entry(pending_sha_index, cx); + self.select_entry(pending_sha_index, ScrollStrategy::Nearest, cx); } } GitGraphEvent::LoadingError => { @@ -1017,7 +1069,7 @@ impl GitGraph { pending_sha_index }) { - self.select_entry(pending_selection_index, cx); + self.select_entry(pending_selection_index, ScrollStrategy::Nearest, cx); self.pending_select_sha.take(); } @@ -1031,8 +1083,7 @@ impl GitGraph { // meaning we are not inside the initial repo loading state // NOTE: this fixes an loading performance regression if repository.read(cx).scan_id > 1 { - self.graph_data.clear(); - cx.notify(); + self.invalidate_state(cx); } } RepositoryEvent::GraphEvent(_, _) => {} @@ -1129,6 +1180,7 @@ impl GitGraph { .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default()); let is_selected = self.selected_entry_idx == Some(idx); + let is_matched = self.search_state.matches.contains(&commit.data.sha); let column_label = |label: SharedString| { Label::new(label) .when(!is_selected, |c| c.color(Color::Muted)) @@ -1136,11 +1188,49 @@ impl GitGraph { .into_any_element() }; + let subject_label = if is_matched { + let query = match &self.search_state.state { + QueryState::Confirmed((query, _)) => Some(query.clone()), + _ => None, + }; + let highlight_ranges = query + .and_then(|q| { + let ranges = if self.search_state.case_sensitive { + subject + .match_indices(q.as_str()) + .map(|(start, matched)| start..start + matched.len()) + .collect::>() + } else { + let q = q.to_lowercase(); + let subject_lower = subject.to_lowercase(); + + subject_lower + .match_indices(&q) + .filter_map(|(start, matched)| { + let end = start + matched.len(); + subject.is_char_boundary(start).then_some(()).and_then( + |_| subject.is_char_boundary(end).then_some(start..end), + ) + }) + .collect::>() + }; + + (!ranges.is_empty()).then_some(ranges) + }) + .unwrap_or_default(); + HighlightedLabel::from_ranges(subject.clone(), highlight_ranges) + .when(!is_selected, |c| c.color(Color::Muted)) + .truncate() + .into_any_element() + } else { + column_label(subject.clone()) + }; + vec![ div() .id(ElementId::NamedInteger("commit-subject".into(), idx as u64)) .overflow_hidden() - .tooltip(Tooltip::text(subject.clone())) + .tooltip(Tooltip::text(subject)) .child( h_flex() .gap_2() @@ -1154,7 +1244,7 @@ impl GitGraph { .map(|name| self.render_chip(name, accent_color)), ) })) - .child(column_label(subject)), + .child(subject_label), ) .into_any_element(), column_label(formatted_time.into()), @@ -1173,12 +1263,16 @@ impl GitGraph { } fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context) { - self.select_entry(0, cx); + self.select_entry(0, ScrollStrategy::Nearest, cx); } fn select_prev(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context) { if let Some(selected_entry_idx) = &self.selected_entry_idx { - self.select_entry(selected_entry_idx.saturating_sub(1), cx); + self.select_entry( + selected_entry_idx.saturating_sub(1), + ScrollStrategy::Nearest, + cx, + ); } else { self.select_first(&SelectFirst, window, cx); } @@ -1190,6 +1284,7 @@ impl GitGraph { selected_entry_idx .saturating_add(1) .min(self.graph_data.commits.len().saturating_sub(1)), + ScrollStrategy::Nearest, cx, ); } else { @@ -1198,14 +1293,88 @@ impl GitGraph { } fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context) { - self.select_entry(self.graph_data.commits.len().saturating_sub(1), cx); + self.select_entry( + self.graph_data.commits.len().saturating_sub(1), + ScrollStrategy::Nearest, + cx, + ); } fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { self.open_selected_commit_view(window, cx); } - fn select_entry(&mut self, idx: usize, cx: &mut Context) { + fn search(&mut self, query: SharedString, cx: &mut Context) { + let Some(repo) = self.get_selected_repository(cx) else { + return; + }; + + self.search_state.matches.clear(); + self.search_state.selected_index = None; + self.search_state.editor.update(cx, |editor, _cx| { + editor.set_text_style_refinement(Default::default()); + }); + + let (request_tx, request_rx) = smol::channel::unbounded::(); + + repo.update(cx, |repo, cx| { + repo.search_commits( + self.log_source.clone(), + SearchCommitArgs { + query: query.clone(), + case_sensitive: self.search_state.case_sensitive, + }, + request_tx, + cx, + ); + }); + + let search_task = cx.spawn(async move |this, cx| { + while let Ok(first_oid) = request_rx.recv().await { + let mut pending_oids = vec![first_oid]; + while let Ok(oid) = request_rx.try_recv() { + pending_oids.push(oid); + } + + this.update(cx, |this, cx| { + if this.search_state.selected_index.is_none() { + this.search_state.selected_index = Some(0); + this.select_commit_by_sha(first_oid, cx); + } + + this.search_state.matches.extend(pending_oids); + cx.notify(); + }) + .ok(); + } + + this.update(cx, |this, cx| { + if this.search_state.matches.is_empty() { + this.search_state.editor.update(cx, |editor, cx| { + editor.set_text_style_refinement(TextStyleRefinement { + color: Some(Color::Error.color(cx)), + ..Default::default() + }); + }); + } + }) + .ok(); + }); + + self.search_state.state = QueryState::Confirmed((query, search_task)); + } + + fn confirm_search(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { + let query = self.search_state.editor.read(cx).text(cx).into(); + self.search(query, cx); + } + + fn select_entry( + &mut self, + idx: usize, + scroll_strategy: ScrollStrategy, + cx: &mut Context, + ) { if self.selected_entry_idx == Some(idx) { return; } @@ -1216,9 +1385,7 @@ impl GitGraph { self.changed_files_scroll_handle .scroll_to_item(0, ScrollStrategy::Top); self.table_interaction_state.update(cx, |state, cx| { - state - .scroll_handle - .scroll_to_item(idx, ScrollStrategy::Nearest); + state.scroll_handle.scroll_to_item(idx, scroll_strategy); cx.notify(); }); @@ -1249,25 +1416,71 @@ impl GitGraph { cx.notify(); } - pub fn select_commit_by_sha(&mut self, sha: &str, cx: &mut Context) { - let Ok(oid) = sha.parse::() else { + fn select_previous_match(&mut self, cx: &mut Context) { + if self.search_state.matches.is_empty() { return; - }; + } + + let mut prev_selection = self.search_state.selected_index.unwrap_or_default(); + + if prev_selection == 0 { + prev_selection = self.search_state.matches.len() - 1; + } else { + prev_selection -= 1; + } - let Some(selected_repository) = self.get_selected_repository(cx) else { + let Some(&oid) = self.search_state.matches.get_index(prev_selection) else { return; }; - let Some(index) = selected_repository - .read(cx) - .get_graph_data(self.log_source.clone(), self.log_order) - .and_then(|data| data.commit_oid_to_index.get(&oid)) - .copied() - else { + self.search_state.selected_index = Some(prev_selection); + self.select_commit_by_sha(oid, cx); + } + + fn select_next_match(&mut self, cx: &mut Context) { + if self.search_state.matches.is_empty() { + return; + } + + let mut next_selection = self + .search_state + .selected_index + .map(|index| index + 1) + .unwrap_or_default(); + + if next_selection >= self.search_state.matches.len() { + next_selection = 0; + } + + let Some(&oid) = self.search_state.matches.get_index(next_selection) else { return; }; - self.select_entry(index, cx); + self.search_state.selected_index = Some(next_selection); + self.select_commit_by_sha(oid, cx); + } + + pub fn select_commit_by_sha(&mut self, sha: impl TryInto, cx: &mut Context) { + fn inner(this: &mut GitGraph, oid: Oid, cx: &mut Context) { + let Some(selected_repository) = this.get_selected_repository(cx) else { + return; + }; + + let Some(index) = selected_repository + .read(cx) + .get_graph_data(this.log_source.clone(), this.log_order) + .and_then(|data| data.commit_oid_to_index.get(&oid)) + .copied() + else { + return; + }; + + this.select_entry(index, ScrollStrategy::Center, cx); + } + + if let Ok(oid) = sha.try_into() { + inner(self, oid, cx); + } } fn open_selected_commit_view(&mut self, window: &mut Window, cx: &mut Context) { @@ -1319,6 +1532,129 @@ impl GitGraph { }) } + fn render_search_bar(&self, cx: &mut Context) -> impl IntoElement { + let color = cx.theme().colors(); + let query_focus_handle = self.search_state.editor.focus_handle(cx); + let search_options = { + let mut options = SearchOptions::NONE; + options.set( + SearchOptions::CASE_SENSITIVE, + self.search_state.case_sensitive, + ); + options + }; + + h_flex() + .w_full() + .p_1p5() + .gap_1p5() + .border_b_1() + .border_color(color.border_variant) + .child( + h_flex() + .h_8() + .flex_1() + .min_w_0() + .px_1p5() + .gap_1() + .border_1() + .border_color(color.border) + .rounded_md() + .bg(color.toolbar_background) + .on_action(cx.listener(Self::confirm_search)) + .child(self.search_state.editor.clone()) + .child(SearchOption::CaseSensitive.as_button( + search_options, + SearchSource::Buffer, + query_focus_handle, + )), + ) + .child( + h_flex() + .min_w_64() + .gap_1() + .child({ + let focus_handle = self.focus_handle.clone(); + IconButton::new("git-graph-search-prev", IconName::ChevronLeft) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action_in( + "Select Previous Match", + &SelectPreviousMatch, + &focus_handle, + cx, + ) + }) + .map(|this| { + if self.search_state.matches.is_empty() { + this.disabled(true) + } else { + this.disabled(false).on_click(cx.listener(|this, _, _, cx| { + this.select_previous_match(cx); + })) + } + }) + }) + .child({ + let focus_handle = self.focus_handle.clone(); + IconButton::new("git-graph-search-next", IconName::ChevronRight) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action_in( + "Select Next Match", + &SelectNextMatch, + &focus_handle, + cx, + ) + }) + .map(|this| { + if self.search_state.matches.is_empty() { + this.disabled(true) + } else { + this.disabled(false).on_click(cx.listener(|this, _, _, cx| { + this.select_next_match(cx); + })) + } + }) + }) + .child( + h_flex() + .gap_1p5() + .child( + Label::new(format!( + "{}/{}", + self.search_state + .selected_index + .map(|index| index + 1) + .unwrap_or(0), + self.search_state.matches.len() + )) + .size(LabelSize::Small) + .when(self.search_state.matches.is_empty(), |this| { + this.color(Color::Disabled) + }), + ) + .when( + matches!( + &self.search_state.state, + QueryState::Confirmed((_, task)) if !task.is_ready() + ), + |this| { + this.child( + Icon::new(IconName::ArrowCircle) + .color(Color::Accent) + .size(IconSize::Small) + .with_rotate_animation(2) + .into_any_element(), + ) + }, + ), + ), + ) + } + fn render_loading_spinner(&self, cx: &App) -> AnyElement { let rems = TextSize::Large.rems(cx); Icon::new(IconName::LoadCircle) @@ -1361,7 +1697,8 @@ impl GitGraph { .copied() .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default()); - let (author_name, author_email, commit_timestamp, subject) = match &data { + // todo(git graph): We should use the full commit message here + let (author_name, author_email, commit_timestamp, commit_message) = match &data { CommitDataState::Loaded(data) => ( data.author_name.clone(), data.author_email.clone(), @@ -1617,7 +1954,7 @@ impl GitGraph { ), ) .child(Divider::horizontal()) - .child(div().min_w_0().p_2().child(Label::new(subject))) + .child(div().p_2().child(Label::new(commit_message))) .child(Divider::horizontal()) .child( v_flex() @@ -1977,7 +2314,7 @@ impl GitGraph { cx: &mut Context, ) { if let Some(row) = self.row_at_position(event.position().y, cx) { - self.select_entry(row, cx); + self.select_entry(row, ScrollStrategy::Nearest, cx); if event.click_count() >= 2 { self.open_commit_view(row, window, cx); } @@ -2068,6 +2405,12 @@ impl GitGraph { impl Render for GitGraph { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // This happens when we changed branches, we should refresh our search as well + if let QueryState::Pending(query) = &mut self.search_state.state { + let query = std::mem::take(query); + self.search_state.state = QueryState::Empty; + self.search(query, cx); + } let description_width_fraction = 0.72; let date_width_fraction = 0.12; let author_width_fraction = 0.10; @@ -2230,7 +2573,7 @@ impl Render for GitGraph { .on_click(move |event, window, cx| { let click_count = event.click_count(); weak.update(cx, |this, cx| { - this.select_entry(index, cx); + this.select_entry(index, ScrollStrategy::Center, cx); if click_count >= 2 { this.open_commit_view(index, window, cx); } @@ -2276,7 +2619,23 @@ impl Render for GitGraph { .on_action(cx.listener(Self::select_next)) .on_action(cx.listener(Self::select_last)) .on_action(cx.listener(Self::confirm)) - .child(content) + .on_action(cx.listener(|this, _: &SelectNextMatch, _window, cx| { + this.select_next_match(cx); + })) + .on_action(cx.listener(|this, _: &SelectPreviousMatch, _window, cx| { + this.select_previous_match(cx); + })) + .on_action(cx.listener(|this, _: &ToggleCaseSensitive, _window, cx| { + this.search_state.case_sensitive = !this.search_state.case_sensitive; + this.search_state.state.next_state(); + cx.notify(); + })) + .child( + v_flex() + .size_full() + .child(self.render_search_bar(cx)) + .child(div().flex_1().child(content)), + ) .children(self.context_menu.as_ref().map(|(menu, position, _)| { deferred( anchored() diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 346ebb1614e3b536d78765ce7ca90ad1e30f6bfc..f439c5da157cdcdaec813a1fd63ea119af78cb83 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -34,7 +34,7 @@ use git::{ repository::{ Branch, CommitDetails, CommitDiff, CommitFile, CommitOptions, DiffType, FetchOptions, GitRepository, GitRepositoryCheckpoint, GraphCommitData, InitialGraphCommitData, LogOrder, - LogSource, PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode, + LogSource, PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode, SearchCommitArgs, UpstreamTrackingStatus, Worktree as GitWorktree, }, stash::{GitStash, StashEntry}, @@ -4570,6 +4570,32 @@ impl Repository { self.initial_graph_data.get(&(log_source, log_order)) } + pub fn search_commits( + &mut self, + log_source: LogSource, + search_args: SearchCommitArgs, + request_tx: smol::channel::Sender, + cx: &mut Context, + ) { + let repository_state = self.repository_state.clone(); + + cx.background_spawn(async move { + let repo_state = repository_state.await; + + match repo_state { + Ok(RepositoryState::Local(LocalRepositoryState { backend, .. })) => { + backend + .search_commits(log_source, search_args, request_tx) + .await + .log_err(); + } + Ok(RepositoryState::Remote(_)) => {} + Err(_) => {} + }; + }) + .detach(); + } + pub fn graph_data( &mut self, log_source: LogSource, diff --git a/crates/search/src/search.rs b/crates/search/src/search.rs index d2104492bebf529821f8ad8571fd3fbb8bdbc69e..8edcdd600bd352d4e33c0c8c1ec9aed3f427c71c 100644 --- a/crates/search/src/search.rs +++ b/crates/search/src/search.rs @@ -85,7 +85,7 @@ pub enum SearchOption { Backwards, } -pub(crate) enum SearchSource<'a, 'b> { +pub enum SearchSource<'a, 'b> { Buffer, Project(&'a Context<'b, ProjectSearchBar>), } @@ -126,7 +126,7 @@ impl SearchOption { } } - pub(crate) fn as_button( + pub fn as_button( &self, active: SearchOptions, search_source: SearchSource, diff --git a/crates/ui/src/components/label/highlighted_label.rs b/crates/ui/src/components/label/highlighted_label.rs index 1b10d910dd0ed1501188781622851e720c0ca102..73e03f82dfdef38f10c62b69be3b75da8a24dd08 100644 --- a/crates/ui/src/components/label/highlighted_label.rs +++ b/crates/ui/src/components/label/highlighted_label.rs @@ -29,6 +29,33 @@ impl HighlightedLabel { } } + /// Constructs a label with the given byte ranges highlighted. + /// Assumes that the highlight ranges are valid UTF-8 byte positions. + pub fn from_ranges( + label: impl Into, + highlight_ranges: Vec>, + ) -> Self { + let label = label.into(); + let highlight_indices = highlight_ranges + .iter() + .flat_map(|range| { + let mut indices = Vec::new(); + let mut index = range.start; + while index < range.end { + indices.push(index); + index += label[index..].chars().next().map_or(0, |c| c.len_utf8()); + } + indices + }) + .collect(); + + Self { + base: LabelLike::new(), + label, + highlight_indices, + } + } + pub fn text(&self) -> &str { self.label.as_str() }