mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
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:
parent
cb97ac48a8
commit
852b4fc5f4
9 changed files with 550 additions and 44 deletions
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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()?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue