Add a Copy Tag action to the git graph context menu (#56110)

https://github.com/user-attachments/assets/7aa683e3-c52c-49e7-9934-ed4df6a1f8e2

Self-Review Checklist:

- [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:

- Added a `Copy Tag` action to the git graph context menu.
This commit is contained in:
Joseph T. Lyons authored and GitHub committed 2026-05-09 02:46:59 +00:00
1 parent c658ed888d
commit 65d40a5724
4 files changed
+361 -6

No files matched your search

Generated
+1
View File
@@ -7343,6 +7343,7 @@ dependencies = [
"language",
"language_model",
"menu",
"picker",
"project",
"project_panel",
"rand 0.9.4",
+35
View File
@@ -96,6 +96,23 @@ pub struct InitialGraphCommitData {
pub ref_names: Vec<SharedString>,
}
impl InitialGraphCommitData {
pub fn tag_names(&self) -> Vec<&str> {
self.ref_names
.iter()
.filter_map(|ref_name| {
let tag_name = ref_name.strip_prefix("tag: ")?;
if tag_name.is_empty() {
return None;
}
Some(tag_name)
})
.collect()
}
}
struct CommitDataRequest {
sha: Oid,
response_tx: oneshot::Sender<Result<CommitData>>,
@@ -3678,6 +3695,24 @@ mod tests {
}
}
#[test]
fn test_initial_graph_commit_data_tag_names() {
let commit = InitialGraphCommitData {
sha: Oid::from_bytes(&[0; 20]).unwrap(),
parents: SmallVec::new(),
ref_names: vec![
SharedString::from("HEAD -> main"),
SharedString::from("origin/main"),
SharedString::from("tag: v1.0.0"),
SharedString::from("tag: v1.1.0"),
SharedString::from("tag: "),
SharedString::from("refs/heads/feature"),
],
};
assert_eq!(commit.tag_names(), ["v1.0.0", "v1.1.0"]);
}
#[gpui::test]
async fn test_build_command_untrusted_includes_both_safety_args(cx: &mut TestAppContext) {
cx.executor().allow_parking();
+1
View File
@@ -30,6 +30,7 @@ git_ui.workspace = true
gpui.workspace = true
language.workspace = true
menu.workspace = true
picker.workspace = true
project.workspace = true
project_panel.workspace = true
search.workspace = true
+324 -6
View File
@@ -20,6 +20,7 @@ use gpui::{
};
use language::line_diff;
use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious};
use picker::{Picker, PickerDelegate};
use project::{
ProjectPath,
git_store::{
@@ -43,14 +44,15 @@ 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,
bind_redistributable_columns, prelude::*, render_redistributable_columns_resize_handles,
render_table_header, table_row::TableRow,
ButtonLike, Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, ContextMenuEntry,
DiffStat, Divider, HeaderResizeInfo, HighlightedLabel, ListItem, ListItemSpacing,
RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState,
TableRenderContext, TableResizeBehavior, Tooltip, WithScrollbar, bind_redistributable_columns,
prelude::*, render_redistributable_columns_resize_handles, render_table_header,
table_row::TableRow,
};
use workspace::{
Workspace,
ModalView, Workspace,
item::{Item, ItemEvent, TabTooltipContent},
};
@@ -87,6 +89,106 @@ impl CopiedState {
struct DraggedSplitHandle;
struct CommitTagPicker {
picker: Entity<Picker<CommitTagPickerDelegate>>,
}
impl CommitTagPicker {
fn new(tag_names: Vec<SharedString>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let delegate = CommitTagPickerDelegate {
picker: cx.entity().downgrade(),
tag_names,
selected_index: 0,
};
let picker = cx.new(|cx| Picker::nonsearchable_uniform_list(delegate, window, cx));
Self { picker }
}
}
impl EventEmitter<DismissEvent> for CommitTagPicker {}
impl ModalView for CommitTagPicker {}
impl Focusable for CommitTagPicker {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.picker.focus_handle(cx)
}
}
impl Render for CommitTagPicker {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
v_flex().w(rems(18.)).child(self.picker.clone())
}
}
struct CommitTagPickerDelegate {
picker: WeakEntity<CommitTagPicker>,
tag_names: Vec<SharedString>,
selected_index: usize,
}
impl PickerDelegate for CommitTagPickerDelegate {
type ListItem = ListItem;
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Copy Tag".into()
}
fn match_count(&self) -> usize {
self.tag_names.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(
&mut self,
ix: usize,
_window: &mut Window,
_cx: &mut Context<Picker<Self>>,
) {
self.selected_index = ix;
}
fn update_matches(
&mut self,
_query: String,
_window: &mut Window,
_cx: &mut Context<Picker<Self>>,
) -> Task<()> {
Task::ready(())
}
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
if let Some(tag_name) = self.tag_names.get(self.selected_index) {
cx.write_to_clipboard(ClipboardItem::new_string(tag_name.to_string()));
}
self.dismissed(window, cx);
}
fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
self.picker
.update(cx, |_this, cx| cx.emit(DismissEvent))
.ok();
}
fn render_match(
&self,
ix: usize,
selected: bool,
_window: &mut Window,
_cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
Some(
ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(Label::new(self.tag_names.get(ix)?.clone())),
)
}
}
#[derive(Clone)]
struct ChangedFileEntry {
status: FileStatus,
@@ -281,6 +383,8 @@ actions!(
[
/// 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.
@@ -1888,6 +1992,45 @@ impl GitGraph {
self.copy_commit_sha(selected_entry_index, cx);
}
fn copy_commit_tag(&mut self, entry_index: usize, window: &mut Window, cx: &mut Context<Self>) {
let Some(commit) = self.graph_data.commits.get(entry_index) else {
return;
};
let tag_names = commit
.data
.tag_names()
.into_iter()
.map(|tag_name| SharedString::from(tag_name.to_string()))
.collect::<Vec<_>>();
match tag_names.as_slice() {
[] => {}
[tag_name] => cx.write_to_clipboard(ClipboardItem::new_string(tag_name.to_string())),
_ => {
self.workspace
.update(cx, |workspace, cx| {
workspace.toggle_modal(window, cx, |window, cx| {
CommitTagPicker::new(tag_names, window, cx)
});
})
.ok();
}
}
}
fn copy_selected_commit_tag(
&mut self,
_: &CopyCommitTag,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(selected_entry_index) = self.selected_entry_idx else {
return;
};
self.copy_commit_tag(selected_entry_index, window, cx);
}
fn deploy_entry_context_menu(
&mut self,
position: Point<Pixels>,
@@ -1899,6 +2042,14 @@ impl GitGraph {
return;
};
let short_sha = commit.data.sha.display_short();
let tag_names = commit.data.tag_names();
let copy_tag_label = "Copy Tag";
let copy_tag_label: SharedString = match tag_names.as_slice() {
[] => copy_tag_label.into(),
[tag_name] => format!("{copy_tag_label}: {tag_name}").into(),
_ => format!("{copy_tag_label}…").into(),
};
let copy_tag_disabled = tag_names.is_empty();
let focus_handle = self.focus_handle.clone();
let git_graph = cx.entity();
@@ -1920,6 +2071,14 @@ impl GitGraph {
this.copy_commit_sha(index, cx);
}),
)
.item(
ContextMenuEntry::new(copy_tag_label)
.action(CopyCommitTag.boxed_clone())
.disabled(copy_tag_disabled)
.handler(window.handler_for(&git_graph, move |this, window, cx| {
this.copy_commit_tag(index, window, cx);
})),
)
});
self.set_context_menu(context_menu, position, index, window, cx);
}
@@ -3243,6 +3402,7 @@ impl Render for GitGraph {
this.open_selected_commit_view(window, cx);
}))
.on_action(cx.listener(Self::copy_selected_commit_sha))
.on_action(cx.listener(Self::copy_selected_commit_tag))
.on_action(cx.listener(Self::cancel))
.on_action(cx.listener(|this, _: &FocusSearch, window, cx| {
this.search_state
@@ -5445,6 +5605,164 @@ mod tests {
});
}
#[gpui::test]
async fn test_copy_selected_commit_tag_with_one_tag_copies_to_clipboard(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
Path::new("/project"),
serde_json::json!({
".git": {},
"file.txt": "content",
}),
)
.await;
let commit_sha = Oid::from_bytes(&[1; 20]).unwrap();
let commits = vec![Arc::new(InitialGraphCommitData {
sha: commit_sha,
parents: smallvec![],
ref_names: vec![
SharedString::from("HEAD -> main"),
SharedString::from("origin/main"),
SharedString::from("tag: v1.0.0"),
],
})];
fs.set_graph_commits(Path::new("/project/.git"), commits);
let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
cx.run_until_parked();
let repository = project.read_with(cx, |project, cx| {
project
.active_repository(cx)
.expect("should have a repository")
});
let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
workspace::MultiWorkspace::test_new(project.clone(), window, cx)
});
let workspace = multi_workspace.read_with(&*cx, |multi, _| multi.workspace().clone());
let workspace_weak = workspace.downgrade();
let git_graph = cx.new_window_entity(|window, cx| {
GitGraph::new(
repository.read(cx).id,
project.read(cx).git_store().clone(),
workspace_weak,
None,
window,
cx,
)
});
cx.run_until_parked();
git_graph.update_in(cx, |graph, window, cx| {
assert_eq!(graph.graph_data.commits.len(), 1);
graph.selected_entry_idx = Some(0);
graph.copy_selected_commit_tag(&CopyCommitTag, window, cx);
});
assert_eq!(
cx.read_from_clipboard().and_then(|item| item.text()),
Some("v1.0.0".to_string())
);
}
#[gpui::test]
async fn test_copy_selected_commit_tag_with_multiple_tags_opens_picker_and_copies_selected_tag(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
Path::new("/project"),
serde_json::json!({
".git": {},
"file.txt": "content",
}),
)
.await;
let commit_sha = Oid::from_bytes(&[1; 20]).unwrap();
let commits = vec![Arc::new(InitialGraphCommitData {
sha: commit_sha,
parents: smallvec![],
ref_names: vec![
SharedString::from("HEAD -> main"),
SharedString::from("origin/main"),
SharedString::from("tag: v1.0.0"),
SharedString::from("tag: v1.1.0"),
],
})];
fs.set_graph_commits(Path::new("/project/.git"), commits);
let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
cx.run_until_parked();
let repository = project.read_with(cx, |project, cx| {
project
.active_repository(cx)
.expect("should have a repository")
});
let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
workspace::MultiWorkspace::test_new(project.clone(), window, cx)
});
let workspace = multi_workspace.read_with(&*cx, |multi, _| multi.workspace().clone());
let workspace_weak = workspace.downgrade();
let git_graph = cx.new_window_entity(|window, cx| {
GitGraph::new(
repository.read(cx).id,
project.read(cx).git_store().clone(),
workspace_weak,
None,
window,
cx,
)
});
cx.run_until_parked();
git_graph.update_in(cx, |graph, window, cx| {
assert_eq!(graph.graph_data.commits.len(), 1);
graph.selected_entry_idx = Some(0);
graph.copy_selected_commit_tag(&CopyCommitTag, window, cx);
});
// Ensure that nothing has been copied at this point
assert_eq!(cx.read_from_clipboard().and_then(|item| item.text()), None);
let picker = workspace.update(cx, |workspace, cx| {
workspace
.active_modal::<CommitTagPicker>(cx)
.expect("commit tag picker is not open")
.read(cx)
.picker
.clone()
});
picker.read_with(cx, |picker, _| {
assert_eq!(picker.delegate.selected_index, 0);
assert_eq!(
picker.delegate.tag_names,
[SharedString::from("v1.0.0"), SharedString::from("v1.1.0")]
);
});
cx.dispatch_action(menu::Confirm);
cx.run_until_parked();
assert_eq!(
cx.read_from_clipboard().and_then(|item| item.text()),
Some("v1.0.0".to_string())
);
}
#[gpui::test]
async fn test_git_graph_navigation(cx: &mut TestAppContext) {
init_test(cx);