Cargo.lock 🔗
@@ -7343,6 +7343,7 @@ dependencies = [
"language",
"language_model",
"menu",
+ "picker",
"project",
"project_panel",
"rand 0.9.4",
Joseph T. Lyons created
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(-)
@@ -7343,6 +7343,7 @@ dependencies = [
"language",
"language_model",
"menu",
+ "picker",
"project",
"project_panel",
"rand 0.9.4",
@@ -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();
@@ -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
@@ -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);