diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index d98e917d69ce59..9f87b1cb662077 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -702,21 +702,103 @@ pub enum LogSource { Branch(SharedString), Sha(Oid), Path(RepoPath), + Filtered { + source: Box, + options: GraphLogOptions, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct GraphLogOptions { + pub show_stashes: bool, + pub show_tags: bool, + pub include_reflog_commits: bool, + pub first_parent_only: bool, } impl LogSource { - fn get_arg(&self) -> Result<&str> { + pub fn with_graph_options(self, options: GraphLogOptions) -> Self { + let base_source = match self { + LogSource::Filtered { source, .. } => *source, + source => source, + }; + + if options == GraphLogOptions::default() { + base_source + } else { + LogSource::Filtered { + source: Box::new(base_source), + options, + } + } + } + + pub fn graph_options(&self) -> GraphLogOptions { match self { - LogSource::All => Ok("--all"), - LogSource::Branch(branch) => Ok(branch.as_str()), - LogSource::Sha(oid) => { - str::from_utf8(oid.as_bytes()).context("Failed to build str from sha") + LogSource::Filtered { options, .. } => *options, + _ => GraphLogOptions::default(), + } + } + + fn get_args(&self) -> Result> { + match self { + LogSource::All => Ok(vec!["--all".to_string()]), + LogSource::Branch(branch) => Ok(vec![branch.to_string()]), + LogSource::Sha(oid) => Ok(vec![ + str::from_utf8(oid.as_bytes()) + .context("Failed to build str from sha")? + .to_string(), + ]), + LogSource::Path(path) => Ok(vec![ + "--follow".to_string(), + "--".to_string(), + path.as_unix_str().to_string(), + ]), + LogSource::Filtered { source, options } => { + let mut args = options.get_args(); + args.extend(source.get_args()?); + Ok(args) } - LogSource::Path(_) => Ok("--follow"), } } } +impl Default for GraphLogOptions { + fn default() -> Self { + Self { + show_stashes: true, + show_tags: true, + include_reflog_commits: false, + first_parent_only: false, + } + } +} + +impl GraphLogOptions { + fn get_args(&self) -> Vec { + let mut args = Vec::new(); + + if !self.show_stashes { + args.push("--exclude=refs/stash".to_string()); + } + + if !self.show_tags { + args.push("--exclude=refs/tags".to_string()); + args.push("--exclude=refs/tags/*".to_string()); + } + + if self.include_reflog_commits { + args.push("--reflog".to_string()); + } + + if self.first_parent_only { + args.push("--first-parent".to_string()); + } + + args + } +} + pub struct SearchCommitArgs { pub query: SharedString, pub case_sensitive: bool, @@ -2928,17 +3010,15 @@ impl GitRepository for RealGitRepository { let git = git_binary?; let mut git_log_command = vec![ - "log", - GRAPH_COMMIT_FORMAT, - log_order.as_arg(), - log_source.get_arg()?, + "log".to_string(), + GRAPH_COMMIT_FORMAT.to_string(), + log_order.as_arg().to_string(), ]; + git_log_command.extend(log_source.get_args()?); + let git_log_command_ref: Vec<_> = + git_log_command.iter().map(|arg| arg.as_str()).collect(); - if let LogSource::Path(path) = &log_source { - git_log_command.extend(["--", path.as_unix_str()]); - } - - let mut command = git.build_command(&git_log_command); + let mut command = git.build_command(&git_log_command_ref); command.stdout(Stdio::piped()); command.stderr(Stdio::piped()); @@ -3009,22 +3089,21 @@ impl GitRepository for RealGitRepository { async move { let git = git_binary?; - let mut args = vec!["log", SEARCH_COMMIT_FORMAT, log_source.get_arg()?]; + let mut args = vec!["log".to_string(), SEARCH_COMMIT_FORMAT.to_string()]; + args.extend(log_source.get_args()?); - args.push("--fixed-strings"); + args.push("--fixed-strings".to_string()); if !search_args.case_sensitive { - args.push("--regexp-ignore-case"); + args.push("--regexp-ignore-case".to_string()); } - args.push("--grep"); - args.push(search_args.query.as_str()); + args.push("--grep".to_string()); + args.push(search_args.query.to_string()); - if let LogSource::Path(path) = &log_source { - args.extend(["--", path.as_unix_str()]); - } + let args_ref: Vec<_> = args.iter().map(|arg| arg.as_str()).collect(); - let mut command = git.build_command(&args); + let mut command = git.build_command(&args_ref); command.stdout(Stdio::piped()); command.stderr(Stdio::null()); @@ -4232,6 +4311,42 @@ mod tests { assert!(result[0].is_main); } + #[test] + fn test_graph_log_options_build_args() { + let options = GraphLogOptions { + show_stashes: false, + show_tags: false, + include_reflog_commits: true, + first_parent_only: true, + }; + + assert_eq!( + options.get_args(), + vec![ + "--exclude=refs/stash", + "--exclude=refs/tags", + "--exclude=refs/tags/*", + "--reflog", + "--first-parent", + ] + ); + } + + #[test] + fn test_filtered_log_source_prepends_graph_args_to_source() { + let source = LogSource::All.with_graph_options(GraphLogOptions { + show_stashes: false, + show_tags: true, + include_reflog_commits: true, + first_parent_only: false, + }); + + assert_eq!( + source.get_args().unwrap(), + vec!["--exclude=refs/stash", "--reflog", "--all"] + ); + } + #[gpui::test] async fn test_create_and_list_worktrees(cx: &mut TestAppContext) { disable_git_global_config(); diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index 73ad9293e17318..e5f836a051ff5b 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -4,8 +4,8 @@ use git::{ BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote, parse_git_remote_url, repository::{ - CommitDiff, CommitFile, InitialGraphCommitData, LogOrder, LogSource, RepoPath, - SearchCommitArgs, + CommitDiff, CommitFile, GraphLogOptions, InitialGraphCommitData, LogOrder, LogSource, + RepoPath, SearchCommitArgs, }, status::{FileStatus, StatusCode, TrackedStatus}, }; @@ -43,9 +43,10 @@ use std::{ use theme::AccentColors; use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem}; use ui::{ - ButtonLike, Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, - HeaderResizeInfo, HighlightedLabel, RedistributableColumnsState, ScrollableHandle, Table, - TableInteractionState, TableRenderContext, TableResizeBehavior, Tooltip, WithScrollbar, + ButtonLike, Checkbox, Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, DiffStat, + Divider, HeaderResizeInfo, HighlightedLabel, PopoverMenu, PopoverMenuHandle, + RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState, + TableRenderContext, TableResizeBehavior, ToggleState, Tooltip, WithScrollbar, bind_redistributable_columns, prelude::*, render_redistributable_columns_resize_handles, render_table_header, table_row::TableRow, }; @@ -232,6 +233,42 @@ struct SearchState { pub selected_index: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct GraphSettings { + show_stashes: bool, + show_tags: bool, + include_reflog_commits: bool, + first_parent_only: bool, +} + +impl Default for GraphSettings { + fn default() -> Self { + Self { + show_stashes: true, + show_tags: true, + include_reflog_commits: false, + first_parent_only: false, + } + } +} + +impl From for GraphLogOptions { + fn from(settings: GraphSettings) -> Self { + Self { + show_stashes: settings.show_stashes, + show_tags: settings.show_tags, + include_reflog_commits: settings.include_reflog_commits, + first_parent_only: settings.first_parent_only, + } + } +} + +#[derive(Default)] +struct SettingsDropdownState { + handle: PopoverMenuHandle, + settings: GraphSettings, +} + pub struct SplitState { left_ratio: f32, visible_left_ratio: f32, @@ -285,6 +322,16 @@ actions!( OpenCommitView, /// Focuses the search field. FocusSearch, + /// Opens the git graph settings menu. + ToggleSettingsDropdown, + /// Toggles whether stash commits are shown in the git graph. + ToggleShowStashes, + /// Toggles whether tags are shown in the git graph. + ToggleShowTags, + /// Toggles whether reflog commits are included in the git graph. + ToggleReflogCommits, + /// Toggles whether only first-parent history is shown in the git graph. + ToggleFirstParentOnly, /// Focuses the next git graph tab stop. FocusNextTabStop, /// Focuses the previous git graph tab stop. @@ -925,6 +972,17 @@ fn open_or_reuse_graph( workspace.add_item_to_active_pane(Box::new(git_graph), None, true, window, cx); } +fn base_log_source(log_source: &LogSource) -> &LogSource { + match log_source { + LogSource::Filtered { source, .. } => source, + source => source, + } +} + +fn is_path_history_source(log_source: &LogSource) -> bool { + matches!(base_log_source(log_source), LogSource::Path(_)) +} + fn lane_center_x(bounds: Bounds, lane: f32) -> Pixels { bounds.origin.x + LEFT_PADDING + lane * LANE_WIDTH + LANE_WIDTH / 2.0 } @@ -994,6 +1052,7 @@ struct GitGraphContextMenu { pub struct GitGraph { focus_handle: FocusHandle, search_state: SearchState, + settings_dropdown_state: SettingsDropdownState, graph_data: GraphData, git_store: Entity, workspace: WeakEntity, @@ -1065,7 +1124,7 @@ impl GitGraph { .read(cx) .preview_fractions(window.rem_size()); - let is_path_history = matches!(self.log_source, LogSource::Path(_)); + let is_path_history = is_path_history_source(&self.log_source); let graph_fraction = if is_path_history { 0.0 } else { fractions[0] }; let offset = if is_path_history { 0 } else { 1 }; @@ -1122,7 +1181,10 @@ impl GitGraph { let accent_colors = cx.theme().accents(); let graph = GraphData::new(accent_colors_count(accent_colors)); - let log_source = log_source.unwrap_or_default(); + let settings_dropdown_state = SettingsDropdownState::default(); + let log_source = log_source + .unwrap_or_default() + .with_graph_options(settings_dropdown_state.settings.into()); let log_order = LogOrder::default(); cx.subscribe(&git_store, |this, _, event, cx| match event { @@ -1149,7 +1211,7 @@ impl GitGraph { state }); - let column_widths = if matches!(log_source, LogSource::Path(_)) { + let column_widths = if is_path_history_source(&log_source) { cx.new(|_cx| { RedistributableColumnsState::new( 4, @@ -1215,6 +1277,7 @@ impl GitGraph { selected_index: None, state: QueryState::Empty, }, + settings_dropdown_state, workspace, graph_data: graph, _commit_diff_task: None, @@ -1311,7 +1374,9 @@ impl GitGraph { self.invalidate_state(cx); } } - RepositoryEvent::StashEntriesChanged if self.log_source == LogSource::All => { + RepositoryEvent::StashEntriesChanged + if base_log_source(&self.log_source) == &LogSource::All => + { // Stash entries initial's scan id is 2, so we don't want to invalidate the graph before that if repository.read(cx).scan_id > 2 { self.pending_select_sha = None; @@ -1339,6 +1404,52 @@ impl GitGraph { git_store.repositories().get(&self.repo_id).cloned() } + fn is_visible_ref_name(&self, ref_name: &str) -> bool { + if !self.settings_dropdown_state.settings.show_tags + && (ref_name.starts_with("tag: ") || ref_name.starts_with("refs/tags/")) + { + return false; + } + + if !self.settings_dropdown_state.settings.show_stashes + && (ref_name == "refs/stash" + || ref_name == "stash" + || ref_name.starts_with("stash@{") + || ref_name.contains("refs/stash")) + { + return false; + } + + true + } + + fn visible_ref_names(&self, ref_names: &[SharedString]) -> Vec { + ref_names + .iter() + .filter(|name| self.is_visible_ref_name(name)) + .cloned() + .collect() + } + + fn update_graph_settings( + &mut self, + update: impl FnOnce(&mut GraphSettings), + cx: &mut Context, + ) { + let mut settings = self.settings_dropdown_state.settings; + update(&mut settings); + + if settings == self.settings_dropdown_state.settings { + return; + } + + self.settings_dropdown_state.settings = settings; + self.log_source = self.log_source.clone().with_graph_options(settings.into()); + self.pending_select_sha = None; + self.invalidate_state(cx); + self.fetch_initial_graph_data(cx); + } + fn has_context_menu(&self) -> bool { self.context_menu.is_some() } @@ -1445,6 +1556,7 @@ impl GitGraph { .get(commit.color_idx) .copied() .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default()); + let visible_ref_names = self.visible_ref_names(&commit.data.ref_names); let is_selected = self.selected_entry_idx == Some(idx); let is_matched = self.search_state.matches.contains(&commit.data.sha); @@ -1504,8 +1616,8 @@ impl GitGraph { h_flex() .gap_2() .overflow_hidden() - .children((!commit.data.ref_names.is_empty()).then(|| { - h_flex().gap_1().children(commit.data.ref_names.iter().map( + .children((!visible_ref_names.is_empty()).then(|| { + h_flex().gap_1().children(visible_ref_names.iter().map( |name| { let is_head = Self::is_head_ref(name.as_ref(), &head_branch_name); @@ -1975,6 +2087,76 @@ impl GitGraph { }) } + fn render_settings_button(&self, _cx: &mut Context) -> PopoverMenu { + let settings = self.settings_dropdown_state.settings; + + let render_setting = |id_suffix: &'static str, label: &'static str, enabled: bool| { + move |_window: &mut Window, _cx: &mut App| { + Checkbox::new( + format!("git-graph-settings-checkbox-{id_suffix}"), + if enabled { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .label(label) + .label_size(LabelSize::Small) + .label_color(Color::Default) + .visualization_only(true) + .into_any_element() + } + }; + + PopoverMenu::new("git-graph-settings") + .trigger_with_tooltip( + IconButton::new("toggle-git-graph-settings", IconName::Settings) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .style(ButtonStyle::Subtle) + .toggle_state(self.settings_dropdown_state.handle.is_deployed()), + Tooltip::text("Git Graph Settings"), + ) + .anchor(Anchor::TopRight) + .with_handle(self.settings_dropdown_state.handle.clone()) + .menu(move |window, cx| { + Some(ContextMenu::build(window, cx, move |menu, _window, _cx| { + menu.custom_entry( + render_setting("show-stashes", "Show Stashes", settings.show_stashes), + move |window, cx| { + window.dispatch_action(Box::new(ToggleShowStashes), cx); + }, + ) + .custom_entry( + render_setting("show-tags", "Show Tags", settings.show_tags), + move |window, cx| { + window.dispatch_action(Box::new(ToggleShowTags), cx); + }, + ) + .custom_entry( + render_setting( + "include-reflog-commits", + "Include Reflog Commits", + settings.include_reflog_commits, + ), + move |window, cx| { + window.dispatch_action(Box::new(ToggleReflogCommits), cx); + }, + ) + .custom_entry( + render_setting( + "first-parent-only", + "First Parent Only", + settings.first_parent_only, + ), + move |window, cx| { + window.dispatch_action(Box::new(ToggleFirstParentOnly), cx); + }, + ) + })) + }) + } + fn render_search_bar(&self, cx: &mut Context) -> impl IntoElement { let color = cx.theme().colors(); let query_focus_handle = self @@ -2022,6 +2204,7 @@ impl GitGraph { query_focus_handle, )), ) + .child(self.render_settings_button(cx)) .child( h_flex() .min_w_64() @@ -2141,7 +2324,7 @@ impl GitGraph { }); let full_sha: SharedString = commit_entry.data.sha.to_string().into(); - let ref_names = commit_entry.data.ref_names.clone(); + let ref_names = self.visible_ref_names(&commit_entry.data.ref_names); let head_branch_name: Option = repository .read(cx) @@ -3000,7 +3183,7 @@ impl Render for GitGraph { this.child(self.render_loading_spinner(cx)) }) } else { - let is_path_history = matches!(self.log_source, LogSource::Path(_)); + let is_path_history = is_path_history_source(&self.log_source); let header_resize_info = HeaderResizeInfo::from_redistributable(&self.column_widths, cx); let header_context = TableRenderContext::for_column_widths( @@ -3250,6 +3433,9 @@ impl Render for GitGraph { .update(cx, |editor, cx| editor.focus_handle(cx).focus(window, cx)); this.activate_search_editor_if_focused(window, cx); })) + .on_action(cx.listener(|this, _: &ToggleSettingsDropdown, window, cx| { + this.settings_dropdown_state.handle.toggle(window, cx); + })) .on_action(cx.listener(Self::select_first)) .on_action(cx.listener(Self::select_prev)) .on_action(cx.listener(Self::select_next)) @@ -3271,6 +3457,29 @@ impl Render for GitGraph { cx.emit(ItemEvent::Edit); cx.notify(); })) + .on_action(cx.listener(|this, _: &ToggleShowStashes, _window, cx| { + this.update_graph_settings( + |settings| settings.show_stashes = !settings.show_stashes, + cx, + ); + })) + .on_action(cx.listener(|this, _: &ToggleShowTags, _window, cx| { + this.update_graph_settings(|settings| settings.show_tags = !settings.show_tags, cx); + })) + .on_action(cx.listener(|this, _: &ToggleReflogCommits, _window, cx| { + this.update_graph_settings( + |settings| { + settings.include_reflog_commits = !settings.include_reflog_commits; + }, + cx, + ); + })) + .on_action(cx.listener(|this, _: &ToggleFirstParentOnly, _window, cx| { + this.update_graph_settings( + |settings| settings.first_parent_only = !settings.first_parent_only, + cx, + ); + })) .child( v_flex() .size_full() @@ -3315,7 +3524,7 @@ impl Item for GitGraph { .file_name() .map(|name| name.to_string_lossy().to_string()) }); - let path_history_path = match &self.log_source { + let path_history_path = match base_log_source(&self.log_source) { LogSource::Path(path) => Some(path.as_unix_str().to_string()), _ => None, }; @@ -3340,7 +3549,7 @@ impl Item for GitGraph { } fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString { - if let LogSource::Path(path) = &self.log_source { + if let LogSource::Path(path) = base_log_source(&self.log_source) { return path .as_ref() .file_name() @@ -3537,6 +3746,7 @@ impl workspace::SerializableItem for GitGraph { } mod persistence { + use super::base_log_source; use std::{path::PathBuf, str::FromStr}; use db::{ @@ -3594,20 +3804,22 @@ mod persistence { pub const LOG_ORDER_REVERSE: i32 = 3; pub fn serialize_log_source_type(log_source: &LogSource) -> i32 { - match log_source { + match base_log_source(log_source) { LogSource::All => LOG_SOURCE_ALL, LogSource::Branch(_) => LOG_SOURCE_BRANCH, LogSource::Sha(_) => LOG_SOURCE_SHA, LogSource::Path(_) => LOG_SOURCE_PATH, + LogSource::Filtered { .. } => LOG_SOURCE_ALL, } } pub fn serialize_log_source_value(log_source: &LogSource) -> Option { - match log_source { + match base_log_source(log_source) { LogSource::All => None, LogSource::Branch(branch) => Some(branch.to_string()), LogSource::Sha(oid) => Some(oid.to_string()), LogSource::Path(path) => Some(path.as_unix_str().to_string()), + LogSource::Filtered { .. } => None, } } diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 20facc32640bf9..5f786409429542 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -8225,12 +8225,20 @@ fn deserialize_blame_buffer_response( } fn log_source_to_proto(log_source: &LogSource) -> proto::GitLogSource { + let base_source = match log_source { + LogSource::Filtered { source, .. } => source.as_ref(), + source => source, + }; + proto::GitLogSource { - source: Some(match log_source { + source: Some(match base_source { LogSource::All => proto::git_log_source::Source::All(proto::GitLogSourceAll {}), LogSource::Branch(branch) => proto::git_log_source::Source::Branch(branch.to_string()), LogSource::Sha(sha) => proto::git_log_source::Source::Sha(sha.to_string()), LogSource::Path(path) => proto::git_log_source::Source::Path(path.to_proto()), + LogSource::Filtered { .. } => { + proto::git_log_source::Source::All(proto::GitLogSourceAll {}) + } }), } }