diff --git a/crates/git_ui/src/commit_context_menu.rs b/crates/git_ui/src/commit_context_menu.rs new file mode 100644 index 0000000000..e3affe8732 --- /dev/null +++ b/crates/git_ui/src/commit_context_menu.rs @@ -0,0 +1,243 @@ +use crate::commit_view::CommitView; +use git::Oid; +use gpui::{Action, ClipboardItem, Entity, FocusHandle, SharedString, WeakEntity, Window, actions}; +use project::{GIT_COMMAND_TASK_TAG, git_store::Repository}; + +use task::{TaskContext, TaskVariables, VariableName}; +use ui::{Color, ContextMenu, ContextMenuEntry, IconName, IconPosition, prelude::*}; +use workspace::Workspace; + +actions!( + git_graph, + [ + /// 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, + ] +); + +const COMMIT_TAG_LIST_WIDTH_IN_REMS: Rems = rems(10.); +const CUSTOM_GIT_COMMANDS_DOCS_SLUG: &str = "tasks#custom-git-commands"; + +pub(crate) struct CommitContextMenuData { + pub(crate) sha: Oid, + pub(crate) tag_names: Vec, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum CommitContextMenuSource { + GitGraph, + GitPanel, +} + +pub(crate) fn commit_context_menu( + commit: CommitContextMenuData, + source: CommitContextMenuSource, + ref_name: Option, + focus_handle: FocusHandle, + repository: Option>, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, +) -> Entity { + let sha = commit.sha; + let sha_short = sha.display_short(); + let git_tasks = git_context_menu_tasks( + git_task_context(&repository, sha, ref_name.as_deref(), cx), + &workspace, + cx, + ); + let header = match &ref_name { + Some(ref_name) => format!("Ref {ref_name}"), + None => format!("Commit {sha_short}"), + }; + + ContextMenu::build(window, cx, move |context_menu, _, _| { + context_menu + .context(focus_handle) + .header(header) + .entry("View Commit", Some(OpenCommitView.boxed_clone()), { + let repository = repository.clone(); + let workspace = workspace.clone(); + move |window, cx| { + let Some(repository) = repository.clone() else { + return; + }; + CommitView::open( + sha.to_string(), + repository, + workspace.clone(), + None, + None, + window, + cx, + ); + } + }) + .entry( + "Copy SHA", + Some(CopyCommitSha.boxed_clone()), + move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(sha.to_string())); + }, + ) + .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.tag_names.clone(); + 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(); + menu = menu.entry(tag_name, None, move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + tag_name_to_copy.to_string(), + )); + }); + } + menu + }), + } + }) + }) + .when(source == CommitContextMenuSource::GitPanel, |menu| { + menu.entry("Show in Git Graph", None, move |window, cx| { + window.dispatch_action( + Box::new(crate::git_graph::OpenAtCommit { + sha: sha.to_string(), + }), + cx, + ); + }) + }) + .map(|mut menu| { + menu = menu.separator().header("Custom Commands"); + + if git_tasks.is_empty() { + return menu.item( + ContextMenuEntry::new("Learn More") + .icon(IconName::ArrowUpRight) + .icon_color(Color::Muted) + .icon_position(IconPosition::End) + .handler(|_window, cx| { + let docs_url = + release_channel::docs_url(CUSTOM_GIT_COMMANDS_DOCS_SLUG, cx); + cx.open_url(&docs_url); + }), + ); + } + + for (task_source_kind, resolved_task) in git_tasks { + let label = resolved_task.display_label().to_string(); + let workspace = workspace.clone(); + menu = menu.entry(label, None, move |window, cx| { + workspace + .update(cx, |workspace, cx| { + workspace.schedule_resolved_task( + task_source_kind.clone(), + resolved_task.clone(), + false, + window, + cx, + ); + }) + .ok(); + }); + } + + menu + }) + }) +} + +fn git_task_context( + repository: &Option>, + commit_sha: git::Oid, + ref_name: Option<&str>, + cx: &App, +) -> Option { + let repository_path = repository + .as_ref()? + .upgrade()? + .read(cx) + .work_directory_abs_path + .to_path_buf(); + let repository_name = repository_path + .file_name() + .and_then(|name| name.to_str()) + .map(ToString::to_string); + let mut task_variables = TaskVariables::from_iter([ + (VariableName::GitSha, commit_sha.to_string()), + (VariableName::GitShaShort, commit_sha.display_short()), + ( + VariableName::GitRepositoryPath, + repository_path.to_string_lossy().into_owned(), + ), + ]); + + if let Some(repository_name) = repository_name { + 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, + ..TaskContext::default() + }) +} + +fn git_context_menu_tasks( + task_context: Option, + workspace: &WeakEntity, + cx: &App, +) -> Vec<(project::TaskSourceKind, task::ResolvedTask)> { + let Some(task_context) = task_context else { + return Vec::new(); + }; + let Some(workspace) = workspace.upgrade() else { + return Vec::new(); + }; + let project = workspace.read(cx).project().clone(); + let task_inventory = project.read_with(cx, |project, cx| { + project.task_store().read(cx).task_inventory().cloned() + }); + let Some(task_inventory) = task_inventory else { + return Vec::new(); + }; + + task_inventory + .read(cx) + .resolve_global_tasks_with_tag(GIT_COMMAND_TASK_TAG, &task_context) +} diff --git a/crates/git_ui/src/git_graph.rs b/crates/git_ui/src/git_graph.rs index 343cf2abbd..4399545790 100644 --- a/crates/git_ui/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -1,4 +1,6 @@ +pub use crate::commit_context_menu::{CopyCommitSha, CopyCommitTag, OpenCommitView}; use crate::{ + commit_context_menu::{CommitContextMenuData, CommitContextMenuSource, commit_context_menu}, commit_tooltip::{CommitAvatar, CommitDetails, CommitTooltip}, commit_view::CommitView, git_status_icon, @@ -17,9 +19,9 @@ use git::{ status::{FileStatus, StatusCode, TrackedStatus}, }; use gpui::{ - Action, Anchor, AnyElement, App, Bounds, ClickEvent, ClipboardItem, DefiniteLength, - DismissEvent, DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, - Hsla, MouseButton, MouseDownEvent, PathBuilder, Pixels, Point, ScrollHandle, ScrollStrategy, + Anchor, AnyElement, App, Bounds, ClickEvent, ClipboardItem, DefiniteLength, DismissEvent, + DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, + MouseButton, MouseDownEvent, PathBuilder, Pixels, Point, ScrollHandle, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task, TextStyleRefinement, UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, point, prelude::*, px, uniform_list, @@ -29,7 +31,7 @@ use markdown::{Markdown, MarkdownElement}; use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use picker::{Picker, PickerDelegate}; use project::{ - GIT_COMMAND_TASK_TAG, ProjectPath, TaskSourceKind, + ProjectPath, git_store::{ CommitDataState, GitGraphEvent, GitStore, GitStoreEvent, GraphDataResponse, Repository, RepositoryEvent, RepositoryId, @@ -47,12 +49,12 @@ use std::{ sync::{Arc, OnceLock}, time::{Duration, Instant}, }; -use task::{ResolvedTask, TaskContext, TaskVariables, VariableName}; + use theme::AccentColors; use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem}; use ui::{ - Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, ContextMenuEntry, DiffStat, - Divider, HeaderResizeInfo, HighlightedLabel, IndentGuideColors, ListItem, ListItemSpacing, + Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, + HeaderResizeInfo, HighlightedLabel, IndentGuideColors, ListItem, ListItemSpacing, RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState, TableRenderContext, TableResizeBehavior, Tooltip, WithScrollbar, bind_redistributable_columns, prelude::*, redistribute_hidden_fractions, redistribute_hidden_widths, @@ -72,7 +74,6 @@ const LINE_WIDTH: Pixels = px(1.5); const RESIZE_HANDLE_WIDTH: f32 = 8.0; const COPIED_STATE_DURATION: Duration = Duration::from_secs(2); const COMMIT_TAG_LIST_WIDTH_IN_REMS: Rems = rems(10.); -const CUSTOM_GIT_COMMANDS_DOCS_SLUG: &str = "tasks#custom-git-commands"; const TREE_INDENT: f32 = 20.0; const TABLE_COLUMN_COUNT: usize = 4; const ROW_VERTICAL_PADDING: Pixels = px(4.0); @@ -580,12 +581,6 @@ actions!( [ /// Opens the Git Graph Tab. Open, - /// 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. FocusSearch, /// Focuses the next git graph tab stop. @@ -2467,91 +2462,6 @@ impl GitGraph { self.copy_commit_tag(selected_entry_index, window, cx); } - fn git_task_context( - &self, - commit_sha: Oid, - ref_name: Option<&str>, - cx: &App, - ) -> Option { - let repository_path = self - .get_repository(cx)? - .read(cx) - .work_directory_abs_path - .to_path_buf(); - - let repository_name = repository_path - .file_name() - .and_then(|name| name.to_str()) - .map(ToString::to_string); - - let mut task_variables = TaskVariables::from_iter([ - (VariableName::GitSha, commit_sha.to_string()), - (VariableName::GitShaShort, commit_sha.display_short()), - ( - VariableName::GitRepositoryPath, - repository_path.to_string_lossy().into_owned(), - ), - ]); - - if let Some(repository_name) = repository_name { - 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, - ..TaskContext::default() - }) - } - - fn git_context_menu_tasks( - &self, - task_context: &TaskContext, - cx: &App, - ) -> Vec<(TaskSourceKind, ResolvedTask)> { - let Some(workspace) = self.workspace.upgrade() else { - return Vec::new(); - }; - - let project = workspace.read(cx).project().clone(); - - let task_inventory = project.read_with(cx, |project, cx| { - project.task_store().read(cx).task_inventory().cloned() - }); - - let Some(task_inventory) = task_inventory else { - return Vec::new(); - }; - - task_inventory - .read(cx) - .resolve_global_tasks_with_tag(GIT_COMMAND_TASK_TAG, task_context) - } - - fn schedule_git_task( - &mut self, - task_source_kind: TaskSourceKind, - resolved_task: ResolvedTask, - window: &mut Window, - cx: &mut Context, - ) { - self.workspace - .update(cx, |workspace, cx| { - workspace.schedule_resolved_task( - task_source_kind, - resolved_task, - false, - window, - cx, - ); - }) - .ok(); - } - fn deploy_entry_context_menu( &mut self, position: Point, @@ -2563,129 +2473,27 @@ impl GitGraph { let Some(commit) = self.graph_data.commits.get(index) else { return; }; - let sha = commit.data.sha; - let sha_short = sha.display_short(); - let git_tasks = self - .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(header) - .entry( - "View Commit", - Some(OpenCommitView.boxed_clone()), - window.handler_for(&git_graph, move |this, window, cx| { - this.open_commit_view(index, window, cx); - }), - ) - .entry( - "Copy SHA", - Some(CopyCommitSha.boxed_clone()), - window.handler_for(&git_graph, move |this, _window, cx| { - this.copy_commit_sha(index, cx); - }), - ) - .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(); - - 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"); - - if git_tasks.is_empty() { - return menu.item( - ContextMenuEntry::new("Learn More") - .icon(IconName::ArrowUpRight) - .icon_color(Color::Muted) - .icon_position(IconPosition::End) - .handler(|_window, cx| { - let docs_url = release_channel::docs_url( - CUSTOM_GIT_COMMANDS_DOCS_SLUG, - cx, - ); - cx.open_url(&docs_url); - }), - ); - } - - for (task_source_kind, resolved_task) in git_tasks { - let label = resolved_task.display_label().to_string(); - - menu = menu.entry( - label, - None, - window.handler_for(&git_graph, move |this, window, cx| { - this.schedule_git_task( - task_source_kind.clone(), - resolved_task.clone(), - window, - cx, - ); - }), - ); - } - - menu - }) - }); + let repository = self + .get_repository(cx) + .map(|repository| repository.downgrade()); + let context_menu = commit_context_menu( + CommitContextMenuData { + sha: commit.data.sha, + tag_names: commit + .data + .tag_names() + .into_iter() + .map(|tag_name| SharedString::from(tag_name.to_string())) + .collect(), + }, + CommitContextMenuSource::GitGraph, + ref_name, + self.focus_handle.clone(), + repository, + self.workspace.clone(), + window, + cx, + ); self.set_context_menu(context_menu, position, Some(index), window, cx); } @@ -4946,7 +4754,9 @@ mod tests { use git::repository::{CommitData, InitialGraphCommitData}; use gpui::{TestAppContext, UpdateGlobal}; use project::git_store::{GitStoreEvent, RepositoryEvent}; - use project::{Project, TaskSourceKind, task_store::TaskSettingsLocation}; + use project::{ + GIT_COMMAND_TASK_TAG, Project, TaskSourceKind, task_store::TaskSettingsLocation, + }; use rand::prelude::*; use serde_json::json; use settings::{SettingsStore, ThemeSettingsContent}; diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 29328c7746..12d9a1e739 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -1,4 +1,7 @@ use crate::askpass_modal::AskPassModal; +use crate::commit_context_menu::{ + CommitContextMenuData, CommitContextMenuSource, commit_context_menu, +}; use crate::commit_modal::CommitModal; use crate::commit_tooltip::{CommitAvatar, CommitTooltip}; use crate::commit_view::CommitView; @@ -6187,6 +6190,37 @@ impl GitPanel { ); } + fn deploy_history_context_menu( + &mut self, + position: Point, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { + let Some(commit) = self.commit_history_entries().get(index).cloned() else { + return; + }; + let Some(repository) = self.active_repository.as_ref() else { + return; + }; + let context_menu = commit_context_menu( + CommitContextMenuData { + sha: commit.sha, + tag_names: commit.tag_names, + }, + CommitContextMenuSource::GitPanel, + None, + self.focus_handle.clone(), + Some(repository.downgrade()), + self.workspace.clone(), + window, + cx, + ); + self.focused_history_entry = Some(index); + self.history_keyboard_nav = false; + self.set_context_menu(context_menu, position, window, cx); + } + fn activate_changes_tab( &mut self, _: &ActivateChangesTab, @@ -6347,6 +6381,7 @@ impl GitPanel { let focused_history_entry = self.focused_history_entry; let is_panel_focused = self.focus_handle.is_focused(window); let show_focus_border = self.history_keyboard_nav; + let has_context_menu = self.context_menu.is_some(); let ahead_count = active_repository .read(cx) @@ -6485,13 +6520,20 @@ impl GitPanel { tag_names .iter() .take(MAX_HISTORY_TAG_CHIPS) - .cloned() .map(|tag_name| { + let tag_name = tag_name.clone(); Chip::new(tag_name.clone()) .truncate() - .tooltip(Tooltip::text( - tag_name, - )) + .when( + !has_context_menu, + |chip| { + chip.tooltip( + Tooltip::text( + tag_name, + ), + ) + }, + ) }), ) .when(hidden_tag_count > 0, |this| { @@ -6502,9 +6544,11 @@ impl GitPanel { Chip::new(format!( "+{hidden_tag_count}" )) - .tooltip(Tooltip::text( - hidden_tag_names, - )), + .when(!has_context_menu, |chip| { + chip.tooltip(Tooltip::text( + hidden_tag_names, + )) + }), ) }) })) @@ -6541,13 +6585,15 @@ impl GitPanel { .color(Color::Muted), ), ) - .tooltip(move |_, cx| { - Tooltip::with_meta( - "View Commit", - None, - short_sha.clone(), - cx, - ) + .when(!has_context_menu, |this| { + this.tooltip(move |_, cx| { + Tooltip::with_meta( + "View Commit", + None, + short_sha.clone(), + cx, + ) + }) }) .on_mouse_down(gpui::MouseButton::Left, { let git_panel = git_panel.clone(); @@ -6561,6 +6607,22 @@ impl GitPanel { .ok(); } }) + .on_mouse_down(MouseButton::Right, { + let git_panel = git_panel.clone(); + move |event, window, cx| { + git_panel + .update(cx, |panel, cx| { + panel.deploy_history_context_menu( + event.position, + index, + window, + cx, + ); + }) + .ok(); + cx.stop_propagation(); + } + }) .on_click(move |_, window, cx| { CommitView::open( sha_for_click.clone(), diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index aefd0eff50..39e9299bc4 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -36,6 +36,7 @@ use crate::{ mod askpass_modal; pub mod branch_diff; pub mod branch_picker; +mod commit_context_menu; mod commit_modal; pub mod commit_tooltip; pub mod commit_view;