git_graph: Implement basic search functionality (#51886)

Anthony Eid , Remco Smits , and Danilo Leal created

## Context

This uses `git log` to get a basic search working in the git graph. This
is one of the last blockers until a full release, the others being
improvements to the graph canvas UI.

## Self-Review Checklist

<!-- Check before requesting review: -->
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A

---------

Co-authored-by: Remco Smits <djsmits12@gmail.com>
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>

Change summary

Cargo.lock                                          |   3 
crates/fs/src/fake_git_repo.rs                      |  11 
crates/git/src/git.rs                               |   8 
crates/git/src/repository.rs                        |  71 ++
crates/git_graph/Cargo.toml                         |   3 
crates/git_graph/src/git_graph.rs                   | 437 +++++++++++++-
crates/project/src/git_store.rs                     |  28 
crates/search/src/search.rs                         |   4 
crates/ui/src/components/label/highlighted_label.rs |  27 
9 files changed, 549 insertions(+), 43 deletions(-)

Detailed changes

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",

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<Oid>,
+    ) -> BoxFuture<'_, Result<()>> {
+        async { bail!("search_commits not supported for FakeGitRepository") }.boxed()
+    }
+
     fn commit_data_reader(&self) -> Result<CommitDataReader> {
         anyhow::bail!("commit_data_reader not supported for FakeGitRepository")
     }

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<Self, Self::Error> {
+        Oid::from_str(value)
+    }
+}
+
 impl FromStr for Oid {
     type Err = anyhow::Error;
 

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<Vec<Arc<InitialGraphCommitData>>>,
     ) -> BoxFuture<'_, Result<()>>;
 
+    fn search_commits(
+        &self,
+        log_source: LogSource,
+        search_args: SearchCommitArgs,
+        request_tx: Sender<Oid>,
+    ) -> BoxFuture<'_, Result<()>>;
+
     fn commit_data_reader(&self) -> Result<CommitDataReader>;
 
     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<Oid>,
+    ) -> 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<CommitDataReader> {
         let git_binary = self.git_binary()?;
 

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

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<Editor>,
+    state: QueryState,
+    pub matches: IndexSet<Oid>,
+    pub selected_index: Option<usize>,
+}
+
 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::<GitGraph>(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<Project>,
     workspace: WeakEntity<Workspace>,
@@ -860,6 +892,14 @@ pub struct GitGraph {
 }
 
 impl GitGraph {
+    fn invalidate_state(&mut self, cx: &mut Context<Self>) {
+        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::<Vec<_>>()
+                            } 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::<Vec<_>>()
+                            };
+
+                            (!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>) {
-        self.select_entry(0, cx);
+        self.select_entry(0, ScrollStrategy::Nearest, cx);
     }
 
     fn select_prev(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
         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>) {
-        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>) {
         self.open_selected_commit_view(window, cx);
     }
 
-    fn select_entry(&mut self, idx: usize, cx: &mut Context<Self>) {
+    fn search(&mut self, query: SharedString, cx: &mut Context<Self>) {
+        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::<Oid>();
+
+        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<Self>) {
+        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<Self>,
+    ) {
         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<Self>) {
-        let Ok(oid) = sha.parse::<Oid>() else {
+    fn select_previous_match(&mut self, cx: &mut Context<Self>) {
+        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<Self>) {
+        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<Oid>, cx: &mut Context<Self>) {
+        fn inner(this: &mut GitGraph, oid: Oid, cx: &mut Context<GitGraph>) {
+            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<Self>) {
@@ -1319,6 +1532,129 @@ impl GitGraph {
         })
     }
 
+    fn render_search_bar(&self, cx: &mut Context<Self>) -> 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<Self>,
     ) {
         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<Self>) -> 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()

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<Oid>,
+        cx: &mut Context<Self>,
+    ) {
+        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,

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,

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<SharedString>,
+        highlight_ranges: Vec<Range<usize>>,
+    ) -> 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()
     }