From 715df4a70c9ac6f520f5597b83a038a9d962dbb0 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Fri, 8 May 2026 22:46:59 -0400 Subject: [PATCH] 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. --- Cargo.lock | 1 + crates/git/src/repository.rs | 35 ++++ crates/git_graph/Cargo.toml | 1 + crates/git_graph/src/git_graph.rs | 330 +++++++++++++++++++++++++++++- 4 files changed, 361 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4377c68785db0da021f0e1bc9892ea755ad0d0c4..9f643e60486e99b1c862adebbd43496621e7cfa8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7343,6 +7343,7 @@ dependencies = [ "language", "language_model", "menu", + "picker", "project", "project_panel", "rand 0.9.4", diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index a0fa3c8a95b93cc6c289563bc70ca7e20793619f..ae3172fd128aa8f2065cb2bb4967d8a87cdcf14b 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -96,6 +96,23 @@ pub struct InitialGraphCommitData { pub ref_names: Vec, } +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>, @@ -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(); diff --git a/crates/git_graph/Cargo.toml b/crates/git_graph/Cargo.toml index 7a8f78b46e7023cc9e4d56a3e5ba6388e4d9aa83..3d0180e30617089115456f63155befd3d5a59efe 100644 --- a/crates/git_graph/Cargo.toml +++ b/crates/git_graph/Cargo.toml @@ -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 diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index ac9a01deb9fb4a7417b0964c8bc49f6c30b06a62..4a09d69254b6358da73b24f915cfbc799903f175 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -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>, +} + +impl CommitTagPicker { + fn new(tag_names: Vec, window: &mut Window, cx: &mut Context) -> 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 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) -> impl IntoElement { + v_flex().w(rems(18.)).child(self.picker.clone()) + } +} + +struct CommitTagPickerDelegate { + picker: WeakEntity, + tag_names: Vec, + selected_index: usize, +} + +impl PickerDelegate for CommitTagPickerDelegate { + type ListItem = ListItem; + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + "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>, + ) { + self.selected_index = ix; + } + + fn update_matches( + &mut self, + _query: String, + _window: &mut Window, + _cx: &mut Context>, + ) -> Task<()> { + Task::ready(()) + } + + fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { + 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>) { + self.picker + .update(cx, |_this, cx| cx.emit(DismissEvent)) + .ok(); + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _window: &mut Window, + _cx: &mut Context>, + ) -> Option { + 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) { + 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::>(); + + 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, + ) { + 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, @@ -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::(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);