git_graph: Implement basic search functionality (#51886)

## 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>
This commit is contained in:
Anthony Eid 2026-03-27 11:10:01 -04:00 committed by GitHub
parent cb97ac48a8
commit 852b4fc5f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 550 additions and 44 deletions

3
Cargo.lock generated
View file

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

View file

@ -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")
}

View file

@ -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;

View file

@ -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()?;

View file

@ -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

View file

@ -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(&oid) = self.search_state.matches.get_index(prev_selection) else {
return;
};
let Some(selected_repository) = self.get_selected_repository(cx) 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;
};
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 {
return;
};
self.search_state.selected_index = Some(next_selection);
self.select_commit_by_sha(oid, cx);
}
self.select_entry(index, 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()

View file

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

View file

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

View file

@ -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()
}