diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index e95c71a27a1ecc..bd8f3a29eb1bf3 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -1680,6 +1680,19 @@ impl GitGraph { }) } + /// Extracts a ref name (branch, remote ref, or tag) from a decoration in + /// git's `%D` format, returning `None` for a detached `HEAD`. + fn ref_name_from_decoration(decoration: &str) -> Option { + let name = decoration + .strip_prefix("tag: ") + .or_else(|| decoration.strip_prefix("HEAD -> ")) + .unwrap_or(decoration); + if name.is_empty() || name == "HEAD" { + return None; + } + Some(SharedString::from(name.to_string())) + } + fn render_chip( &self, name: &SharedString, @@ -1701,6 +1714,40 @@ impl GitGraph { }) } + /// Renders a ref chip for the commit at `commit_idx`. Chips that name a ref + /// (branch, remote ref, or tag) get a right-click handler that opens a + /// ref-specific context menu, so that custom commands can be resolved + /// against the clicked ref. + fn render_ref_chip( + &self, + name: &SharedString, + accent_color: gpui::Hsla, + is_head: bool, + commit_idx: usize, + cx: &mut Context, + ) -> AnyElement { + let chip = self.render_chip(name, accent_color, is_head); + let Some(ref_name) = Self::ref_name_from_decoration(name) else { + return chip.into_any_element(); + }; + div() + .child(chip) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |this, event: &MouseDownEvent, window, cx| { + this.deploy_entry_context_menu( + event.position, + commit_idx, + Some(ref_name.clone()), + window, + cx, + ); + cx.stop_propagation(); + }), + ) + .into_any_element() + } + fn render_table_rows( &mut self, range: Range, @@ -1877,7 +1924,13 @@ impl GitGraph { |name| { let is_head = Self::is_head_ref(name.as_ref(), &head_branch_name); - self.render_chip(name, accent_color, is_head) + self.render_ref_chip( + name, + accent_color, + is_head, + idx, + cx, + ) }, )) })) @@ -2309,7 +2362,12 @@ impl GitGraph { self.copy_commit_tag(selected_entry_index, window, cx); } - fn git_task_context(&self, commit_sha: Oid, cx: &App) -> Option { + fn git_task_context( + &self, + commit_sha: Oid, + ref_name: Option<&str>, + cx: &App, + ) -> Option { let repository_path = self .get_repository(cx)? .read(cx) @@ -2334,6 +2392,10 @@ impl GitGraph { task_variables.insert(VariableName::GitRepositoryName, repository_name); } + if let Some(ref_name) = ref_name { + task_variables.insert(VariableName::GitRef, ref_name.to_string()); + } + Some(TaskContext { cwd: Some(repository_path), task_variables, @@ -2389,6 +2451,7 @@ impl GitGraph { &mut self, position: Point, index: usize, + ref_name: Option, window: &mut Window, cx: &mut Context, ) { @@ -2398,16 +2461,21 @@ impl GitGraph { let sha = commit.data.sha; let sha_short = sha.display_short(); let git_tasks = self - .git_task_context(sha, cx) + .git_task_context(sha, ref_name.as_deref(), cx) .map(|task_context| self.git_context_menu_tasks(&task_context, cx)) .unwrap_or_default(); + let header = match &ref_name { + Some(ref_name) => format!("Ref {ref_name}"), + None => format!("Commit {sha_short}"), + }; + let focus_handle = self.focus_handle.clone(); let git_graph = cx.entity(); let context_menu = ContextMenu::build(window, cx, |context_menu, window, _| { context_menu .context(focus_handle) - .header(format!("Commit {sha_short}")) + .header(header) .entry( "View Commit", Some(OpenCommitView.boxed_clone()), @@ -2422,49 +2490,57 @@ impl GitGraph { this.copy_commit_sha(index, cx); }), ) - .map(|menu| { - let tag_names = commit - .data - .tag_names() - .into_iter() - .map(|tag_name| SharedString::from(tag_name.to_string())) - .collect::>(); - let copy_tag_label = "Copy Tag"; - - match tag_names.as_slice() { - [] => menu.item( - ContextMenuEntry::new(copy_tag_label) - .action(CopyCommitTag.boxed_clone()) - .disabled(true), - ), - [tag_name] => { - let tag_name = tag_name.clone(); - let label = format!("{copy_tag_label}: {tag_name}"); - menu.entry( - label, - Some(CopyCommitTag.boxed_clone()), - move |_window, cx| { - cx.write_to_clipboard(ClipboardItem::new_string( - tag_name.to_string(), - )); - }, - ) - } - _ => menu.submenu(copy_tag_label, move |menu, _window, _cx| { - let mut menu = menu.fixed_width(COMMIT_TAG_LIST_WIDTH_IN_REMS.into()); + .when_some(ref_name.clone(), |menu, ref_name| { + menu.entry("Copy Ref Name", None, move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(ref_name.to_string())); + }) + }) + .when(ref_name.is_none(), |menu| { + menu.map(|menu| { + let tag_names = commit + .data + .tag_names() + .into_iter() + .map(|tag_name| SharedString::from(tag_name.to_string())) + .collect::>(); + let copy_tag_label = "Copy Tag"; + + match tag_names.as_slice() { + [] => menu.item( + ContextMenuEntry::new(copy_tag_label) + .action(CopyCommitTag.boxed_clone()) + .disabled(true), + ), + [tag_name] => { + let tag_name = tag_name.clone(); + let label = format!("{copy_tag_label}: {tag_name}"); + menu.entry( + label, + Some(CopyCommitTag.boxed_clone()), + move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + tag_name.to_string(), + )); + }, + ) + } + _ => menu.submenu(copy_tag_label, move |menu, _window, _cx| { + let mut menu = + menu.fixed_width(COMMIT_TAG_LIST_WIDTH_IN_REMS.into()); - for tag_name in tag_names.clone() { - let tag_name_to_copy = tag_name.clone(); + for tag_name in tag_names.clone() { + let tag_name_to_copy = tag_name.clone(); - menu = menu.entry(tag_name, None, move |_window, cx| { - cx.write_to_clipboard(ClipboardItem::new_string( - tag_name_to_copy.to_string(), - )); - }); - } - menu - }), - } + menu = menu.entry(tag_name, None, move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + tag_name_to_copy.to_string(), + )); + }); + } + menu + }), + } + }) }) .map(|mut menu| { menu = menu.separator().header("Custom Commands"); @@ -2855,7 +2931,7 @@ impl GitGraph { h_flex().gap_1().flex_wrap().justify_center().children( ref_names.iter().map(|name| { let is_head = Self::is_head_ref(name.as_ref(), &head_branch_name); - self.render_chip(name, accent_color, is_head) + self.render_ref_chip(name, accent_color, is_head, selected_idx, cx) }), ) })) @@ -3509,7 +3585,7 @@ impl GitGraph { window: &mut Window, cx: &mut Context, ) { - self.deploy_entry_context_menu(event.position, entry_idx, window, cx); + self.deploy_entry_context_menu(event.position, entry_idx, None, window, cx); cx.stop_propagation(); } @@ -6539,7 +6615,7 @@ mod tests { git_graph.update_in(cx, |git_graph, window, cx| { assert_eq!(git_graph.graph_data.commits.len(), 1); - git_graph.deploy_entry_context_menu(point(px(20.), px(20.)), 0, window, cx); + git_graph.deploy_entry_context_menu(point(px(20.), px(20.)), 0, None, window, cx); }); cx.run_until_parked(); @@ -6587,4 +6663,151 @@ mod tests { Some(&"project".to_string()) ); } + + #[gpui::test] + async fn test_global_git_command_task_runs_from_ref_context_menu(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + Path::new("/project"), + json!({ + ".git": {}, + "file.txt": "content", + }), + ) + .await; + + let commit_sha = Oid::try_from("abcdef1234567890abcdef1234567890abcdef12") + .expect("commit SHA should be valid"); + fs.set_graph_commits( + Path::new("/project/.git"), + vec![Arc::new(InitialGraphCommitData { + sha: commit_sha, + parents: SmallVec::new(), + ref_names: vec!["HEAD -> feature-x".into()], + })], + ); + + 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("project should have an active repository") + }); + let task_inventory = project.read_with(cx, |project, cx| { + project + .task_store() + .read(cx) + .task_inventory() + .cloned() + .expect("project should have a task inventory") + }); + + task_inventory.update(cx, |inventory, _| { + inventory + .update_file_based_tasks( + TaskSettingsLocation::Global(Path::new("/tasks.json")), + Some( + &serde_json::to_string(&json!([ + { + "label": "Check out $ZED_GIT_REF", + "command": "git", + "args": ["checkout", "$ZED_GIT_REF"], + "cwd": "$ZED_GIT_REPOSITORY_PATH", + "tags": [GIT_COMMAND_TASK_TAG], + }, + ])) + .expect("tasks JSON should serialize"), + ), + ) + .expect("tasks should parse"); + }); + + 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_workspace, _| { + multi_workspace.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, + ) + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(git_graph.clone()), None, true, window, cx); + }); + cx.run_until_parked(); + + git_graph.update_in(cx, |git_graph, window, cx| { + assert_eq!(git_graph.graph_data.commits.len(), 1); + git_graph.deploy_entry_context_menu( + point(px(20.), px(20.)), + 0, + Some("feature-x".into()), + window, + cx, + ); + }); + cx.run_until_parked(); + + let context_menu = git_graph.read_with(&*cx, |git_graph, _| { + git_graph + .context_menu + .as_ref() + .expect("context menu should be open") + .menu + .clone() + }); + context_menu.update_in(cx, |context_menu, window, cx| { + context_menu + .select_last(window, cx) + .expect("custom Git task should be selectable"); + context_menu.confirm(&menu::Confirm, window, cx); + }); + cx.run_until_parked(); + + let (_task_source_kind, resolved_task) = task_inventory.read_with(&*cx, |inventory, _| { + inventory + .last_scheduled_task(None) + .expect("custom Git task should be scheduled") + }); + + assert_eq!(resolved_task.resolved_label, "Check out feature-x"); + assert_eq!( + resolved_task.resolved.args, + vec!["checkout".to_string(), "feature-x".to_string()] + ); + } + + #[test] + fn test_ref_name_from_decoration() { + assert_eq!( + GitGraph::ref_name_from_decoration("HEAD -> main"), + Some("main".into()) + ); + assert_eq!( + GitGraph::ref_name_from_decoration("main"), + Some("main".into()) + ); + assert_eq!( + GitGraph::ref_name_from_decoration("origin/main"), + Some("origin/main".into()) + ); + assert_eq!( + GitGraph::ref_name_from_decoration("tag: v1.0"), + Some("v1.0".into()) + ); + assert_eq!(GitGraph::ref_name_from_decoration("HEAD"), None); + } } diff --git a/crates/task/src/task.rs b/crates/task/src/task.rs index a0e3f6f6e553bb..43eb2da428ded5 100644 --- a/crates/task/src/task.rs +++ b/crates/task/src/task.rs @@ -193,6 +193,8 @@ pub enum VariableName { GitRepositoryName, /// Absolute path of the Git repository associated with the task context. GitRepositoryPath, + /// Name of the Git ref (branch, remote ref, or tag) associated with the task context. + GitRef, /// Custom variable, provided by the plugin or other external source. /// Will be printed with `CUSTOM_` prefix to avoid potential conflicts with other variables. Custom(Cow<'static, str>), @@ -233,6 +235,7 @@ impl FromStr for VariableName { "GIT_SHA_SHORT" => Self::GitShaShort, "GIT_REPOSITORY_NAME" => Self::GitRepositoryName, "GIT_REPOSITORY_PATH" => Self::GitRepositoryPath, + "GIT_REF" => Self::GitRef, _ => { if let Some(custom_name) = without_prefix.strip_prefix(ZED_CUSTOM_VARIABLE_NAME_PREFIX) @@ -273,6 +276,7 @@ impl std::fmt::Display for VariableName { Self::GitShaShort => write!(f, "{ZED_VARIABLE_NAME_PREFIX}GIT_SHA_SHORT"), Self::GitRepositoryName => write!(f, "{ZED_VARIABLE_NAME_PREFIX}GIT_REPOSITORY_NAME"), Self::GitRepositoryPath => write!(f, "{ZED_VARIABLE_NAME_PREFIX}GIT_REPOSITORY_PATH"), + Self::GitRef => write!(f, "{ZED_VARIABLE_NAME_PREFIX}GIT_REF"), Self::Custom(s) => write!( f, "{ZED_VARIABLE_NAME_PREFIX}{ZED_CUSTOM_VARIABLE_NAME_PREFIX}{s}" diff --git a/docs/src/tasks.md b/docs/src/tasks.md index 889f475aa86d7d..fa7996712786f1 100644 --- a/docs/src/tasks.md +++ b/docs/src/tasks.md @@ -260,6 +260,7 @@ Tasks that define `hooks` are still available from the task modal like any other The Git Graph supports running custom Git command tasks from the commit context menu. To add a command, define a task in your global `tasks.json` file with the `git-command` tag (worktree-local tasks are not supported yet). When shown from a commit's context menu, the task is resolved against the selected commit and repository, and runs from the selected repository root by default. +Right-clicking a ref label (a branch, remote ref, or tag) opens a ref-specific context menu, where the task is additionally resolved against the clicked ref via `ZED_GIT_REF`. Git Graph command tasks support the Git-specific task variables below. These variables are provided only when resolving Git Graph command tasks. @@ -269,6 +270,7 @@ Other task variables, such as `ZED_FILE`, `ZED_SELECTED_TEXT`, `ZED_WORKTREE_ROO - `ZED_GIT_SHA_SHORT`: short SHA of the selected commit. - `ZED_GIT_REPOSITORY_NAME`: name of the selected Git repository. - `ZED_GIT_REPOSITORY_PATH`: absolute path to the selected Git repository's working directory. +- `ZED_GIT_REF`: name of the clicked ref (a branch, remote ref, or tag). Only provided when the menu is opened from a ref label. For example: @@ -279,6 +281,12 @@ For example: "command": "git", "args": ["branch", "-a", "--contains", "$ZED_GIT_SHA"], "tags": ["git-command"] + }, + { + "label": "Check out $ZED_GIT_REF", + "command": "git", + "args": ["checkout", "$ZED_GIT_REF"], + "tags": ["git-command"] } ] ```