Surface the git graph context menu in the git panel's history tab (#60713)

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.

<img width="511" height="566" alt="SCR-20260709-qlxt"
src="https://github.com/user-attachments/assets/8f18f22b-21d7-46a0-8ce2-183cf4af86c9"
/>

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.
This commit is contained in:
Joseph T. Lyons authored and GitHub committed 2026-07-14 19:16:37 +00:00
1 parent 27095fe544
commit 0188a3a952
4 files changed
+353 -237

No files matched your search

+243
View File
@@ -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<SharedString>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum CommitContextMenuSource {
GitGraph,
GitPanel,
}
pub(crate) fn commit_context_menu(
commit: CommitContextMenuData,
source: CommitContextMenuSource,
ref_name: Option<SharedString>,
focus_handle: FocusHandle,
repository: Option<WeakEntity<Repository>>,
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut App,
) -> Entity<ContextMenu> {
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<WeakEntity<Repository>>,
commit_sha: git::Oid,
ref_name: Option<&str>,
cx: &App,
) -> Option<TaskContext> {
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<TaskContext>,
workspace: &WeakEntity<Workspace>,
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)
}
+33 -223
View File
@@ -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<TaskContext> {
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>,
) {
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<Pixels>,
@@ -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::<Vec<_>>();
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};
+76 -14
View File
@@ -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<Pixels>,
index: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
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(),
+1
View File
@@ -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;