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:
1 parent
c658ed888d
commit
65d40a5724
4 files changed
+361
-6
No files matched your search
Generated
+1
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user