From 0188a3a952cfd58057434ddf0aebb636046c1f56 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Tue, 14 Jul 2026 15:16:37 -0400 Subject: [PATCH] Surface the git graph context menu in the git panel's history tab (#60713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR refactors the Git Graph’s commit context menu into its own file so it can be reused by both the Git Graph and the Git panel’s History tab. SCR-20260709-qlxt Also, includes a "Show in Git Graph" option only shown in the history tab's context menu: https://github.com/user-attachments/assets/2e30648b-2802-4caa-a99e-2d135e2dc539 Release Notes: - Surfaced the git graph context menu in the git panel's history tab. --- crates/git_ui/src/commit_context_menu.rs | 243 +++++++++++++++++++++ crates/git_ui/src/git_graph.rs | 256 +++-------------------- crates/git_ui/src/git_panel.rs | 90 ++++++-- crates/git_ui/src/git_ui.rs | 1 + 4 files changed, 353 insertions(+), 237 deletions(-) create mode 100644 crates/git_ui/src/commit_context_menu.rs diff --git a/crates/git_ui/src/commit_context_menu.rs b/crates/git_ui/src/commit_context_menu.rs new file mode 100644 index 0000000000..e3affe8732 --- /dev/null +++ b/crates/git_ui/src/commit_context_menu.rs @@ -0,0 +1,243 @@ +use crate::commit_view::CommitView; +use git::Oid; +use gpui::{Action, ClipboardItem, Entity, FocusHandle, SharedString, WeakEntity, Window, actions}; +use project::{GIT_COMMAND_TASK_TAG, git_store::Repository}; + +use task::{TaskContext, TaskVariables, VariableName}; +use ui::{Color, ContextMenu, ContextMenuEntry, IconName, IconPosition, prelude::*}; +use workspace::Workspace; + +actions!( + git_graph, + [ + /// Copies the SHA of the selected commit to the clipboard. + CopyCommitSha, + /// Copies a tag from the selected commit to the clipboard. + CopyCommitTag, + /// Opens the commit view for the selected commit. + OpenCommitView, + ] +); + +const COMMIT_TAG_LIST_WIDTH_IN_REMS: Rems = rems(10.); +const CUSTOM_GIT_COMMANDS_DOCS_SLUG: &str = "tasks#custom-git-commands"; + +pub(crate) struct CommitContextMenuData { + pub(crate) sha: Oid, + pub(crate) tag_names: Vec, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum CommitContextMenuSource { + GitGraph, + GitPanel, +} + +pub(crate) fn commit_context_menu( + commit: CommitContextMenuData, + source: CommitContextMenuSource, + ref_name: Option, + focus_handle: FocusHandle, + repository: Option>, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, +) -> Entity { + let sha = commit.sha; + let sha_short = sha.display_short(); + let git_tasks = git_context_menu_tasks( + git_task_context(&repository, sha, ref_name.as_deref(), cx), + &workspace, + cx, + ); + let header = match &ref_name { + Some(ref_name) => format!("Ref {ref_name}"), + None => format!("Commit {sha_short}"), + }; + + ContextMenu::build(window, cx, move |context_menu, _, _| { + context_menu + .context(focus_handle) + .header(header) + .entry("View Commit", Some(OpenCommitView.boxed_clone()), { + let repository = repository.clone(); + let workspace = workspace.clone(); + move |window, cx| { + let Some(repository) = repository.clone() else { + return; + }; + CommitView::open( + sha.to_string(), + repository, + workspace.clone(), + None, + None, + window, + cx, + ); + } + }) + .entry( + "Copy SHA", + Some(CopyCommitSha.boxed_clone()), + move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(sha.to_string())); + }, + ) + .when_some(ref_name.clone(), |menu, ref_name| { + menu.entry("Copy Ref Name", None, move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(ref_name.to_string())); + }) + }) + .when(ref_name.is_none(), |menu| { + menu.map(|menu| { + let tag_names = commit.tag_names.clone(); + let copy_tag_label = "Copy Tag"; + + match tag_names.as_slice() { + [] => menu.item( + ContextMenuEntry::new(copy_tag_label) + .action(CopyCommitTag.boxed_clone()) + .disabled(true), + ), + [tag_name] => { + let tag_name = tag_name.clone(); + let label = format!("{copy_tag_label}: {tag_name}"); + menu.entry( + label, + Some(CopyCommitTag.boxed_clone()), + move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + tag_name.to_string(), + )); + }, + ) + } + _ => menu.submenu(copy_tag_label, move |menu, _window, _cx| { + let mut menu = menu.fixed_width(COMMIT_TAG_LIST_WIDTH_IN_REMS.into()); + + for tag_name in tag_names.clone() { + let tag_name_to_copy = tag_name.clone(); + menu = menu.entry(tag_name, None, move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + tag_name_to_copy.to_string(), + )); + }); + } + menu + }), + } + }) + }) + .when(source == CommitContextMenuSource::GitPanel, |menu| { + menu.entry("Show in Git Graph", None, move |window, cx| { + window.dispatch_action( + Box::new(crate::git_graph::OpenAtCommit { + sha: sha.to_string(), + }), + cx, + ); + }) + }) + .map(|mut menu| { + menu = menu.separator().header("Custom Commands"); + + if git_tasks.is_empty() { + return menu.item( + ContextMenuEntry::new("Learn More") + .icon(IconName::ArrowUpRight) + .icon_color(Color::Muted) + .icon_position(IconPosition::End) + .handler(|_window, cx| { + let docs_url = + release_channel::docs_url(CUSTOM_GIT_COMMANDS_DOCS_SLUG, cx); + cx.open_url(&docs_url); + }), + ); + } + + for (task_source_kind, resolved_task) in git_tasks { + let label = resolved_task.display_label().to_string(); + let workspace = workspace.clone(); + menu = menu.entry(label, None, move |window, cx| { + workspace + .update(cx, |workspace, cx| { + workspace.schedule_resolved_task( + task_source_kind.clone(), + resolved_task.clone(), + false, + window, + cx, + ); + }) + .ok(); + }); + } + + menu + }) + }) +} + +fn git_task_context( + repository: &Option>, + commit_sha: git::Oid, + ref_name: Option<&str>, + cx: &App, +) -> Option { + let repository_path = repository + .as_ref()? + .upgrade()? + .read(cx) + .work_directory_abs_path + .to_path_buf(); + let repository_name = repository_path + .file_name() + .and_then(|name| name.to_str()) + .map(ToString::to_string); + let mut task_variables = TaskVariables::from_iter([ + (VariableName::GitSha, commit_sha.to_string()), + (VariableName::GitShaShort, commit_sha.display_short()), + ( + VariableName::GitRepositoryPath, + repository_path.to_string_lossy().into_owned(), + ), + ]); + + if let Some(repository_name) = repository_name { + task_variables.insert(VariableName::GitRepositoryName, repository_name); + } + if let Some(ref_name) = ref_name { + task_variables.insert(VariableName::GitRef, ref_name.to_string()); + } + + Some(TaskContext { + cwd: Some(repository_path), + task_variables, + ..TaskContext::default() + }) +} + +fn git_context_menu_tasks( + task_context: Option, + workspace: &WeakEntity, + cx: &App, +) -> Vec<(project::TaskSourceKind, task::ResolvedTask)> { + let Some(task_context) = task_context else { + return Vec::new(); + }; + let Some(workspace) = workspace.upgrade() else { + return Vec::new(); + }; + let project = workspace.read(cx).project().clone(); + let task_inventory = project.read_with(cx, |project, cx| { + project.task_store().read(cx).task_inventory().cloned() + }); + let Some(task_inventory) = task_inventory else { + return Vec::new(); + }; + + task_inventory + .read(cx) + .resolve_global_tasks_with_tag(GIT_COMMAND_TASK_TAG, &task_context) +} diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index 343cf2abbd..4399545790 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -1,4 +1,6 @@ +pub use crate::commit_context_menu::{CopyCommitSha, CopyCommitTag, OpenCommitView}; use crate::{ + commit_context_menu::{CommitContextMenuData, CommitContextMenuSource, commit_context_menu}, commit_tooltip::{CommitAvatar, CommitDetails, CommitTooltip}, commit_view::CommitView, git_status_icon, @@ -17,9 +19,9 @@ use git::{ status::{FileStatus, StatusCode, TrackedStatus}, }; use gpui::{ - Action, Anchor, AnyElement, App, Bounds, ClickEvent, ClipboardItem, DefiniteLength, - DismissEvent, DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, - Hsla, MouseButton, MouseDownEvent, PathBuilder, Pixels, Point, ScrollHandle, ScrollStrategy, + Anchor, AnyElement, App, Bounds, ClickEvent, ClipboardItem, DefiniteLength, DismissEvent, + DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, + MouseButton, MouseDownEvent, PathBuilder, Pixels, Point, ScrollHandle, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task, TextStyleRefinement, UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, point, prelude::*, px, uniform_list, @@ -29,7 +31,7 @@ use markdown::{Markdown, MarkdownElement}; use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use picker::{Picker, PickerDelegate}; use project::{ - GIT_COMMAND_TASK_TAG, ProjectPath, TaskSourceKind, + ProjectPath, git_store::{ CommitDataState, GitGraphEvent, GitStore, GitStoreEvent, GraphDataResponse, Repository, RepositoryEvent, RepositoryId, @@ -47,12 +49,12 @@ use std::{ sync::{Arc, OnceLock}, time::{Duration, Instant}, }; -use task::{ResolvedTask, TaskContext, TaskVariables, VariableName}; + use theme::AccentColors; use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem}; use ui::{ - Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, ContextMenuEntry, DiffStat, - Divider, HeaderResizeInfo, HighlightedLabel, IndentGuideColors, ListItem, ListItemSpacing, + Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, + HeaderResizeInfo, HighlightedLabel, IndentGuideColors, ListItem, ListItemSpacing, RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState, TableRenderContext, TableResizeBehavior, Tooltip, WithScrollbar, bind_redistributable_columns, prelude::*, redistribute_hidden_fractions, redistribute_hidden_widths, @@ -72,7 +74,6 @@ const LINE_WIDTH: Pixels = px(1.5); const RESIZE_HANDLE_WIDTH: f32 = 8.0; const COPIED_STATE_DURATION: Duration = Duration::from_secs(2); const COMMIT_TAG_LIST_WIDTH_IN_REMS: Rems = rems(10.); -const CUSTOM_GIT_COMMANDS_DOCS_SLUG: &str = "tasks#custom-git-commands"; const TREE_INDENT: f32 = 20.0; const TABLE_COLUMN_COUNT: usize = 4; const ROW_VERTICAL_PADDING: Pixels = px(4.0); @@ -580,12 +581,6 @@ actions!( [ /// Opens the Git Graph Tab. Open, - /// Copies the SHA of the selected commit to the clipboard. - CopyCommitSha, - /// Copies a tag from the selected commit to the clipboard. - CopyCommitTag, - /// Opens the commit view for the selected commit. - OpenCommitView, /// Focuses the search field. FocusSearch, /// Focuses the next git graph tab stop. @@ -2467,91 +2462,6 @@ impl GitGraph { self.copy_commit_tag(selected_entry_index, window, cx); } - fn git_task_context( - &self, - commit_sha: Oid, - ref_name: Option<&str>, - cx: &App, - ) -> Option { - let repository_path = self - .get_repository(cx)? - .read(cx) - .work_directory_abs_path - .to_path_buf(); - - let repository_name = repository_path - .file_name() - .and_then(|name| name.to_str()) - .map(ToString::to_string); - - let mut task_variables = TaskVariables::from_iter([ - (VariableName::GitSha, commit_sha.to_string()), - (VariableName::GitShaShort, commit_sha.display_short()), - ( - VariableName::GitRepositoryPath, - repository_path.to_string_lossy().into_owned(), - ), - ]); - - if let Some(repository_name) = repository_name { - task_variables.insert(VariableName::GitRepositoryName, repository_name); - } - - if let Some(ref_name) = ref_name { - task_variables.insert(VariableName::GitRef, ref_name.to_string()); - } - - Some(TaskContext { - cwd: Some(repository_path), - task_variables, - ..TaskContext::default() - }) - } - - fn git_context_menu_tasks( - &self, - task_context: &TaskContext, - cx: &App, - ) -> Vec<(TaskSourceKind, ResolvedTask)> { - let Some(workspace) = self.workspace.upgrade() else { - return Vec::new(); - }; - - let project = workspace.read(cx).project().clone(); - - let task_inventory = project.read_with(cx, |project, cx| { - project.task_store().read(cx).task_inventory().cloned() - }); - - let Some(task_inventory) = task_inventory else { - return Vec::new(); - }; - - task_inventory - .read(cx) - .resolve_global_tasks_with_tag(GIT_COMMAND_TASK_TAG, task_context) - } - - fn schedule_git_task( - &mut self, - task_source_kind: TaskSourceKind, - resolved_task: ResolvedTask, - window: &mut Window, - cx: &mut Context, - ) { - self.workspace - .update(cx, |workspace, cx| { - workspace.schedule_resolved_task( - task_source_kind, - resolved_task, - false, - window, - cx, - ); - }) - .ok(); - } - fn deploy_entry_context_menu( &mut self, position: Point, @@ -2563,129 +2473,27 @@ impl GitGraph { let Some(commit) = self.graph_data.commits.get(index) else { return; }; - let sha = commit.data.sha; - let sha_short = sha.display_short(); - let git_tasks = self - .git_task_context(sha, ref_name.as_deref(), cx) - .map(|task_context| self.git_context_menu_tasks(&task_context, cx)) - .unwrap_or_default(); - - let header = match &ref_name { - Some(ref_name) => format!("Ref {ref_name}"), - None => format!("Commit {sha_short}"), - }; - - let focus_handle = self.focus_handle.clone(); - let git_graph = cx.entity(); - let context_menu = ContextMenu::build(window, cx, |context_menu, window, _| { - context_menu - .context(focus_handle) - .header(header) - .entry( - "View Commit", - Some(OpenCommitView.boxed_clone()), - window.handler_for(&git_graph, move |this, window, cx| { - this.open_commit_view(index, window, cx); - }), - ) - .entry( - "Copy SHA", - Some(CopyCommitSha.boxed_clone()), - window.handler_for(&git_graph, move |this, _window, cx| { - this.copy_commit_sha(index, cx); - }), - ) - .when_some(ref_name.clone(), |menu, ref_name| { - menu.entry("Copy Ref Name", None, move |_window, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(ref_name.to_string())); - }) - }) - .when(ref_name.is_none(), |menu| { - menu.map(|menu| { - let tag_names = commit - .data - .tag_names() - .into_iter() - .map(|tag_name| SharedString::from(tag_name.to_string())) - .collect::>(); - let copy_tag_label = "Copy Tag"; - - match tag_names.as_slice() { - [] => menu.item( - ContextMenuEntry::new(copy_tag_label) - .action(CopyCommitTag.boxed_clone()) - .disabled(true), - ), - [tag_name] => { - let tag_name = tag_name.clone(); - let label = format!("{copy_tag_label}: {tag_name}"); - menu.entry( - label, - Some(CopyCommitTag.boxed_clone()), - move |_window, cx| { - cx.write_to_clipboard(ClipboardItem::new_string( - tag_name.to_string(), - )); - }, - ) - } - _ => menu.submenu(copy_tag_label, move |menu, _window, _cx| { - let mut menu = - menu.fixed_width(COMMIT_TAG_LIST_WIDTH_IN_REMS.into()); - - for tag_name in tag_names.clone() { - let tag_name_to_copy = tag_name.clone(); - - menu = menu.entry(tag_name, None, move |_window, cx| { - cx.write_to_clipboard(ClipboardItem::new_string( - tag_name_to_copy.to_string(), - )); - }); - } - menu - }), - } - }) - }) - .map(|mut menu| { - menu = menu.separator().header("Custom Commands"); - - if git_tasks.is_empty() { - return menu.item( - ContextMenuEntry::new("Learn More") - .icon(IconName::ArrowUpRight) - .icon_color(Color::Muted) - .icon_position(IconPosition::End) - .handler(|_window, cx| { - let docs_url = release_channel::docs_url( - CUSTOM_GIT_COMMANDS_DOCS_SLUG, - cx, - ); - cx.open_url(&docs_url); - }), - ); - } - - for (task_source_kind, resolved_task) in git_tasks { - let label = resolved_task.display_label().to_string(); - - menu = menu.entry( - label, - None, - window.handler_for(&git_graph, move |this, window, cx| { - this.schedule_git_task( - task_source_kind.clone(), - resolved_task.clone(), - window, - cx, - ); - }), - ); - } - - menu - }) - }); + let repository = self + .get_repository(cx) + .map(|repository| repository.downgrade()); + let context_menu = commit_context_menu( + CommitContextMenuData { + sha: commit.data.sha, + tag_names: commit + .data + .tag_names() + .into_iter() + .map(|tag_name| SharedString::from(tag_name.to_string())) + .collect(), + }, + CommitContextMenuSource::GitGraph, + ref_name, + self.focus_handle.clone(), + repository, + self.workspace.clone(), + window, + cx, + ); self.set_context_menu(context_menu, position, Some(index), window, cx); } @@ -4946,7 +4754,9 @@ mod tests { use git::repository::{CommitData, InitialGraphCommitData}; use gpui::{TestAppContext, UpdateGlobal}; use project::git_store::{GitStoreEvent, RepositoryEvent}; - use project::{Project, TaskSourceKind, task_store::TaskSettingsLocation}; + use project::{ + GIT_COMMAND_TASK_TAG, Project, TaskSourceKind, task_store::TaskSettingsLocation, + }; use rand::prelude::*; use serde_json::json; use settings::{SettingsStore, ThemeSettingsContent}; diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 29328c7746..12d9a1e739 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -1,4 +1,7 @@ use crate::askpass_modal::AskPassModal; +use crate::commit_context_menu::{ + CommitContextMenuData, CommitContextMenuSource, commit_context_menu, +}; use crate::commit_modal::CommitModal; use crate::commit_tooltip::{CommitAvatar, CommitTooltip}; use crate::commit_view::CommitView; @@ -6187,6 +6190,37 @@ impl GitPanel { ); } + fn deploy_history_context_menu( + &mut self, + position: Point, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { + let Some(commit) = self.commit_history_entries().get(index).cloned() else { + return; + }; + let Some(repository) = self.active_repository.as_ref() else { + return; + }; + let context_menu = commit_context_menu( + CommitContextMenuData { + sha: commit.sha, + tag_names: commit.tag_names, + }, + CommitContextMenuSource::GitPanel, + None, + self.focus_handle.clone(), + Some(repository.downgrade()), + self.workspace.clone(), + window, + cx, + ); + self.focused_history_entry = Some(index); + self.history_keyboard_nav = false; + self.set_context_menu(context_menu, position, window, cx); + } + fn activate_changes_tab( &mut self, _: &ActivateChangesTab, @@ -6347,6 +6381,7 @@ impl GitPanel { let focused_history_entry = self.focused_history_entry; let is_panel_focused = self.focus_handle.is_focused(window); let show_focus_border = self.history_keyboard_nav; + let has_context_menu = self.context_menu.is_some(); let ahead_count = active_repository .read(cx) @@ -6485,13 +6520,20 @@ impl GitPanel { tag_names .iter() .take(MAX_HISTORY_TAG_CHIPS) - .cloned() .map(|tag_name| { + let tag_name = tag_name.clone(); Chip::new(tag_name.clone()) .truncate() - .tooltip(Tooltip::text( - tag_name, - )) + .when( + !has_context_menu, + |chip| { + chip.tooltip( + Tooltip::text( + tag_name, + ), + ) + }, + ) }), ) .when(hidden_tag_count > 0, |this| { @@ -6502,9 +6544,11 @@ impl GitPanel { Chip::new(format!( "+{hidden_tag_count}" )) - .tooltip(Tooltip::text( - hidden_tag_names, - )), + .when(!has_context_menu, |chip| { + chip.tooltip(Tooltip::text( + hidden_tag_names, + )) + }), ) }) })) @@ -6541,13 +6585,15 @@ impl GitPanel { .color(Color::Muted), ), ) - .tooltip(move |_, cx| { - Tooltip::with_meta( - "View Commit", - None, - short_sha.clone(), - cx, - ) + .when(!has_context_menu, |this| { + this.tooltip(move |_, cx| { + Tooltip::with_meta( + "View Commit", + None, + short_sha.clone(), + cx, + ) + }) }) .on_mouse_down(gpui::MouseButton::Left, { let git_panel = git_panel.clone(); @@ -6561,6 +6607,22 @@ impl GitPanel { .ok(); } }) + .on_mouse_down(MouseButton::Right, { + let git_panel = git_panel.clone(); + move |event, window, cx| { + git_panel + .update(cx, |panel, cx| { + panel.deploy_history_context_menu( + event.position, + index, + window, + cx, + ); + }) + .ok(); + cx.stop_propagation(); + } + }) .on_click(move |_, window, cx| { CommitView::open( sha_for_click.clone(), diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index aefd0eff50..39e9299bc4 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -36,6 +36,7 @@ use crate::{ mod askpass_modal; pub mod branch_diff; pub mod branch_picker; +mod commit_context_menu; mod commit_modal; pub mod commit_tooltip; pub mod commit_view;