Skip to content

Commit 75ac36d

Browse files
authored
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.
1 parent 48a0cbc commit 75ac36d

4 files changed

Lines changed: 353 additions & 237 deletions

File tree

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
use crate::commit_view::CommitView;
2+
use git::Oid;
3+
use gpui::{Action, ClipboardItem, Entity, FocusHandle, SharedString, WeakEntity, Window, actions};
4+
use project::{GIT_COMMAND_TASK_TAG, git_store::Repository};
5+
6+
use task::{TaskContext, TaskVariables, VariableName};
7+
use ui::{Color, ContextMenu, ContextMenuEntry, IconName, IconPosition, prelude::*};
8+
use workspace::Workspace;
9+
10+
actions!(
11+
git_graph,
12+
[
13+
/// Copies the SHA of the selected commit to the clipboard.
14+
CopyCommitSha,
15+
/// Copies a tag from the selected commit to the clipboard.
16+
CopyCommitTag,
17+
/// Opens the commit view for the selected commit.
18+
OpenCommitView,
19+
]
20+
);
21+
22+
const COMMIT_TAG_LIST_WIDTH_IN_REMS: Rems = rems(10.);
23+
const CUSTOM_GIT_COMMANDS_DOCS_SLUG: &str = "tasks#custom-git-commands";
24+
25+
pub(crate) struct CommitContextMenuData {
26+
pub(crate) sha: Oid,
27+
pub(crate) tag_names: Vec<SharedString>,
28+
}
29+
30+
#[derive(Clone, Copy, PartialEq, Eq)]
31+
pub(crate) enum CommitContextMenuSource {
32+
GitGraph,
33+
GitPanel,
34+
}
35+
36+
pub(crate) fn commit_context_menu(
37+
commit: CommitContextMenuData,
38+
source: CommitContextMenuSource,
39+
ref_name: Option<SharedString>,
40+
focus_handle: FocusHandle,
41+
repository: Option<WeakEntity<Repository>>,
42+
workspace: WeakEntity<Workspace>,
43+
window: &mut Window,
44+
cx: &mut App,
45+
) -> Entity<ContextMenu> {
46+
let sha = commit.sha;
47+
let sha_short = sha.display_short();
48+
let git_tasks = git_context_menu_tasks(
49+
git_task_context(&repository, sha, ref_name.as_deref(), cx),
50+
&workspace,
51+
cx,
52+
);
53+
let header = match &ref_name {
54+
Some(ref_name) => format!("Ref {ref_name}"),
55+
None => format!("Commit {sha_short}"),
56+
};
57+
58+
ContextMenu::build(window, cx, move |context_menu, _, _| {
59+
context_menu
60+
.context(focus_handle)
61+
.header(header)
62+
.entry("View Commit", Some(OpenCommitView.boxed_clone()), {
63+
let repository = repository.clone();
64+
let workspace = workspace.clone();
65+
move |window, cx| {
66+
let Some(repository) = repository.clone() else {
67+
return;
68+
};
69+
CommitView::open(
70+
sha.to_string(),
71+
repository,
72+
workspace.clone(),
73+
None,
74+
None,
75+
window,
76+
cx,
77+
);
78+
}
79+
})
80+
.entry(
81+
"Copy SHA",
82+
Some(CopyCommitSha.boxed_clone()),
83+
move |_window, cx| {
84+
cx.write_to_clipboard(ClipboardItem::new_string(sha.to_string()));
85+
},
86+
)
87+
.when_some(ref_name.clone(), |menu, ref_name| {
88+
menu.entry("Copy Ref Name", None, move |_window, cx| {
89+
cx.write_to_clipboard(ClipboardItem::new_string(ref_name.to_string()));
90+
})
91+
})
92+
.when(ref_name.is_none(), |menu| {
93+
menu.map(|menu| {
94+
let tag_names = commit.tag_names.clone();
95+
let copy_tag_label = "Copy Tag";
96+
97+
match tag_names.as_slice() {
98+
[] => menu.item(
99+
ContextMenuEntry::new(copy_tag_label)
100+
.action(CopyCommitTag.boxed_clone())
101+
.disabled(true),
102+
),
103+
[tag_name] => {
104+
let tag_name = tag_name.clone();
105+
let label = format!("{copy_tag_label}: {tag_name}");
106+
menu.entry(
107+
label,
108+
Some(CopyCommitTag.boxed_clone()),
109+
move |_window, cx| {
110+
cx.write_to_clipboard(ClipboardItem::new_string(
111+
tag_name.to_string(),
112+
));
113+
},
114+
)
115+
}
116+
_ => menu.submenu(copy_tag_label, move |menu, _window, _cx| {
117+
let mut menu = menu.fixed_width(COMMIT_TAG_LIST_WIDTH_IN_REMS.into());
118+
119+
for tag_name in tag_names.clone() {
120+
let tag_name_to_copy = tag_name.clone();
121+
menu = menu.entry(tag_name, None, move |_window, cx| {
122+
cx.write_to_clipboard(ClipboardItem::new_string(
123+
tag_name_to_copy.to_string(),
124+
));
125+
});
126+
}
127+
menu
128+
}),
129+
}
130+
})
131+
})
132+
.when(source == CommitContextMenuSource::GitPanel, |menu| {
133+
menu.entry("Show in Git Graph", None, move |window, cx| {
134+
window.dispatch_action(
135+
Box::new(crate::git_graph::OpenAtCommit {
136+
sha: sha.to_string(),
137+
}),
138+
cx,
139+
);
140+
})
141+
})
142+
.map(|mut menu| {
143+
menu = menu.separator().header("Custom Commands");
144+
145+
if git_tasks.is_empty() {
146+
return menu.item(
147+
ContextMenuEntry::new("Learn More")
148+
.icon(IconName::ArrowUpRight)
149+
.icon_color(Color::Muted)
150+
.icon_position(IconPosition::End)
151+
.handler(|_window, cx| {
152+
let docs_url =
153+
release_channel::docs_url(CUSTOM_GIT_COMMANDS_DOCS_SLUG, cx);
154+
cx.open_url(&docs_url);
155+
}),
156+
);
157+
}
158+
159+
for (task_source_kind, resolved_task) in git_tasks {
160+
let label = resolved_task.display_label().to_string();
161+
let workspace = workspace.clone();
162+
menu = menu.entry(label, None, move |window, cx| {
163+
workspace
164+
.update(cx, |workspace, cx| {
165+
workspace.schedule_resolved_task(
166+
task_source_kind.clone(),
167+
resolved_task.clone(),
168+
false,
169+
window,
170+
cx,
171+
);
172+
})
173+
.ok();
174+
});
175+
}
176+
177+
menu
178+
})
179+
})
180+
}
181+
182+
fn git_task_context(
183+
repository: &Option<WeakEntity<Repository>>,
184+
commit_sha: git::Oid,
185+
ref_name: Option<&str>,
186+
cx: &App,
187+
) -> Option<TaskContext> {
188+
let repository_path = repository
189+
.as_ref()?
190+
.upgrade()?
191+
.read(cx)
192+
.work_directory_abs_path
193+
.to_path_buf();
194+
let repository_name = repository_path
195+
.file_name()
196+
.and_then(|name| name.to_str())
197+
.map(ToString::to_string);
198+
let mut task_variables = TaskVariables::from_iter([
199+
(VariableName::GitSha, commit_sha.to_string()),
200+
(VariableName::GitShaShort, commit_sha.display_short()),
201+
(
202+
VariableName::GitRepositoryPath,
203+
repository_path.to_string_lossy().into_owned(),
204+
),
205+
]);
206+
207+
if let Some(repository_name) = repository_name {
208+
task_variables.insert(VariableName::GitRepositoryName, repository_name);
209+
}
210+
if let Some(ref_name) = ref_name {
211+
task_variables.insert(VariableName::GitRef, ref_name.to_string());
212+
}
213+
214+
Some(TaskContext {
215+
cwd: Some(repository_path),
216+
task_variables,
217+
..TaskContext::default()
218+
})
219+
}
220+
221+
fn git_context_menu_tasks(
222+
task_context: Option<TaskContext>,
223+
workspace: &WeakEntity<Workspace>,
224+
cx: &App,
225+
) -> Vec<(project::TaskSourceKind, task::ResolvedTask)> {
226+
let Some(task_context) = task_context else {
227+
return Vec::new();
228+
};
229+
let Some(workspace) = workspace.upgrade() else {
230+
return Vec::new();
231+
};
232+
let project = workspace.read(cx).project().clone();
233+
let task_inventory = project.read_with(cx, |project, cx| {
234+
project.task_store().read(cx).task_inventory().cloned()
235+
});
236+
let Some(task_inventory) = task_inventory else {
237+
return Vec::new();
238+
};
239+
240+
task_inventory
241+
.read(cx)
242+
.resolve_global_tasks_with_tag(GIT_COMMAND_TASK_TAG, &task_context)
243+
}

0 commit comments

Comments
 (0)