From a552765c60e24c03beb62983043967a2213b7273 Mon Sep 17 00:00:00 2001 From: Trong Nguyen Date: Wed, 6 May 2026 09:17:12 +0700 Subject: [PATCH 01/11] feat(git_graph): add commit context menu operations --- crates/fs/src/fake_git_repo.rs | 59 +- crates/git/src/repository.rs | 270 ++++++++ crates/git_graph/src/git_graph.rs | 1048 ++++++++++++++++++++++++++++- crates/project/src/git_store.rs | 163 ++++- 4 files changed, 1504 insertions(+), 36 deletions(-) diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 5f2cb0515ce757..5939948a32804b 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -12,7 +12,7 @@ use git::{ blame::Blame, repository::{ AskPassDelegate, Branch, CommitData, CommitDataReader, CommitDetails, CommitOptions, - CreateWorktreeTarget, FetchOptions, GRAPH_CHUNK_SIZE, GitRepository, + CreateWorktreeTarget, DropCommitSupport, FetchOptions, GRAPH_CHUNK_SIZE, GitRepository, GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, LogSource, PushOptions, RefEdit, Remote, RepoPath, ResetMode, SearchCommitArgs, Worktree, }, @@ -322,6 +322,10 @@ impl GitRepository for FakeGitRepository { state.head_contents = snapshot.head_contents; state.index_contents = state.head_contents.clone(); } + ResetMode::Hard => { + state.head_contents = snapshot.head_contents; + state.index_contents = state.head_contents.clone(); + } } state.refs.insert("HEAD".into(), snapshot.sha); @@ -875,6 +879,59 @@ impl GitRepository for FakeGitRepository { }) } + fn create_branch_at(&self, _sha: String, name: String) -> BoxFuture<'_, Result<()>> { + self.with_state_async(true, move |state| { + state.branches.insert(name); + Ok(()) + }) + } + + fn create_tag( + &self, + _sha: String, + _name: String, + _message: Option, + ) -> BoxFuture<'_, Result<()>> { + future::ready(Ok(())).boxed() + } + + fn checkout_commit(&self, _sha: String) -> BoxFuture<'_, Result<()>> { + future::ready(Ok(())).boxed() + } + + fn cherry_pick( + &self, + _sha: String, + _record_origin: bool, + _no_commit: bool, + ) -> BoxFuture<'_, Result<()>> { + future::ready(Ok(())).boxed() + } + + fn revert_commit(&self, _sha: String) -> BoxFuture<'_, Result<()>> { + future::ready(Ok(())).boxed() + } + + fn drop_commit_support(&self, _sha: String) -> BoxFuture<'_, Result> { + future::ready(Ok(DropCommitSupport { + can_drop: true, + reason: None, + })) + .boxed() + } + + fn drop_commit(&self, _sha: String) -> BoxFuture<'_, Result<()>> { + future::ready(Ok(())).boxed() + } + + fn merge_commit(&self, _sha: String) -> BoxFuture<'_, Result<()>> { + future::ready(Ok(())).boxed() + } + + fn rebase_onto(&self, _sha: String) -> BoxFuture<'_, Result<()>> { + future::ready(Ok(())).boxed() + } + fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> { self.with_state_async(true, move |state| { if !state.branches.remove(&branch) { diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 90ac06d959a1fa..7116a7ffa3917e 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -530,6 +530,14 @@ pub enum ResetMode { /// Reset the branch pointer and index, leave worktree unchanged (this makes it look as though things that were /// committed are now unstaged). Mixed, + /// Reset the branch pointer, index, and worktree. + Hard, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DropCommitSupport { + pub can_drop: bool, + pub reason: Option, } #[derive(Debug, Clone, Hash, PartialEq, Eq)] @@ -773,6 +781,25 @@ pub trait GitRepository: Send + Sync { fn change_branch(&self, name: String) -> BoxFuture<'_, Result<()>>; fn create_branch(&self, name: String, base_branch: Option) -> BoxFuture<'_, Result<()>>; + fn create_branch_at(&self, sha: String, name: String) -> BoxFuture<'_, Result<()>>; + fn create_tag( + &self, + sha: String, + name: String, + message: Option, + ) -> BoxFuture<'_, Result<()>>; + fn checkout_commit(&self, sha: String) -> BoxFuture<'_, Result<()>>; + fn cherry_pick( + &self, + sha: String, + record_origin: bool, + no_commit: bool, + ) -> BoxFuture<'_, Result<()>>; + fn revert_commit(&self, sha: String) -> BoxFuture<'_, Result<()>>; + fn drop_commit_support(&self, sha: String) -> BoxFuture<'_, Result>; + fn drop_commit(&self, sha: String) -> BoxFuture<'_, Result<()>>; + fn merge_commit(&self, sha: String) -> BoxFuture<'_, Result<()>>; + fn rebase_onto(&self, sha: String) -> BoxFuture<'_, Result<()>>; fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>>; fn delete_branch(&self, is_remote: bool, name: String) -> BoxFuture<'_, Result<()>>; @@ -1125,6 +1152,79 @@ impl RealGitRepository { .boxed() } + fn simple_git_command(&self, args: Vec) -> BoxFuture<'_, Result<()>> { + let git = self.git_binary(); + + self.executor + .spawn(async move { + let arg_refs = args.iter().map(String::as_str).collect::>(); + git.run(&arg_refs).await?; + Ok(()) + }) + .boxed() + } + + fn drop_commit_support_impl(&self, sha: String) -> BoxFuture<'_, Result> { + let git = self.git_binary(); + + self.executor + .spawn(async move { + let head_sha = git.run(&["rev-parse", "HEAD"]).await?; + let commit_line = git.run(&["rev-list", "--parents", "-n", "1", &sha]).await?; + let mut commit_parts = commit_line.split_whitespace(); + let _commit_sha = commit_parts + .next() + .context("failed to parse commit metadata for drop preflight")?; + let parent_shas = commit_parts.collect::>(); + + if parent_shas.is_empty() { + return Ok(DropCommitSupport { + can_drop: false, + reason: Some("Cannot drop the root commit".into()), + }); + } + + if parent_shas.len() > 1 { + return Ok(DropCommitSupport { + can_drop: false, + reason: Some("Cannot drop merge commits".into()), + }); + } + + let ancestry_output = git + .build_command(&["merge-base", "--is-ancestor", &sha, "HEAD"]) + .output() + .await?; + if !ancestry_output.status.success() { + return Ok(DropCommitSupport { + can_drop: false, + reason: Some("Commit is not on the current branch".into()), + }); + } + + if sha.trim() == head_sha.trim() { + let status_output = git + .run(&["status", "--porcelain=v1", "--untracked-files=no"]) + .await?; + if !status_output.trim().is_empty() { + return Ok(DropCommitSupport { + can_drop: false, + reason: Some( + "Cannot drop HEAD while the working tree has uncommitted changes" + .into(), + ), + }); + } + } + + Ok(DropCommitSupport { + can_drop: true, + reason: None, + }) + }) + .boxed() + } + async fn any_git_binary_help_output(&self) -> SharedString { if let Some(output) = self.any_git_binary_help_output.lock().clone() { return output; @@ -1386,6 +1486,7 @@ impl GitRepository for RealGitRepository { let mode_flag = match mode { ResetMode::Mixed => "--mixed", ResetMode::Soft => "--soft", + ResetMode::Hard => "--hard", }; let git = git_binary?; @@ -2020,6 +2121,175 @@ impl GitRepository for RealGitRepository { .boxed() } + fn create_branch_at(&self, sha: String, name: String) -> BoxFuture<'_, Result<()>> { + self.simple_git_command(vec!["branch".into(), name, sha]) + } + + fn create_tag( + &self, + sha: String, + name: String, + message: Option, + ) -> BoxFuture<'_, Result<()>> { + let mut args = vec!["tag".to_string()]; + if let Some(message) = message.filter(|message| !message.trim().is_empty()) { + args.push("-a".into()); + args.push(name); + args.push(sha); + args.push("-m".into()); + args.push(message); + } else { + args.push(name); + args.push(sha); + } + + self.simple_git_command(args) + } + + fn checkout_commit(&self, sha: String) -> BoxFuture<'_, Result<()>> { + self.simple_git_command(vec!["checkout".into(), "--detach".into(), sha]) + } + + fn cherry_pick( + &self, + sha: String, + record_origin: bool, + no_commit: bool, + ) -> BoxFuture<'_, Result<()>> { + let git = self.git_binary(); + + self.executor + .spawn(async move { + let commit_line = git.run(&["rev-list", "--parents", "-n", "1", &sha]).await?; + let parent_count = commit_line.split_whitespace().count().saturating_sub(1); + + let mut args = vec!["cherry-pick"]; + if parent_count > 1 { + args.extend_from_slice(&["-m", "1"]); + } + if record_origin { + args.push("-x"); + } + if no_commit { + args.push("--no-commit"); + } + args.push(&sha); + git.run(&args).await?; + Ok(()) + }) + .boxed() + } + + fn revert_commit(&self, sha: String) -> BoxFuture<'_, Result<()>> { + self.simple_git_command(vec!["revert".into(), "--no-edit".into(), sha]) + } + + fn drop_commit_support(&self, sha: String) -> BoxFuture<'_, Result> { + self.drop_commit_support_impl(sha) + } + + fn drop_commit(&self, sha: String) -> BoxFuture<'_, Result<()>> { + let git = self.git_binary(); + + self.executor + .spawn(async move { + let support = { + let commit_line = git.run(&["rev-list", "--parents", "-n", "1", &sha]).await?; + let mut commit_parts = commit_line.split_whitespace(); + let _commit_sha = commit_parts + .next() + .context("failed to parse commit metadata while dropping commit")?; + let parent_shas = commit_parts.collect::>(); + + if parent_shas.is_empty() { + DropCommitSupport { + can_drop: false, + reason: Some("Cannot drop the root commit".into()), + } + } else if parent_shas.len() > 1 { + DropCommitSupport { + can_drop: false, + reason: Some("Cannot drop merge commits".into()), + } + } else { + let ancestry_output = git + .build_command(&["merge-base", "--is-ancestor", &sha, "HEAD"]) + .output() + .await?; + if !ancestry_output.status.success() { + DropCommitSupport { + can_drop: false, + reason: Some("Commit is not on the current branch".into()), + } + } else { + let head_sha = git.run(&["rev-parse", "HEAD"]).await?; + if sha.trim() == head_sha.trim() { + let status_output = git + .run(&["status", "--porcelain=v1", "--untracked-files=no"]) + .await?; + if !status_output.trim().is_empty() { + DropCommitSupport { + can_drop: false, + reason: Some( + "Cannot drop HEAD while the working tree has uncommitted changes" + .into(), + ), + } + } else { + DropCommitSupport { + can_drop: true, + reason: None, + } + } + } else { + DropCommitSupport { + can_drop: true, + reason: None, + } + } + } + } + }; + + if !support.can_drop { + bail!( + "{}", + support + .reason + .unwrap_or_else(|| "Commit cannot be dropped".into()) + ); + } + + let head_sha = git.run(&["rev-parse", "HEAD"]).await?; + if sha.trim() == head_sha.trim() { + git.run(&["reset", "--hard", "HEAD^"]).await?; + return Ok(()); + } + + let commit_line = git.run(&["rev-list", "--parents", "-n", "1", &sha]).await?; + let mut commit_parts = commit_line.split_whitespace(); + let _commit_sha = commit_parts + .next() + .context("failed to parse commit metadata while dropping commit")?; + let parent_sha = commit_parts + .next() + .context("selected commit does not have a first parent")?; + + git.run(&["rebase", "--onto", parent_sha, &sha, "HEAD"]) + .await?; + Ok(()) + }) + .boxed() + } + + fn merge_commit(&self, sha: String) -> BoxFuture<'_, Result<()>> { + self.simple_git_command(vec!["merge".into(), sha]) + } + + fn rebase_onto(&self, sha: String) -> BoxFuture<'_, Result<()>> { + self.simple_git_command(vec!["rebase".into(), sha]) + } + fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary_in_worktree(); diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index 73ad9293e17318..2c06d67c3ef095 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -4,22 +4,24 @@ use git::{ BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote, parse_git_remote_url, repository::{ - CommitDiff, CommitFile, InitialGraphCommitData, LogOrder, LogSource, RepoPath, - SearchCommitArgs, + CommitDiff, CommitFile, DropCommitSupport, InitialGraphCommitData, LogOrder, LogSource, + RepoPath, ResetMode, SearchCommitArgs, }, status::{FileStatus, StatusCode, TrackedStatus}, }; -use git_ui::{commit_tooltip::CommitAvatar, commit_view::CommitView, git_status_icon}; +use git_ui::{ + commit_tooltip::CommitAvatar, commit_view::CommitView, git_status_icon, picker_prompt, +}; use gpui::{ Action, Anchor, AnyElement, App, Bounds, ClickEvent, ClipboardItem, DefiniteLength, DismissEvent, DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, - Hsla, MouseButton, MouseDownEvent, PathBuilder, Pixels, Point, ScrollStrategy, + Hsla, MouseButton, MouseDownEvent, PathBuilder, Pixels, Point, PromptLevel, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task, TextStyleRefinement, UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, point, prelude::*, px, uniform_list, }; use language::line_diff; -use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious}; +use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use project::{ ProjectPath, git_store::{ @@ -43,15 +45,17 @@ 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, + Button, ButtonLike, ButtonStyle, Checkbox, Chip, ColumnWidthConfig, CommonAnimationExt as _, + ContextMenu, DiffStat, Divider, HeaderResizeInfo, HighlightedLabel, + RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState, + TableRenderContext, TableResizeBehavior, ToggleState, 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}, + notifications::DetachAndPromptErr, }; const COMMIT_CIRCLE_RADIUS: Pixels = px(3.5); @@ -232,6 +236,46 @@ struct SearchState { pub selected_index: Option, } +#[derive(Clone)] +struct SelectedCommitInfo { + index: usize, + sha: SharedString, + subject: Option, +} + +#[derive(Clone)] +struct CommitContextMenuState { + row_index: usize, + drop_support: DropCommitSupport, +} + +#[derive(Clone, Copy)] +enum ResetPromptMode { + Soft, + Mixed, + Hard, +} + +impl ResetPromptMode { + const ALL: [Self; 3] = [Self::Soft, Self::Mixed, Self::Hard]; + + fn to_reset_mode(self) -> ResetMode { + match self { + Self::Soft => ResetMode::Soft, + Self::Mixed => ResetMode::Mixed, + Self::Hard => ResetMode::Hard, + } + } + + fn label(self) -> &'static str { + match self { + Self::Soft => "Soft", + Self::Mixed => "Mixed", + Self::Hard => "Hard", + } + } +} + pub struct SplitState { left_ratio: f32, visible_left_ratio: f32, @@ -293,6 +337,17 @@ actions!( ScrollUp, /// Selects a commit half a page below the current selection. ScrollDown, + AddTag, + CreateBranchAtCommit, + CheckoutCommit, + CherryPickCommit, + RevertCommit, + DropCommit, + MergeCommit, + RebaseOntoCommit, + ResetCommit, + CopyCommitHash, + CopyCommitSubject, ] ); @@ -998,6 +1053,7 @@ pub struct GitGraph { git_store: Entity, workspace: WeakEntity, context_menu: Option, + commit_context_menu_state: Option, table_interaction_state: Entity, column_widths: Entity, selected_entry_idx: Option, @@ -1021,6 +1077,7 @@ impl GitGraph { self.search_state.selected_index = None; self.search_state.state.next_state(); self.context_menu = None; + self.commit_context_menu_state = None; cx.emit(ItemEvent::Edit); cx.notify(); } @@ -1040,6 +1097,18 @@ impl GitGraph { (raw * scale).round() / scale } + fn reload_graph(&mut self, cx: &mut Context) { + self.context_menu = None; + self.commit_context_menu_state = None; + self.selected_entry_idx = None; + self.hovered_entry_idx = None; + self.selected_commit_diff = None; + self.selected_commit_diff_stats = None; + self._commit_diff_task = None; + self.pending_select_sha = None; + self.invalidate_state(cx); + } + fn visible_row_count(&self, window: &Window, cx: &App) -> usize { let row_height = Self::row_height(window, cx); let viewport_height = self @@ -1219,6 +1288,7 @@ impl GitGraph { graph_data: graph, _commit_diff_task: None, context_menu: None, + commit_context_menu_state: None, table_interaction_state, column_widths, selected_entry_idx: None, @@ -1339,6 +1409,70 @@ impl GitGraph { git_store.repositories().get(&self.repo_id).cloned() } + fn commit_info_for_entry(&self, index: usize, cx: &App) -> Option { + let commit = self.graph_data.commits.get(index)?; + let repository = self.get_repository(cx)?; + let subject = match repository.read(cx).commit_data_state(commit.data.sha) { + Some(CommitDataState::Loaded(data)) => Some(data.subject.clone()), + _ => None, + }; + + Some(SelectedCommitInfo { + index, + sha: commit.data.sha.to_string().into(), + subject, + }) + } + + fn context_menu_commit_info(&self, cx: &App) -> Option { + self.commit_info_for_entry(self.context_menu.as_ref()?.entry_idx, cx) + } + + fn prompt_confirmation( + &self, + level: PromptLevel, + message: impl Into, + detail: Option, + confirm_label: &'static str, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + let message = message.into(); + let detail = detail.map(|detail| detail.to_string()); + let answer = window.prompt( + level, + message.as_ref(), + detail.as_deref(), + &[confirm_label, "Cancel"], + cx, + ); + + cx.spawn(async move |_, _| Ok(answer.await? == 0)) + } + + fn prompt_reset_mode( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + let Some(workspace) = self.workspace.upgrade() else { + return Task::ready(None); + }; + + let options = ResetPromptMode::ALL + .into_iter() + .map(|mode| SharedString::from(mode.label())) + .collect::>(); + let workspace = workspace.downgrade(); + let picker = picker_prompt::prompt("Select reset mode...", options, workspace, window, cx); + + window.spawn(cx, async move |_| { + picker + .await + .and_then(|index| ResetPromptMode::ALL.get(index).copied()) + }) + } + fn has_context_menu(&self) -> bool { self.context_menu.is_some() } @@ -1888,40 +2022,502 @@ impl GitGraph { self.copy_commit_sha(selected_entry_index, cx); } + fn copy_context_menu_commit_hash(&mut self, cx: &mut Context) { + let Some(commit) = self.context_menu_commit_info(cx) else { + return; + }; + + cx.write_to_clipboard(ClipboardItem::new_string(commit.sha.to_string())); + } + + fn copy_context_menu_commit_subject(&mut self, cx: &mut Context) { + let Some(commit) = self.context_menu_commit_info(cx) else { + return; + }; + let Some(subject) = commit.subject else { + return; + }; + + cx.write_to_clipboard(ClipboardItem::new_string(subject.to_string())); + } + + fn show_add_tag_modal(&mut self, window: &mut Window, cx: &mut Context) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + let Some(commit) = self.context_menu_commit_info(cx) else { + return; + }; + let workspace = self.workspace.clone(); + let graph = cx.weak_entity(); + + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + AddTagModal::new(graph, repository, commit.sha.clone(), window, cx) + }); + }); + } + } + + fn show_create_branch_modal(&mut self, window: &mut Window, cx: &mut Context) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + let Some(commit) = self.context_menu_commit_info(cx) else { + return; + }; + let workspace = self.workspace.clone(); + let graph = cx.weak_entity(); + + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + CreateBranchAtCommitModal::new( + graph, + repository, + commit.sha.clone(), + window, + cx, + ) + }); + }); + } + } + + fn checkout_context_menu_commit(&mut self, window: &mut Window, cx: &mut Context) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + let Some(commit) = self.context_menu_commit_info(cx) else { + return; + }; + + let confirm = self.prompt_confirmation( + PromptLevel::Warning, + format!("Checkout {} in detached HEAD state?", commit.sha), + Some("This will detach HEAD at the selected commit.".into()), + "Checkout", + window, + cx, + ); + + cx.spawn_in(window, async move |this, cx| { + if !confirm.await? { + return Ok(()); + } + + this.update_in(cx, |this, window, cx| { + let sha = commit.sha.to_string(); + let repository = repository.clone(); + let task = cx.spawn(async move |_, cx| { + repository + .update(cx, |repository, _| repository.checkout_commit(sha)) + .await + .map_err(|_| anyhow::anyhow!("Operation was canceled"))??; + Ok(()) + }); + this.run_git_operation(task, "Failed to checkout commit", window, cx); + })?; + + Ok(()) + }) + .detach_and_prompt_err("Failed to checkout commit", window, cx, |error, _, _| { + Some(error.to_string()) + }); + } + + fn cherry_pick_context_menu_commit(&mut self, window: &mut Window, cx: &mut Context) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + let Some(commit) = self.context_menu_commit_info(cx) else { + return; + }; + let workspace = self.workspace.clone(); + let graph = cx.weak_entity(); + + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + CherryPickModal::new(graph, repository, commit.sha.clone(), window, cx) + }); + }); + } + } + + fn revert_context_menu_commit(&mut self, window: &mut Window, cx: &mut Context) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + let Some(commit) = self.context_menu_commit_info(cx) else { + return; + }; + + let confirm = self.prompt_confirmation( + PromptLevel::Warning, + format!("Revert commit {}?", commit.sha), + None, + "Revert", + window, + cx, + ); + + cx.spawn_in(window, async move |this, cx| { + if !confirm.await? { + return Ok(()); + } + + this.update_in(cx, |this, window, cx| { + let sha = commit.sha.to_string(); + let repository = repository.clone(); + let task = cx.spawn(async move |_, cx| { + repository + .update(cx, |repository, _| repository.revert_commit(sha)) + .await + .map_err(|_| anyhow::anyhow!("Operation was canceled"))??; + Ok(()) + }); + this.run_git_operation(task, "Failed to revert commit", window, cx); + })?; + + Ok(()) + }) + .detach_and_prompt_err("Failed to revert commit", window, cx, |error, _, _| { + Some(error.to_string()) + }); + } + + fn drop_context_menu_commit(&mut self, window: &mut Window, cx: &mut Context) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + let Some(commit) = self.context_menu_commit_info(cx) else { + return; + }; + let Some(context_state) = self.commit_context_menu_state.as_ref() else { + return; + }; + if context_state.row_index != commit.index || !context_state.drop_support.can_drop { + return; + } + + let confirm = self.prompt_confirmation( + PromptLevel::Warning, + format!("Drop commit {}?", commit.sha), + Some("This rewrites history on the current branch.".into()), + "Drop Commit", + window, + cx, + ); + + cx.spawn_in(window, async move |this, cx| { + if !confirm.await? { + return Ok(()); + } + + this.update_in(cx, |this, window, cx| { + let sha = commit.sha.to_string(); + let repository = repository.clone(); + let task = cx.spawn(async move |_, cx| { + repository + .update(cx, |repository, _| repository.drop_commit(sha)) + .await + .map_err(|_| anyhow::anyhow!("Operation was canceled"))??; + Ok(()) + }); + this.run_git_operation(task, "Failed to drop commit", window, cx); + })?; + + Ok(()) + }) + .detach_and_prompt_err("Failed to drop commit", window, cx, |error, _, _| { + Some(error.to_string()) + }); + } + + fn merge_context_menu_commit(&mut self, window: &mut Window, cx: &mut Context) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + let Some(commit) = self.context_menu_commit_info(cx) else { + return; + }; + + let confirm = self.prompt_confirmation( + PromptLevel::Warning, + format!("Merge commit {} into the current branch?", commit.sha), + None, + "Merge", + window, + cx, + ); + + cx.spawn_in(window, async move |this, cx| { + if !confirm.await? { + return Ok(()); + } + + this.update_in(cx, |this, window, cx| { + let sha = commit.sha.to_string(); + let repository = repository.clone(); + let task = cx.spawn(async move |_, cx| { + repository + .update(cx, |repository, _| repository.merge_commit(sha)) + .await + .map_err(|_| anyhow::anyhow!("Operation was canceled"))??; + Ok(()) + }); + this.run_git_operation(task, "Failed to merge commit", window, cx); + })?; + + Ok(()) + }) + .detach_and_prompt_err("Failed to merge commit", window, cx, |error, _, _| { + Some(error.to_string()) + }); + } + + fn rebase_context_menu_commit(&mut self, window: &mut Window, cx: &mut Context) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + let Some(commit) = self.context_menu_commit_info(cx) else { + return; + }; + + let confirm = self.prompt_confirmation( + PromptLevel::Warning, + format!("Rebase the current branch onto {}?", commit.sha), + None, + "Rebase", + window, + cx, + ); + + cx.spawn_in(window, async move |this, cx| { + if !confirm.await? { + return Ok(()); + } + + this.update_in(cx, |this, window, cx| { + let sha = commit.sha.to_string(); + let repository = repository.clone(); + let task = cx.spawn(async move |_, cx| { + repository + .update(cx, |repository, _| repository.rebase_onto(sha)) + .await + .map_err(|_| anyhow::anyhow!("Operation was canceled"))??; + Ok(()) + }); + this.run_git_operation(task, "Failed to rebase current branch", window, cx); + })?; + + Ok(()) + }) + .detach_and_prompt_err( + "Failed to rebase current branch", + window, + cx, + |error, _, _| Some(error.to_string()), + ); + } + + fn reset_context_menu_commit(&mut self, window: &mut Window, cx: &mut Context) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + let Some(commit) = self.context_menu_commit_info(cx) else { + return; + }; + + let reset_mode_prompt = self.prompt_reset_mode(window, cx); + + cx.spawn_in(window, async move |this, cx| { + let Some(prompt_mode) = reset_mode_prompt.await else { + return Ok(()); + }; + + let confirm = this.update_in(cx, |this, window, cx| { + let detail = match prompt_mode { + ResetPromptMode::Hard => { + Some("Hard reset will discard working tree and index changes.".into()) + } + ResetPromptMode::Soft => { + Some("Soft reset moves the branch pointer and keeps changes staged.".into()) + } + ResetPromptMode::Mixed => Some( + "Mixed reset moves the branch pointer and keeps changes unstaged.".into(), + ), + }; + this.prompt_confirmation( + PromptLevel::Warning, + format!( + "Reset the current branch to {} using {} mode?", + commit.sha, + prompt_mode.label() + ), + detail, + "Reset", + window, + cx, + ) + })?; + + if !confirm.await? { + return Ok(()); + } + + this.update_in(cx, |this, window, cx| { + let sha = commit.sha.to_string(); + let repository = repository.clone(); + let task = cx.spawn(async move |_, cx| { + repository + .update(cx, |repository, cx| { + repository.reset(sha, prompt_mode.to_reset_mode(), cx) + }) + .await + .map_err(|_| anyhow::anyhow!("Operation was canceled"))??; + Ok(()) + }); + this.run_git_operation(task, "Failed to reset current branch", window, cx); + })?; + + Ok(()) + }) + .detach_and_prompt_err( + "Failed to reset current branch", + window, + cx, + |error, _, _| Some(error.to_string()), + ); + } + fn deploy_entry_context_menu( &mut self, position: Point, - index: usize, + entry_idx: usize, window: &mut Window, cx: &mut Context, ) { - let Some(commit) = self.graph_data.commits.get(index) else { + let Some(commit) = self.graph_data.commits.get(entry_idx) else { + return; + }; + let Some(repository) = self.get_repository(cx) else { return; }; - let short_sha = commit.data.sha.display_short(); + let sha = commit.data.sha.to_string(); + let receiver = repository.update(cx, |repository, _| repository.drop_commit_support(sha)); + + cx.spawn_in(window, async move |this, cx| { + let drop_support = receiver + .await + .map_err(|_| anyhow::anyhow!("Operation was canceled")) + .and_then(|result| result) + .unwrap_or_else(|error| DropCommitSupport { + can_drop: false, + reason: Some(SharedString::from(error.to_string())), + }); + + let _ = this.update_in(cx, |this, window, cx| { + this.commit_context_menu_state = Some(CommitContextMenuState { + row_index: entry_idx, + drop_support, + }); + if let Some(context_menu) = this.build_commit_context_menu(entry_idx, window, cx) { + this.set_context_menu(context_menu, position, entry_idx, window, cx); + } + }); + }) + .detach(); + } + + fn build_commit_context_menu( + &self, + entry_idx: usize, + window: &mut Window, + cx: &mut Context, + ) -> Option> { + let selected_commit = self.commit_info_for_entry(entry_idx, cx)?; + let context_state = self.commit_context_menu_state.as_ref()?; + if context_state.row_index != selected_commit.index { + return None; + } + + let drop_disabled = !context_state.drop_support.can_drop; + let copy_subject_disabled = selected_commit.subject.is_none(); 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 {short_sha}")) - .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); - }), - ) + + Some(ContextMenu::build( + window, + cx, + move |context_menu, window, _| { + context_menu + .context(focus_handle) + .header(format!("Commit {}", selected_commit.sha)) + .entry( + "View Commit", + Some(OpenCommitView.boxed_clone()), + window.handler_for(&git_graph, move |this, window, cx| { + this.open_commit_view(entry_idx, window, cx); + }), + ) + .separator() + .action("Add Tag...", AddTag.boxed_clone()) + .action("Create Branch...", CreateBranchAtCommit.boxed_clone()) + .separator() + .action("Checkout...", CheckoutCommit.boxed_clone()) + .action("Cherry Pick...", CherryPickCommit.boxed_clone()) + .action("Revert...", RevertCommit.boxed_clone()) + .action_disabled_when(drop_disabled, "Drop...", DropCommit.boxed_clone()) + .action("Merge into current branch...", MergeCommit.boxed_clone()) + .action( + "Rebase current branch on this Commit...", + RebaseOntoCommit.boxed_clone(), + ) + .action( + "Reset current branch to this Commit...", + ResetCommit.boxed_clone(), + ) + .separator() + .action( + "Copy Commit Hash to Clipboard", + CopyCommitHash.boxed_clone(), + ) + .action_disabled_when( + copy_subject_disabled, + "Copy Commit Subject to Clipboard", + CopyCommitSubject.boxed_clone(), + ) + }, + )) + } + + fn run_git_operation( + &mut self, + operation: Task>, + error_message: &'static str, + window: &mut Window, + cx: &mut Context, + ) { + self.context_menu = None; + self.commit_context_menu_state = None; + + cx.spawn(async move |this, cx| { + operation.await?; + + this.update(cx, |this, cx| { + this.reload_graph(cx); + }) + .ok(); + + Ok(()) + }) + .detach_and_prompt_err(error_message, window, cx, |error, _, _| { + Some(error.to_string()) }); - self.set_context_menu(context_menu, position, index, window, cx); } fn set_context_menu( @@ -1947,6 +2543,7 @@ impl GitGraph { cx.focus_self(window); } this.context_menu.take(); + this.commit_context_menu_state = None; cx.notify(); }, ); @@ -2937,6 +3534,362 @@ impl GitGraph { } } +struct CreateBranchAtCommitModal { + graph: WeakEntity, + repository: Entity, + commit_sha: SharedString, + editor: Entity, +} + +impl CreateBranchAtCommitModal { + fn new( + graph: WeakEntity, + repository: Entity, + commit_sha: SharedString, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Enter branch name...", window, cx); + editor + }); + + Self { + graph, + repository, + commit_sha, + editor, + } + } + + fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } + + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let branch_name = self.editor.read(cx).text(cx).trim().replace(' ', "-"); + if branch_name.is_empty() { + return; + } + + let repository = self.repository.clone(); + let graph = self.graph.clone(); + let commit_sha = self.commit_sha.to_string(); + + cx.spawn(async move |_, cx| { + repository + .update(cx, |repository, _| { + repository.create_branch_at(commit_sha, branch_name) + }) + .await??; + + let _ = graph.update(cx, |graph, cx| { + graph.reload_graph(cx); + }); + + Ok(()) + }) + .detach_and_prompt_err("Failed to create branch", window, cx, |error, _, _| { + Some(error.to_string()) + }); + + cx.emit(DismissEvent); + } +} + +impl EventEmitter for CreateBranchAtCommitModal {} +impl ModalView for CreateBranchAtCommitModal {} +impl Focusable for CreateBranchAtCommitModal { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.editor.focus_handle(cx) + } +} + +impl Render for CreateBranchAtCommitModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("CreateBranchAtCommitModal") + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .elevation_2(cx) + .w(ui::rems(34.)) + .child( + h_flex() + .px_3() + .pt_2() + .pb_1() + .gap_1p5() + .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall)) + .child(Label::new(format!("Create Branch at {}", self.commit_sha))), + ) + .child(div().px_3().pb_3().w_full().child(self.editor.clone())) + } +} + +struct CherryPickModal { + graph: WeakEntity, + repository: Entity, + commit_sha: SharedString, + record_origin: bool, + no_commit: bool, + focus_handle: FocusHandle, +} + +impl CherryPickModal { + fn new( + graph: WeakEntity, + repository: Entity, + commit_sha: SharedString, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + Self { + graph, + repository, + commit_sha, + record_origin: false, + no_commit: false, + focus_handle: cx.focus_handle(), + } + } + + fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } + + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let repository = self.repository.clone(); + let graph = self.graph.clone(); + let sha = self.commit_sha.to_string(); + let record_origin = self.record_origin; + let no_commit = self.no_commit; + + cx.spawn(async move |_, cx| { + repository + .update(cx, |repository, _| { + repository.cherry_pick(sha, record_origin, no_commit) + }) + .await??; + + let _ = graph.update(cx, |graph, cx| { + graph.reload_graph(cx); + }); + + Ok(()) + }) + .detach_and_prompt_err( + "Failed to cherry-pick commit", + window, + cx, + |error, _, _| Some(error.to_string()), + ); + + cx.emit(DismissEvent); + } +} + +impl EventEmitter for CherryPickModal {} +impl ModalView for CherryPickModal {} +impl Focusable for CherryPickModal { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for CherryPickModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("CherryPickModal") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .elevation_2(cx) + .w(ui::rems(34.)) + .child( + h_flex() + .px_3() + .pt_2() + .pb_1() + .gap_1p5() + .child(Icon::new(IconName::GitCommit).size(IconSize::XSmall)) + .child(Label::new(format!("Cherry Pick {}", self.commit_sha))), + ) + .child( + v_flex() + .px_3() + .pb_2() + .gap_1() + .child( + Checkbox::new( + "cherry-pick-record-origin", + if self.record_origin { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .label("Record origin (-x)") + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.record_origin = !this.record_origin; + cx.notify(); + })), + ) + .child( + Checkbox::new( + "cherry-pick-no-commit", + if self.no_commit { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .label("No commit (--no-commit)") + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.no_commit = !this.no_commit; + cx.notify(); + })), + ), + ) + .child( + h_flex() + .px_3() + .pb_3() + .gap_2() + .justify_end() + .child( + Button::new("cherry-pick-cancel", "Cancel") + .style(ButtonStyle::Subtle) + .on_click(cx.listener(|this, _, window, cx| { + this.cancel(&Cancel, window, cx); + })), + ) + .child( + Button::new("cherry-pick-confirm", "Cherry Pick") + .style(ButtonStyle::Filled) + .on_click(cx.listener(|this, _, window, cx| { + this.confirm(&Confirm, window, cx); + })), + ), + ) + } +} + +struct AddTagModal { + graph: WeakEntity, + repository: Entity, + commit_sha: SharedString, + name_editor: Entity, + message_editor: Entity, +} + +impl AddTagModal { + fn new( + graph: WeakEntity, + repository: Entity, + commit_sha: SharedString, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let name_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Enter tag name...", window, cx); + editor + }); + let message_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Optional tag message...", window, cx); + editor + }); + + Self { + graph, + repository, + commit_sha, + name_editor, + message_editor, + } + } + + fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } + + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let tag_name = self.name_editor.read(cx).text(cx).trim().to_string(); + if tag_name.is_empty() { + return; + } + + let tag_message = self.message_editor.read(cx).text(cx).trim().to_string(); + let repository = self.repository.clone(); + let graph = self.graph.clone(); + let commit_sha = self.commit_sha.to_string(); + + cx.spawn(async move |_, cx| { + repository + .update(cx, |repository, _| { + repository.create_tag( + commit_sha, + tag_name, + (!tag_message.is_empty()).then_some(tag_message), + ) + }) + .await??; + + let _ = graph.update(cx, |graph, cx| { + graph.reload_graph(cx); + }); + + Ok(()) + }) + .detach_and_prompt_err("Failed to add tag", window, cx, |error, _, _| { + Some(error.to_string()) + }); + + cx.emit(DismissEvent); + } +} + +impl EventEmitter for AddTagModal {} +impl ModalView for AddTagModal {} +impl Focusable for AddTagModal { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.name_editor.focus_handle(cx) + } +} + +impl Render for AddTagModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("AddTagModal") + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .elevation_2(cx) + .w(ui::rems(34.)) + .child( + h_flex() + .px_3() + .pt_2() + .pb_1() + .gap_1p5() + .child(Icon::new(IconName::GitCommit).size(IconSize::XSmall)) + .child(Label::new(format!("Add Tag at {}", self.commit_sha))), + ) + .child( + v_flex() + .px_3() + .pb_3() + .w_full() + .gap_2() + .child(self.name_editor.clone()) + .child(self.message_editor.clone()), + ) + } +} + impl Render for GitGraph { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { // This happens when we changed branches, we should refresh our search as well @@ -3271,6 +4224,39 @@ impl Render for GitGraph { cx.emit(ItemEvent::Edit); cx.notify(); })) + .on_action(cx.listener(|this, _: &AddTag, window, cx| { + this.show_add_tag_modal(window, cx); + })) + .on_action(cx.listener(|this, _: &CreateBranchAtCommit, window, cx| { + this.show_create_branch_modal(window, cx); + })) + .on_action(cx.listener(|this, _: &CheckoutCommit, window, cx| { + this.checkout_context_menu_commit(window, cx); + })) + .on_action(cx.listener(|this, _: &CherryPickCommit, window, cx| { + this.cherry_pick_context_menu_commit(window, cx); + })) + .on_action(cx.listener(|this, _: &RevertCommit, window, cx| { + this.revert_context_menu_commit(window, cx); + })) + .on_action(cx.listener(|this, _: &DropCommit, window, cx| { + this.drop_context_menu_commit(window, cx); + })) + .on_action(cx.listener(|this, _: &MergeCommit, window, cx| { + this.merge_context_menu_commit(window, cx); + })) + .on_action(cx.listener(|this, _: &RebaseOntoCommit, window, cx| { + this.rebase_context_menu_commit(window, cx); + })) + .on_action(cx.listener(|this, _: &ResetCommit, window, cx| { + this.reset_context_menu_commit(window, cx); + })) + .on_action(cx.listener(|this, _: &CopyCommitHash, _window, cx| { + this.copy_context_menu_commit_hash(cx); + })) + .on_action(cx.listener(|this, _: &CopyCommitSubject, _window, cx| { + this.copy_context_menu_commit_subject(cx); + })) .child( v_flex() .size_full() diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 20facc32640bf9..eb78d96eae8fed 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -34,10 +34,10 @@ use git::{ parse_git_remote_url, repository::{ Branch, CommitData, CommitDetails, CommitDiff, CommitFile, CommitOptions, - CreateWorktreeTarget, DiffType, FetchOptions, GitCommitTemplate, GitRepository, - GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, LogSource, PushOptions, Remote, - RemoteCommandOutput, RepoPath, ResetMode, SearchCommitArgs, UpstreamTrackingStatus, - Worktree as GitWorktree, + CreateWorktreeTarget, DiffType, DropCommitSupport, FetchOptions, GitCommitTemplate, + GitRepository, GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, LogSource, + PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode, SearchCommitArgs, + UpstreamTrackingStatus, Worktree as GitWorktree, }, stash::{GitStash, StashEntry}, status::{ @@ -4943,6 +4943,9 @@ impl Repository { mode: match reset_mode { ResetMode::Soft => git_reset::ResetMode::Soft.into(), ResetMode::Mixed => git_reset::ResetMode::Mixed.into(), + ResetMode::Hard => { + bail!("Hard reset is not supported for collab repositories") + } }, }) .await?; @@ -4953,6 +4956,154 @@ impl Repository { }) } + pub fn create_tag( + &mut self, + sha: String, + name: String, + message: Option, + ) -> oneshot::Receiver> { + let this = self.this.clone(); + self.send_job(None, move |repo, mut cx| async move { + let result = match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.create_tag(sha, name, message).await + } + RepositoryState::Remote(_) => { + bail!("Git graph commit operations are not supported for collab repositories") + } + }; + if result.is_ok() { + this.update(&mut cx, |this, cx| { + this.initial_graph_data.clear(); + cx.notify(); + }) + .ok(); + } + result + }) + } + + pub fn create_branch_at(&mut self, sha: String, name: String) -> oneshot::Receiver> { + let this = self.this.clone(); + self.send_job(None, move |repo, mut cx| async move { + let result = match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.create_branch_at(sha, name).await + } + RepositoryState::Remote(_) => { + bail!("Git graph commit operations are not supported for collab repositories") + } + }; + if result.is_ok() { + this.update(&mut cx, |this, cx| { + this.initial_graph_data.clear(); + cx.notify(); + }) + .ok(); + } + result + }) + } + + pub fn checkout_commit(&mut self, sha: String) -> oneshot::Receiver> { + self.send_job(None, move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.checkout_commit(sha).await + } + RepositoryState::Remote(_) => { + bail!("Git graph commit operations are not supported for collab repositories") + } + } + }) + } + + pub fn cherry_pick( + &mut self, + sha: String, + record_origin: bool, + no_commit: bool, + ) -> oneshot::Receiver> { + self.send_job(None, move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.cherry_pick(sha, record_origin, no_commit).await + } + RepositoryState::Remote(_) => { + bail!("Git graph commit operations are not supported for collab repositories") + } + } + }) + } + + pub fn revert_commit(&mut self, sha: String) -> oneshot::Receiver> { + self.send_job(None, move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.revert_commit(sha).await + } + RepositoryState::Remote(_) => { + bail!("Git graph commit operations are not supported for collab repositories") + } + } + }) + } + + pub fn drop_commit_support( + &mut self, + sha: String, + ) -> oneshot::Receiver> { + self.send_job(None, move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.drop_commit_support(sha).await + } + RepositoryState::Remote(_) => { + bail!("Git graph commit operations are not supported for collab repositories") + } + } + }) + } + + pub fn drop_commit(&mut self, sha: String) -> oneshot::Receiver> { + self.send_job(None, move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.drop_commit(sha).await + } + RepositoryState::Remote(_) => { + bail!("Git graph commit operations are not supported for collab repositories") + } + } + }) + } + + pub fn merge_commit(&mut self, sha: String) -> oneshot::Receiver> { + self.send_job(None, move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.merge_commit(sha).await + } + RepositoryState::Remote(_) => { + bail!("Git graph commit operations are not supported for collab repositories") + } + } + }) + } + + pub fn rebase_onto(&mut self, sha: String) -> oneshot::Receiver> { + self.send_job(None, move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.rebase_onto(sha).await + } + RepositoryState::Remote(_) => { + bail!("Git graph commit operations are not supported for collab repositories") + } + } + }) + } + pub fn show(&mut self, commit: String) -> oneshot::Receiver> { let id = self.id; self.send_job(None, move |git_repo, _cx| async move { @@ -5275,6 +5426,10 @@ impl Repository { }) } + pub fn commit_data_state(&self, sha: Oid) -> Option<&CommitDataState> { + self.commit_data.get(&sha) + } + fn get_handler(&mut self, cx: &mut Context) -> &mut CommitDataHandler { if matches!(self.commit_data_handler, CommitDataHandlerState::Closed) { self.commit_data_handler = From 507cee6c9229952c3fe358a9d994da8b67507fad Mon Sep 17 00:00:00 2001 From: Trong Nguyen Date: Wed, 6 May 2026 09:17:12 +0700 Subject: [PATCH 02/11] feat(git_graph): add ref context menu operations --- Cargo.lock | 3 + crates/fs/src/fake_git_repo.rs | 30 +- crates/git/src/repository.rs | 158 +- crates/git_graph/Cargo.toml | 3 + crates/git_graph/src/git_graph.rs | 3912 ++++++++++++++++++++-------- crates/git_ui/src/branch_picker.rs | 2 +- crates/git_ui/src/git_panel.rs | 10 +- crates/project/src/git_store.rs | 162 +- crates/proto/proto/git.proto | 13 +- 9 files changed, 3173 insertions(+), 1120 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6ee81109be5b6f..fe07ceaa845600 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7328,11 +7328,13 @@ name = "git_graph" version = "0.1.0" dependencies = [ "anyhow", + "askpass", "async-channel 2.5.0", "collections", "db", "editor", "fs", + "futures 0.3.32", "git", "git_ui", "gpui", @@ -7352,6 +7354,7 @@ dependencies = [ "time", "ui", "workspace", + "zeroize", ] [[package]] diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 5939948a32804b..f93e568828b3d1 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -14,7 +14,7 @@ use git::{ AskPassDelegate, Branch, CommitData, CommitDataReader, CommitDetails, CommitOptions, CreateWorktreeTarget, DropCommitSupport, FetchOptions, GRAPH_CHUNK_SIZE, GitRepository, GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, LogSource, PushOptions, RefEdit, - Remote, RepoPath, ResetMode, SearchCommitArgs, Worktree, + Remote, RemoteCommandOutput, RepoPath, ResetMode, SearchCommitArgs, Worktree, }, stash::GitStash, status::{ @@ -908,7 +908,7 @@ impl GitRepository for FakeGitRepository { future::ready(Ok(())).boxed() } - fn revert_commit(&self, _sha: String) -> BoxFuture<'_, Result<()>> { + fn revert_commit(&self, _sha: String, _no_commit: bool) -> BoxFuture<'_, Result<()>> { future::ready(Ok(())).boxed() } @@ -945,7 +945,12 @@ impl GitRepository for FakeGitRepository { }) } - fn delete_branch(&self, _is_remote: bool, name: String) -> BoxFuture<'_, Result<()>> { + fn delete_branch( + &self, + _is_remote: bool, + name: String, + _force_delete: bool, + ) -> BoxFuture<'_, Result<()>> { self.with_state_async(true, move |state| { if !state.branches.remove(&name) { bail!("no such branch: {name}"); @@ -954,6 +959,25 @@ impl GitRepository for FakeGitRepository { }) } + fn delete_tag(&self, _name: String) -> BoxFuture<'_, Result<()>> { + future::ready(Ok(())).boxed() + } + + fn push_tag( + &self, + _name: String, + _remote_name: String, + _ask_pass: AskPassDelegate, + _env: Arc>, + _cx: AsyncApp, + ) -> BoxFuture<'_, Result> { + future::ready(Ok(RemoteCommandOutput { + stdout: String::new(), + stderr: String::new(), + })) + .boxed() + } + fn blame( &self, path: RepoPath, diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 7116a7ffa3917e..2a72670dca2c91 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -795,14 +795,28 @@ pub trait GitRepository: Send + Sync { record_origin: bool, no_commit: bool, ) -> BoxFuture<'_, Result<()>>; - fn revert_commit(&self, sha: String) -> BoxFuture<'_, Result<()>>; + fn revert_commit(&self, sha: String, no_commit: bool) -> BoxFuture<'_, Result<()>>; fn drop_commit_support(&self, sha: String) -> BoxFuture<'_, Result>; fn drop_commit(&self, sha: String) -> BoxFuture<'_, Result<()>>; fn merge_commit(&self, sha: String) -> BoxFuture<'_, Result<()>>; fn rebase_onto(&self, sha: String) -> BoxFuture<'_, Result<()>>; fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>>; - fn delete_branch(&self, is_remote: bool, name: String) -> BoxFuture<'_, Result<()>>; + fn delete_branch( + &self, + is_remote: bool, + name: String, + force_delete: bool, + ) -> BoxFuture<'_, Result<()>>; + fn delete_tag(&self, name: String) -> BoxFuture<'_, Result<()>>; + fn push_tag( + &self, + name: String, + remote_name: String, + ask_pass: AskPassDelegate, + env: Arc>, + cx: AsyncApp, + ) -> BoxFuture<'_, Result>; fn worktrees(&self) -> BoxFuture<'_, Result>>; @@ -1041,11 +1055,36 @@ pub enum DiffType { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)] -pub enum PushOptions { - SetUpstream, +pub enum PushMode { + Normal, + ForceWithLease, Force, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)] +pub struct PushOptions { + pub set_upstream: bool, + pub push_mode: PushMode, +} + +impl PushOptions { + pub fn command_args(self) -> Vec<&'static str> { + let mut args = Vec::new(); + + if self.set_upstream { + args.push("--set-upstream"); + } + + match self.push_mode { + PushMode::Normal => {} + PushMode::ForceWithLease => args.push("--force-with-lease"), + PushMode::Force => args.push("--force"), + } + + args + } +} + impl std::fmt::Debug for dyn GitRepository { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("dyn GitRepository<...>").finish() @@ -2180,8 +2219,13 @@ impl GitRepository for RealGitRepository { .boxed() } - fn revert_commit(&self, sha: String) -> BoxFuture<'_, Result<()>> { - self.simple_git_command(vec!["revert".into(), "--no-edit".into(), sha]) + fn revert_commit(&self, sha: String, no_commit: bool) -> BoxFuture<'_, Result<()>> { + let mut args = vec!["revert".into(), "--no-edit".into()]; + if no_commit { + args.push("--no-commit".into()); + } + args.push(sha); + self.simple_git_command(args) } fn drop_commit_support(&self, sha: String) -> BoxFuture<'_, Result> { @@ -2303,19 +2347,72 @@ impl GitRepository for RealGitRepository { .boxed() } - fn delete_branch(&self, is_remote: bool, name: String) -> BoxFuture<'_, Result<()>> { + fn delete_branch( + &self, + is_remote: bool, + name: String, + force_delete: bool, + ) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary_in_worktree(); self.executor .spawn(async move { - git_binary? - .run(&["branch", if is_remote { "-dr" } else { "-d" }, &name]) - .await?; + let flag = match (is_remote, force_delete) { + (true, true) => "-Dr", + (true, false) => "-dr", + (false, true) => "-D", + (false, false) => "-d", + }; + git_binary?.run(&["branch", flag, &name]).await?; anyhow::Ok(()) }) .boxed() } + fn delete_tag(&self, name: String) -> BoxFuture<'_, Result<()>> { + self.simple_git_command(vec!["tag".into(), "-d".into(), name]) + } + + fn push_tag( + &self, + name: String, + remote_name: String, + ask_pass: AskPassDelegate, + env: Arc>, + cx: AsyncApp, + ) -> BoxFuture<'_, Result> { + let working_directory = self.working_directory(); + let git_directory = self.path(); + let executor = cx.background_executor().clone(); + let git_binary_path = self.system_git_binary_path.clone(); + let is_trusted = self.is_trusted(); + + async move { + let git_binary_path = + git_binary_path.context("git not found on $PATH, can't push tag")?; + let working_directory = working_directory?; + let git = GitBinary::new( + git_binary_path, + working_directory, + git_directory, + executor.clone(), + is_trusted, + ); + + let mut command = git.build_command(&["push"]); + command + .envs(env.iter()) + .arg(remote_name) + .arg(format!("refs/tags/{name}")) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + run_git_command(env, ask_pass, command, executor).await + } + .boxed() + } + fn blame( &self, path: RepoPath, @@ -2639,10 +2736,7 @@ impl GitRepository for RealGitRepository { let mut command = git.build_command(&["push"]); command .envs(env.iter()) - .args(options.map(|option| match option { - PushOptions::SetUpstream => "--set-upstream", - PushOptions::Force => "--force-with-lease", - })) + .args(options.into_iter().flat_map(PushOptions::command_args)) .arg(remote_name) .arg(format!("{}:{}", branch_name, remote_branch_name)) .stdin(Stdio::null()) @@ -3930,6 +4024,42 @@ mod tests { } } + #[test] + fn test_push_options_command_args() { + assert_eq!( + PushOptions { + set_upstream: false, + push_mode: PushMode::Normal, + } + .command_args(), + Vec::<&'static str>::new() + ); + assert_eq!( + PushOptions { + set_upstream: true, + push_mode: PushMode::Normal, + } + .command_args(), + vec!["--set-upstream"] + ); + assert_eq!( + PushOptions { + set_upstream: false, + push_mode: PushMode::ForceWithLease, + } + .command_args(), + vec!["--force-with-lease"] + ); + assert_eq!( + PushOptions { + set_upstream: true, + push_mode: PushMode::Force, + } + .command_args(), + vec!["--set-upstream", "--force"] + ); + } + #[gpui::test] async fn test_build_command_untrusted_includes_both_safety_args(cx: &mut TestAppContext) { cx.executor().allow_parking(); diff --git a/crates/git_graph/Cargo.toml b/crates/git_graph/Cargo.toml index 7a8f78b46e7023..01adeff0d4dc14 100644 --- a/crates/git_graph/Cargo.toml +++ b/crates/git_graph/Cargo.toml @@ -21,10 +21,12 @@ test-support = [ [dependencies] anyhow.workspace = true +askpass.workspace = true async-channel.workspace = true collections.workspace = true db.workspace = true editor.workspace = true +futures.workspace = true git.workspace = true git_ui.workspace = true gpui.workspace = true @@ -40,6 +42,7 @@ theme_settings.workspace = true time.workspace = true ui.workspace = true workspace.workspace = true +zeroize.workspace = true [dev-dependencies] db = { workspace = true, features = ["test-support"] } diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index 2c06d67c3ef095..d318bb9588586c 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -1,11 +1,14 @@ +use askpass::EncryptedPassword; use collections::{BTreeMap, HashMap, IndexSet}; use editor::Editor; +use futures::channel::oneshot; use git::{ BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote, parse_git_remote_url, repository::{ - CommitDiff, CommitFile, DropCommitSupport, InitialGraphCommitData, LogOrder, LogSource, - RepoPath, ResetMode, SearchCommitArgs, + AskPassDelegate, Branch, CommitDiff, CommitFile, DropCommitSupport, InitialGraphCommitData, + LogOrder, LogSource, PushMode, PushOptions, Remote, RepoPath, ResetMode, SearchCommitArgs, + UpstreamTracking, }, status::{FileStatus, StatusCode, TrackedStatus}, }; @@ -46,8 +49,8 @@ use theme::AccentColors; use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem}; use ui::{ Button, ButtonLike, ButtonStyle, Checkbox, Chip, ColumnWidthConfig, CommonAnimationExt as _, - ContextMenu, DiffStat, Divider, HeaderResizeInfo, HighlightedLabel, - RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState, + ContextMenu, DiffStat, Divider, DropdownMenu, DropdownStyle, HeaderResizeInfo, + HighlightedLabel, RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState, TableRenderContext, TableResizeBehavior, ToggleState, Tooltip, WithScrollbar, bind_redistributable_columns, prelude::*, render_redistributable_columns_resize_handles, render_table_header, table_row::TableRow, @@ -57,6 +60,7 @@ use workspace::{ item::{Item, ItemEvent, TabTooltipContent}, notifications::DetachAndPromptErr, }; +use zeroize::Zeroize; const COMMIT_CIRCLE_RADIUS: Pixels = px(3.5); const COMMIT_CIRCLE_STROKE_WIDTH: Pixels = px(1.5); @@ -243,6 +247,81 @@ struct SelectedCommitInfo { subject: Option, } +#[derive(Clone, Debug)] +enum RefNameKind { + Branch(SharedString), + Tag(SharedString), + Stash(SharedString), +} + +impl RefNameKind { + fn classify(ref_name: &SharedString) -> Self { + let name = ref_name.as_ref(); + if name == "refs/stash" + || name == "stash" + || name.starts_with("stash@{") + || name.contains("refs/stash") + { + Self::Stash(ref_name.clone()) + } else if name.starts_with("tag: ") || name.starts_with("refs/tags/") { + Self::Tag(ref_name.clone()) + } else { + Self::Branch(ref_name.clone()) + } + } + + fn display_name(&self) -> SharedString { + match self { + Self::Branch(name) => { + let name = name.as_ref(); + name.strip_prefix("HEAD -> ") + .unwrap_or(name) + .to_string() + .into() + } + Self::Tag(name) => { + let name = name.as_ref(); + name.strip_prefix("tag: ") + .or_else(|| name.strip_prefix("refs/tags/")) + .unwrap_or(name) + .to_string() + .into() + } + Self::Stash(name) => name.clone(), + } + } + + fn branch_lookup_name(&self) -> Option { + match self { + Self::Branch(name) => { + let name = name.as_ref(); + Some( + name.strip_prefix("HEAD -> ") + .unwrap_or(name) + .to_string() + .into(), + ) + } + _ => None, + } + } + + fn stash_index(&self) -> Option { + match self { + Self::Stash(name) => { + let name = name.as_ref(); + if let Some(start) = name.find("stash@{") { + let rest = &name[start + 7..]; + rest.strip_suffix('}')?.parse::().ok() + } else { + Some(0) + } + } + _ => None, + } + } +} + #[derive(Clone)] struct CommitContextMenuState { row_index: usize, @@ -276,6 +355,111 @@ impl ResetPromptMode { } } +#[derive(Clone, Debug, PartialEq, Eq)] +struct BranchPushTarget { + branch: Branch, + remote: Remote, + remote_branch_name: SharedString, + options: Option, +} + +#[derive(Clone, Debug)] +struct PushBranchDialogState { + branch: Branch, + available_remotes: Vec, + selected_remote: SharedString, + set_upstream: bool, + push_mode: PushMode, +} + +impl PushBranchDialogState { + fn new(branch: Branch, available_remotes: Vec) -> anyhow::Result { + let selected_remote = Self::default_remote_name(&branch, &available_remotes)?; + let set_upstream = Self::default_set_upstream(&branch, selected_remote.as_ref()); + + Ok(Self { + branch, + available_remotes, + selected_remote, + set_upstream, + push_mode: PushMode::Normal, + }) + } + + fn default_remote_name( + branch: &Branch, + available_remotes: &[SharedString], + ) -> anyhow::Result { + if let Some(remote_name) = Self::tracked_upstream_remote_name(branch) + && let Some(remote) = available_remotes + .iter() + .find(|remote| remote.as_ref() == remote_name) + { + return Ok(remote.clone()); + } + + available_remotes + .first() + .cloned() + .ok_or_else(|| anyhow::anyhow!("No remote configured for repository")) + } + + fn tracked_upstream_remote_name(branch: &Branch) -> Option<&str> { + branch + .upstream + .as_ref() + .filter(|upstream| matches!(upstream.tracking, UpstreamTracking::Tracked(_))) + .and_then(|upstream| upstream.remote_name()) + } + + fn tracked_upstream_branch_name(branch: &Branch) -> Option<&str> { + branch + .upstream + .as_ref() + .filter(|upstream| matches!(upstream.tracking, UpstreamTracking::Tracked(_))) + .and_then(|upstream| upstream.branch_name()) + } + + fn default_set_upstream(branch: &Branch, selected_remote: &str) -> bool { + Self::tracked_upstream_remote_name(branch) != Some(selected_remote) + } + + fn select_remote(&mut self, remote_name: SharedString) { + self.selected_remote = remote_name; + self.set_upstream = Self::default_set_upstream(&self.branch, self.selected_remote.as_ref()); + } + + fn push_target(&self) -> BranchPushTarget { + let remote_branch_name = if Self::tracked_upstream_remote_name(&self.branch) + == Some(self.selected_remote.as_ref()) + { + Self::tracked_upstream_branch_name(&self.branch) + .unwrap_or_else(|| self.branch.name()) + .to_string() + .into() + } else { + self.branch.name().to_string().into() + }; + + let options = match (self.set_upstream, self.push_mode) { + (false, PushMode::Normal) => None, + (set_upstream, push_mode) => Some(PushOptions { + set_upstream, + push_mode, + }), + }; + + BranchPushTarget { + branch: self.branch.clone(), + remote: Remote { + name: self.selected_remote.clone(), + }, + remote_branch_name, + options, + } + } +} + pub struct SplitState { left_ratio: f32, visible_left_ratio: f32, @@ -1506,6 +1690,41 @@ impl GitGraph { }) } + fn render_interactive_chip( + &self, + name: &SharedString, + accent_color: gpui::Hsla, + is_head: bool, + row_index: usize, + cx: &Context, + ) -> impl IntoElement { + let ref_kind = RefNameKind::classify(name); + let weak = cx.weak_entity(); + let chip_id = ElementId::Name(format!("ref-chip-{}-{}", row_index, name.as_ref()).into()); + + div() + .id(chip_id) + .child(self.render_chip(name, accent_color, is_head)) + .on_mouse_down( + MouseButton::Right, + move |event: &MouseDownEvent, window, cx| { + if let Some(entity) = weak.upgrade() { + let ref_kind = ref_kind.clone(); + entity.update(cx, |this, cx| { + this.deploy_ref_context_menu( + event.position, + row_index, + ref_kind, + window, + cx, + ); + }); + } + cx.stop_propagation(); + }, + ) + } + fn render_table_rows( &mut self, range: Range, @@ -1643,7 +1862,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_interactive_chip( + name, + accent_color, + is_head, + idx, + cx, + ) }, )) })) @@ -2077,6 +2302,8 @@ impl GitGraph { graph, repository, commit.sha.clone(), + None, + "Create Branch".into(), window, cx, ) @@ -2154,38 +2381,16 @@ impl GitGraph { return; }; - let confirm = self.prompt_confirmation( - PromptLevel::Warning, - format!("Revert commit {}?", commit.sha), - None, - "Revert", - window, - cx, - ); - - cx.spawn_in(window, async move |this, cx| { - if !confirm.await? { - return Ok(()); - } + let workspace = self.workspace.clone(); + let graph = cx.weak_entity(); - this.update_in(cx, |this, window, cx| { - let sha = commit.sha.to_string(); - let repository = repository.clone(); - let task = cx.spawn(async move |_, cx| { - repository - .update(cx, |repository, _| repository.revert_commit(sha)) - .await - .map_err(|_| anyhow::anyhow!("Operation was canceled"))??; - Ok(()) + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + RevertCommitModal::new(graph, repository, commit.sha.clone(), window, cx) }); - this.run_git_operation(task, "Failed to revert commit", window, cx); - })?; - - Ok(()) - }) - .detach_and_prompt_err("Failed to revert commit", window, cx, |error, _, _| { - Some(error.to_string()) - }); + }); + } } fn drop_context_menu_commit(&mut self, window: &mut Window, cx: &mut Context) { @@ -2445,7 +2650,6 @@ impl GitGraph { return None; } - let drop_disabled = !context_state.drop_support.can_drop; let copy_subject_disabled = selected_commit.subject.is_none(); let focus_handle = self.focus_handle.clone(); let git_graph = cx.entity(); @@ -2465,30 +2669,21 @@ impl GitGraph { }), ) .separator() - .action("Add Tag...", AddTag.boxed_clone()) + .action("Create Tag...", AddTag.boxed_clone()) .action("Create Branch...", CreateBranchAtCommit.boxed_clone()) .separator() - .action("Checkout...", CheckoutCommit.boxed_clone()) - .action("Cherry Pick...", CherryPickCommit.boxed_clone()) - .action("Revert...", RevertCommit.boxed_clone()) - .action_disabled_when(drop_disabled, "Drop...", DropCommit.boxed_clone()) - .action("Merge into current branch...", MergeCommit.boxed_clone()) - .action( - "Rebase current branch on this Commit...", - RebaseOntoCommit.boxed_clone(), - ) + .action("Checkout Commit...", CheckoutCommit.boxed_clone()) + .action("Cherry-Pick Commit...", CherryPickCommit.boxed_clone()) + .action("Revert Commit...", RevertCommit.boxed_clone()) .action( - "Reset current branch to this Commit...", + "Reset Current Branch to This Commit...", ResetCommit.boxed_clone(), ) .separator() - .action( - "Copy Commit Hash to Clipboard", - CopyCommitHash.boxed_clone(), - ) + .action("Copy Commit Hash", CopyCommitHash.boxed_clone()) .action_disabled_when( copy_subject_disabled, - "Copy Commit Subject to Clipboard", + "Copy Commit Subject", CopyCommitSubject.boxed_clone(), ) }, @@ -2556,431 +2751,1262 @@ impl GitGraph { cx.notify(); } - fn get_remote( - &self, - repository: &Repository, - _window: &mut Window, - cx: &mut App, - ) -> Option { - let remote_url = repository.default_remote_url()?; - let provider_registry = GitHostingProviderRegistry::default_global(cx); - let (provider, parsed) = parse_git_remote_url(provider_registry, &remote_url)?; - Some(GitRemote { - host: provider, - owner: parsed.owner.into(), - repo: parsed.repo.into(), - }) + fn deploy_ref_context_menu( + &mut self, + position: Point, + row_index: usize, + ref_kind: RefNameKind, + window: &mut Window, + cx: &mut Context, + ) { + self.select_entry(row_index, ScrollStrategy::Nearest, cx); + + match &ref_kind { + RefNameKind::Branch(_) => { + self.deploy_branch_context_menu(position, row_index, ref_kind, window, cx); + } + RefNameKind::Tag(_) => { + if let Some(context_menu) = self.build_tag_context_menu(&ref_kind, window, cx) { + self.set_context_menu(context_menu, position, row_index, window, cx); + } + } + RefNameKind::Stash(_) => { + if let Some(context_menu) = self.build_stash_context_menu(&ref_kind, window, cx) { + self.set_context_menu(context_menu, position, row_index, window, cx); + } + } + } } - fn render_search_bar(&self, cx: &mut Context) -> impl IntoElement { - let color = cx.theme().colors(); - let query_focus_handle = self - .search_state - .editor - .focus_handle(cx) - .tab_index(1) - .tab_stop(true); - let search_options = { - let mut options = SearchOptions::NONE; - options.set( - SearchOptions::CASE_SENSITIVE, - self.search_state.case_sensitive, - ); - options + fn deploy_branch_context_menu( + &mut self, + position: Point, + row_index: usize, + ref_kind: RefNameKind, + window: &mut Window, + cx: &mut Context, + ) { + let Some(repository) = self.get_repository(cx) else { + return; }; + let branch_task = self.resolve_branch(ref_kind, repository, cx); - h_flex() - .key_context("GitGraphSearchBar") - .tab_index(1) - .tab_group() - .tab_stop(false) - .w_full() - .p_1p5() - .gap_1p5() - .border_b_1() - .border_color(color.border_variant) - .child( - h_flex() - .h_8() - .flex_1() - .min_w_0() - .px_1p5() - .gap_1() - .track_focus(&query_focus_handle) - .border_1() - .border_color(color.border_variant) - .rounded_md() - .bg(color.toolbar_background) - .on_action(cx.listener(Self::confirm_search)) - .child(self.search_state.editor.clone()) - .child(SearchOption::CaseSensitive.as_button( - search_options, - SearchSource::Buffer, - query_focus_handle, - )), - ) - .child( - h_flex() - .min_w_64() - .gap_1() - .child({ - let focus_handle = self.focus_handle.clone(); - IconButton::new("git-graph-search-prev", IconName::ChevronLeft) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .tooltip(move |_, cx| { - Tooltip::for_action_in( - "Select Previous Match", - &SelectPreviousMatch, - &focus_handle, - cx, - ) - }) - .map(|this| { - if self.search_state.matches.is_empty() { - this.disabled(true) - } else { - this.disabled(false).on_click(cx.listener(|this, _, _, cx| { - this.select_previous_match(cx); - })) + cx.spawn_in(window, async move |this, cx| { + let branch = branch_task.await?; + this.update_in(cx, |this, window, cx| { + if let Some(context_menu) = this.build_branch_context_menu(branch, window, cx) { + this.set_context_menu(context_menu, position, row_index, window, cx); + } + })?; + Ok(()) + }) + .detach_and_prompt_err("Failed to open branch menu", window, cx, |error, _, _| { + Some(error.to_string()) + }); + } + + fn build_branch_context_menu( + &self, + branch: Branch, + window: &mut Window, + cx: &mut Context, + ) -> Option> { + let branch_name: SharedString = branch.name().to_string().into(); + let focus_handle = self.focus_handle.clone(); + let weak = cx.weak_entity(); + let is_remote = branch.is_remote(); + + Some(ContextMenu::build(window, cx, { + let branch_name_for_checkout = branch_name.clone(); + let branch_name_for_copy = branch_name.clone(); + let branch_name_for_rename = branch_name.clone(); + let branch_name_for_delete = branch_name.clone(); + let branch_name_for_push = branch_name; + move |context_menu, _, _| { + let context_menu = + context_menu + .context(focus_handle) + .entry("Checkout Branch", None, { + let branch_name = branch_name_for_checkout.clone(); + let weak = weak.clone(); + move |window, cx| { + if let Some(entity) = weak.upgrade() { + entity.update(cx, |this, cx| { + this.checkout_branch(branch_name.to_string(), window, cx); + }); } - }) + } + }); + + let context_menu = if is_remote { + context_menu + } else { + context_menu.entry("Rename Branch...", None, { + let branch_name = branch_name_for_rename.clone(); + let weak = weak.clone(); + move |window, cx| { + if let Some(entity) = weak.upgrade() { + entity.update(cx, |this, cx| { + this.rename_branch(branch_name.to_string(), window, cx); + }); + } + } }) - .child({ - let focus_handle = self.focus_handle.clone(); - IconButton::new("git-graph-search-next", IconName::ChevronRight) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .tooltip(move |_, cx| { - Tooltip::for_action_in( - "Select Next Match", - &SelectNextMatch, - &focus_handle, - cx, - ) - }) - .map(|this| { - if self.search_state.matches.is_empty() { - this.disabled(true) - } else { - this.disabled(false).on_click(cx.listener(|this, _, _, cx| { - this.select_next_match(cx); - })) - } - }) + }; + + let context_menu = context_menu.entry( + if is_remote { + "Delete Remote-Tracking Branch..." + } else { + "Delete Branch..." + }, + None, + { + let branch_name = branch_name_for_delete.clone(); + let weak = weak.clone(); + move |window, cx| { + if let Some(entity) = weak.upgrade() { + entity.update(cx, |this, cx| { + this.delete_branch( + branch_name.to_string(), + is_remote, + window, + cx, + ); + }); + } + } + }, + ); + + let context_menu = if is_remote { + context_menu + } else { + context_menu.entry("Push Branch...", None, { + let branch_name = branch_name_for_push; + let weak = weak.clone(); + move |window, cx| { + if let Some(entity) = weak.upgrade() { + entity.update(cx, |this, cx| { + this.push_branch(branch_name.to_string(), window, cx); + }); + } + } }) - .child( - h_flex() - .gap_1p5() - .child( - Label::new(format!( - "{}/{}", - self.search_state - .selected_index - .map(|index| index + 1) - .unwrap_or(0), - self.search_state.matches.len() - )) - .size(LabelSize::Small) - .when(self.search_state.matches.is_empty(), |this| { - this.color(Color::Disabled) - }), - ) - .when( - matches!( - &self.search_state.state, - QueryState::Confirmed((_, task)) if !task.is_ready() - ), - |this| { - this.child( - Icon::new(IconName::ArrowCircle) - .color(Color::Accent) - .size(IconSize::Small) - .with_rotate_animation(2) - .into_any_element(), - ) - }, - ), - ), - ) + }; + + context_menu + .separator() + .entry("Merge Branch into Current Branch...", None, { + let weak = weak.clone(); + move |window, cx| { + if let Some(entity) = weak.upgrade() { + entity.update(cx, |this, cx| { + this.merge_context_menu_commit(window, cx); + }); + } + } + }) + .entry("Rebase Current Branch onto Branch...", None, { + let weak = weak.clone(); + move |window, cx| { + if let Some(entity) = weak.upgrade() { + entity.update(cx, |this, cx| { + this.rebase_context_menu_commit(window, cx); + }); + } + } + }) + .separator() + .action("Copy Branch HEAD Hash", CopyCommitHash.boxed_clone()) + .entry("Copy Branch Name", None, { + let name = branch_name_for_copy; + move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(name.to_string())); + } + }) + } + })) } - fn render_loading_spinner(&self, cx: &App) -> AnyElement { - let rems = TextSize::Large.rems(cx); - Icon::new(IconName::LoadCircle) - .size(IconSize::Custom(rems)) - .color(Color::Accent) - .with_rotate_animation(3) - .into_any_element() + fn build_tag_context_menu( + &self, + ref_kind: &RefNameKind, + window: &mut Window, + cx: &mut Context, + ) -> Option> { + let tag_name = ref_kind.display_name(); + let focus_handle = self.focus_handle.clone(); + let weak = cx.weak_entity(); + + Some(ContextMenu::build(window, cx, { + let tag_name_for_delete = tag_name.clone(); + let tag_name_for_copy = tag_name.clone(); + let tag_name_for_push = tag_name; + move |context_menu, _, _| { + context_menu + .context(focus_handle) + .action("Checkout Tag...", CheckoutCommit.boxed_clone()) + .separator() + .entry("Delete Tag...", None, { + let tag_name = tag_name_for_delete.clone(); + let weak = weak.clone(); + move |window, cx| { + if let Some(entity) = weak.upgrade() { + entity.update(cx, |this, cx| { + this.delete_tag(tag_name.to_string(), window, cx); + }); + } + } + }) + .entry("Push Tag", None, { + let tag_name = tag_name_for_push; + let weak = weak.clone(); + move |window, cx| { + if let Some(entity) = weak.upgrade() { + entity.update(cx, |this, cx| { + this.push_tag(tag_name.to_string(), window, cx); + }); + } + } + }) + .entry("Create Branch from Tag...", None, { + let weak = weak.clone(); + move |window, cx| { + if let Some(entity) = weak.upgrade() { + entity.update(cx, |this, cx| { + this.show_create_branch_from_tag_modal(window, cx); + }); + } + } + }) + .separator() + .action("Copy Tagged Commit Hash", CopyCommitHash.boxed_clone()) + .entry("Copy Tag Name", None, { + let name = tag_name_for_copy; + move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(name.to_string())); + } + }) + } + })) } - fn render_commit_detail_panel( + fn build_stash_context_menu( &self, + ref_kind: &RefNameKind, window: &mut Window, cx: &mut Context, - ) -> impl IntoElement { - let Some(selected_idx) = self.selected_entry_idx else { - return Empty.into_any_element(); - }; + ) -> Option> { + let stash_name = ref_kind.display_name(); + let stash_index = ref_kind.stash_index(); + let focus_handle = self.focus_handle.clone(); + let weak = cx.weak_entity(); - let Some(commit_entry) = self.graph_data.commits.get(selected_idx) else { - return Empty.into_any_element(); - }; + Some(ContextMenu::build(window, cx, { + let stash_name_for_copy = stash_name.clone(); + let stash_name_for_branch = stash_name; + move |context_menu, _, _| { + context_menu + .context(focus_handle) + .entry("Apply Stash", None, { + let weak = weak.clone(); + move |window, cx| { + if let Some(entity) = weak.upgrade() { + entity.update(cx, |this, cx| { + this.apply_stash(stash_index, window, cx); + }); + } + } + }) + .entry("Pop Stash...", None, { + let weak = weak.clone(); + move |window, cx| { + if let Some(entity) = weak.upgrade() { + entity.update(cx, |this, cx| { + this.pop_stash(stash_index, window, cx); + }); + } + } + }) + .entry("Drop Stash...", None, { + let weak = weak.clone(); + move |window, cx| { + if let Some(entity) = weak.upgrade() { + entity.update(cx, |this, cx| { + this.drop_stash(stash_index, window, cx); + }); + } + } + }) + .separator() + .entry("Create Branch from Stash...", None, { + let weak = weak.clone(); + move |window, cx| { + if let Some(entity) = weak.upgrade() { + let stash_name = stash_name_for_branch.to_string(); + entity.update(cx, |this, cx| { + this.show_create_branch_from_stash_modal( + stash_name, window, cx, + ); + }); + } + } + }) + .separator() + .action("Copy Stash Commit Hash", CopyCommitHash.boxed_clone()) + .entry("Copy Stash Name", None, { + let name = stash_name_for_copy; + move |_window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(name.to_string())); + } + }) + } + })) + } + + fn askpass_delegate( + &self, + operation: impl Into, + window: &mut Window, + cx: &mut Context, + ) -> AskPassDelegate { + let workspace = self.workspace.clone(); + let operation = operation.into(); + let window = window.window_handle(); + AskPassDelegate::new(&mut cx.to_async(), move |prompt, tx, cx| { + window + .update(cx, |_, window, cx| { + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + GitGraphAskPassModal::new( + operation.clone(), + prompt.into(), + tx, + window, + cx, + ) + }); + }); + } + }) + .ok(); + }) + } + + fn resolve_branch( + &self, + ref_kind: RefNameKind, + repository: Entity, + cx: &mut Context, + ) -> Task> { + let branch_name = ref_kind + .branch_lookup_name() + .unwrap_or_else(|| ref_kind.display_name()); + let receiver = repository.update(cx, |repository, _| repository.branches()); + + cx.spawn(async move |_, _| { + let branches = receiver + .await + .map_err(|_| anyhow::anyhow!("Operation was canceled"))??; + branches + .into_iter() + .find(|branch| branch.name() == branch_name.as_ref()) + .ok_or_else(|| anyhow::anyhow!("Branch '{}' not found", branch_name)) + }) + } + fn checkout_branch( + &mut self, + branch_name: String, + window: &mut Window, + cx: &mut Context, + ) { let Some(repository) = self.get_repository(cx) else { - return Empty.into_any_element(); + return; }; - let data = repository.update(cx, |repository, cx| { + let task = cx.spawn(async move |_, cx| { repository - .fetch_commit_data(commit_entry.data.sha, false, cx) - .clone() + .update(cx, |repository, _| repository.change_branch(branch_name)) + .await + .map_err(|_| anyhow::anyhow!("Operation was canceled"))??; + Ok(()) }); + self.run_git_operation(task, "Failed to checkout branch", window, cx); + } - let full_sha: SharedString = commit_entry.data.sha.to_string().into(); - let ref_names = commit_entry.data.ref_names.clone(); - - let head_branch_name: Option = repository - .read(cx) - .snapshot() - .branch - .as_ref() - .map(|branch| SharedString::from(branch.name().to_string())); - - let accent_colors = cx.theme().accents(); - let accent_color = accent_colors - .0 - .get(commit_entry.color_idx) - .copied() - .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default()); - - // todo(git graph): We should use the full commit message here - let (author_name, author_email, commit_timestamp, commit_message) = match &data { - CommitDataState::Loaded(data) => ( - data.author_name.clone(), - data.author_email.clone(), - Some(data.commit_timestamp), - data.subject.clone(), - ), - CommitDataState::Loading(_) => ("Loading…".into(), "".into(), None, "Loading…".into()), + fn push_branch(&mut self, branch_name: String, window: &mut Window, cx: &mut Context) { + let Some(repository) = self.get_repository(cx) else { + return; }; + let branches_receiver = repository.update(cx, |repository, _| repository.branches()); + let remotes_receiver = repository.update(cx, |repository, _| { + repository.get_remotes(Some(branch_name.clone()), true) + }); - let date_string = commit_timestamp - .and_then(|ts| OffsetDateTime::from_unix_timestamp(ts).ok()) - .map(|datetime| { - let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC); - let local_datetime = datetime.to_offset(local_offset); - let format = - time::format_description::parse("[month repr:short] [day], [year]").ok(); - format - .and_then(|f| local_datetime.format(&f).ok()) - .unwrap_or_default() - }) - .unwrap_or_default(); + self.context_menu = None; + self.commit_context_menu_state = None; - let remote = repository.update(cx, |repo, cx| self.get_remote(repo, window, cx)); + cx.spawn_in(window, async move |this, cx| { + let branches = branches_receiver + .await + .map_err(|_| anyhow::anyhow!("Operation was canceled"))??; + let remotes = remotes_receiver + .await + .map_err(|_| anyhow::anyhow!("Operation was canceled"))??; + let branch = branches + .into_iter() + .find(|branch| branch.name() == branch_name.as_str()) + .ok_or_else(|| anyhow::anyhow!("Branch '{}' not found", branch_name))?; + if branch.is_remote() { + anyhow::bail!("Cannot push a remote-tracking branch"); + } - let avatar = { - let author_email_for_avatar = if author_email.is_empty() { - None - } else { - Some(author_email.clone()) - }; + let dialog_state = PushBranchDialogState::new( + branch, + remotes.into_iter().map(|remote| remote.name).collect(), + )?; - CommitAvatar::new(&full_sha, author_email_for_avatar, remote.as_ref()) - .size(px(40.)) - .render(window, cx) - }; + this.update_in(cx, |this, window, cx| { + let Some(workspace) = this.workspace.upgrade() else { + return; + }; + let graph = cx.weak_entity(); + workspace.update(cx, |workspace, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + PushBranchModal::new(graph, dialog_state.clone(), window, cx) + }); + }); + })?; - let changed_files_count = self - .selected_commit_diff + Ok(()) + }) + .detach_and_prompt_err("Failed to push branch", window, cx, |error, _, _| { + Some(error.to_string()) + }); + } + + fn perform_push_branch( + &mut self, + target: BranchPushTarget, + window: &mut Window, + cx: &mut Context, + ) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + let branch_name: SharedString = target.branch.name().to_string().into(); + let remote_branch_name = target.remote_branch_name.clone(); + let remote_name = target.remote.name.clone(); + let options = target.options; + let askpass = self.askpass_delegate(format!("git push {}", remote_name), window, cx); + + let task = cx.spawn(async move |_, cx| { + repository + .update(cx, |repository, cx| { + repository.push( + branch_name, + remote_branch_name, + remote_name, + options, + askpass, + cx, + ) + }) + .await + .map_err(|_| anyhow::anyhow!("Operation was canceled"))??; + Ok(()) + }); + self.run_git_operation(task, "Failed to push branch", window, cx); + } + + fn push_tag(&mut self, tag_name: String, window: &mut Window, cx: &mut Context) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + let Some(remote_name) = repository + .read(cx) + .remote_upstream_url .as_ref() - .map(|diff| diff.files.len()) - .unwrap_or(0); + .map(|_| SharedString::from("upstream")) + .or_else(|| { + repository + .read(cx) + .remote_origin_url + .as_ref() + .map(|_| SharedString::from("origin")) + }) + else { + let prompt = window.prompt( + PromptLevel::Warning, + "No remote configured for repository", + None, + &["Ok"], + cx, + ); + cx.spawn(async move |_, _| { + prompt.await.ok(); + anyhow::Ok(()) + }) + .detach(); + return; + }; - let (total_lines_added, total_lines_removed) = - self.selected_commit_diff_stats.unwrap_or((0, 0)); + let askpass = self.askpass_delegate(format!("git push {}", remote_name), window, cx); - let sorted_file_entries: Rc> = Rc::new( - self.selected_commit_diff - .as_ref() - .map(|diff| { - let mut files: Vec<_> = diff.files.iter().collect(); - files.sort_by_key(|file| file.status()); - files - .into_iter() - .map(|file| ChangedFileEntry::from_commit_file(file, cx)) - .collect() + let task = cx.spawn(async move |_, cx| { + repository + .update(cx, |repository, cx| { + repository.push_tag(tag_name.into(), remote_name, askpass, cx) }) - .unwrap_or_default(), + .await + .map_err(|_| anyhow::anyhow!("Operation was canceled"))??; + Ok(()) + }); + self.run_git_operation(task, "Failed to push tag", window, cx); + } + + fn delete_tag(&mut self, tag_name: String, window: &mut Window, cx: &mut Context) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + + let confirm = self.prompt_confirmation( + PromptLevel::Warning, + format!("Delete tag '{}'?", tag_name), + None, + "Delete", + window, + cx, ); - v_flex() - .min_w(px(300.)) - .h_full() - .bg(cx.theme().colors().editor_background) - .flex_basis(DefiniteLength::Fraction( - self.commit_details_split_state.read(cx).right_ratio(), - )) - .child( - v_flex() - .relative() - .w_full() - .p_2() - .gap_2() - .child( - div().absolute().top_2().right_2().child( - IconButton::new("close-detail", IconName::Close) - .icon_size(IconSize::Small) - .on_click(cx.listener(move |this, _, _, cx| { - this.selected_entry_idx = None; - this.selected_commit_diff = None; - this.selected_commit_diff_stats = None; - this._commit_diff_task = None; - cx.notify(); - })), - ), - ) - .child( - v_flex() - .py_1() - .w_full() - .items_center() - .gap_1() - .child(avatar) - .child( - v_flex() - .items_center() - .child(Label::new(author_name)) - .child( - Label::new(date_string) - .color(Color::Muted) - .size(LabelSize::Small), - ), - ), - ) - .children((!ref_names.is_empty()).then(|| { - 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) - }), - ) - })) - .child( - v_flex() - .ml_neg_1() - .gap_1p5() - .when(!author_email.is_empty(), |this| { - let copied_state: Entity = window.use_keyed_state( - "author-email-copy", - cx, - CopiedState::new, - ); - let is_copied = copied_state.read(cx).is_copied(); + cx.spawn_in(window, async move |this, cx| { + if !confirm.await? { + return Ok(()); + } - let (icon, icon_color, tooltip_label) = if is_copied { - (IconName::Check, Color::Success, "Email Copied!") - } else { - (IconName::Envelope, Color::Muted, "Copy Email") - }; + this.update_in(cx, |this, window, cx| { + let tag_name = tag_name.clone(); + let repository = repository.clone(); + let task = cx.spawn(async move |_, cx| { + repository + .update(cx, |repository, _| repository.delete_tag(tag_name)) + .await + .map_err(|_| anyhow::anyhow!("Operation was canceled"))??; + Ok(()) + }); + this.run_git_operation(task, "Failed to delete tag", window, cx); + })?; - let copy_email = author_email.clone(); - let author_email_for_tooltip = author_email.clone(); + Ok(()) + }) + .detach_and_prompt_err("Failed to delete tag", window, cx, |error, _, _| { + Some(error.to_string()) + }); + } - this.child( - Button::new("author-email-copy", author_email.clone()) - .start_icon( - Icon::new(icon).size(IconSize::Small).color(icon_color), - ) - .label_size(LabelSize::Small) - .truncate(true) - .color(Color::Muted) - .tooltip(move |_, cx| { - Tooltip::with_meta( - tooltip_label, - None, - author_email_for_tooltip.clone(), - cx, - ) - }) - .on_click(move |_, _, cx| { - copied_state.update(cx, |state, _cx| { - state.mark_copied(); - }); - cx.write_to_clipboard(ClipboardItem::new_string( - copy_email.to_string(), - )); - let state_id = copied_state.entity_id(); - cx.spawn(async move |cx| { - cx.background_executor() - .timer(COPIED_STATE_DURATION) - .await; - cx.update(|cx| { - cx.notify(state_id); - }) - }) - .detach(); - }), - ) - }) - .child({ - let copy_sha = full_sha.clone(); - let copied_state: Entity = - window.use_keyed_state("sha-copy", cx, CopiedState::new); - let is_copied = copied_state.read(cx).is_copied(); + fn delete_branch( + &mut self, + branch_name: String, + is_remote: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + let graph = cx.weak_entity(); - let (icon, icon_color, tooltip_label) = if is_copied { - (IconName::Check, Color::Success, "Commit SHA Copied!") - } else { - (IconName::Hash, Color::Muted, "Copy Commit SHA") - }; + workspace.update(cx, |workspace, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + DeleteBranchModal::new(graph, branch_name, is_remote, window, cx) + }); + }); + } - Button::new("sha-button", &full_sha) - .start_icon( - Icon::new(icon).size(IconSize::Small).color(icon_color), - ) - .label_size(LabelSize::Small) - .truncate(true) - .color(Color::Muted) - .tooltip({ - let full_sha = full_sha.clone(); - move |_, cx| { - Tooltip::with_meta( - tooltip_label, - None, - full_sha.clone(), - cx, - ) - } - }) - .on_click(move |_, _, cx| { - copied_state.update(cx, |state, _cx| { - state.mark_copied(); - }); - cx.write_to_clipboard(ClipboardItem::new_string( - copy_sha.to_string(), - )); - let state_id = copied_state.entity_id(); - cx.spawn(async move |cx| { - cx.background_executor() - .timer(COPIED_STATE_DURATION) - .await; - cx.update(|cx| { - cx.notify(state_id); - }) - }) - .detach(); - }) - }) - .when_some(remote.clone(), |this, remote| { - let provider_name = remote.host.name(); - let icon = match provider_name.as_str() { - "GitHub" => IconName::Github, - _ => IconName::Link, - }; - let parsed_remote = ParsedGitRemote { - owner: remote.owner.as_ref().into(), + fn perform_delete_branch( + &mut self, + branch_name: String, + is_remote: bool, + force_delete: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + + let task = cx.spawn(async move |_, cx| { + repository + .update(cx, |repository, _| { + repository.delete_branch(is_remote, branch_name, force_delete) + }) + .await + .map_err(|_| anyhow::anyhow!("Operation was canceled"))??; + Ok(()) + }); + self.run_git_operation(task, "Failed to delete branch", window, cx); + } + + fn rename_branch(&mut self, branch_name: String, window: &mut Window, cx: &mut Context) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + + let graph = cx.weak_entity(); + + workspace.update(cx, |workspace, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + RenameBranchModal::new(branch_name, repository, graph, window, cx) + }); + }); + } + + fn apply_stash( + &mut self, + stash_index: Option, + window: &mut Window, + cx: &mut Context, + ) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + + self.context_menu = None; + self.commit_context_menu_state = None; + + let task = repository.update(cx, |repository, cx| repository.stash_apply(stash_index, cx)); + + cx.spawn(async move |this, cx| { + task.await?; + + this.update(cx, |this, cx| { + this.reload_graph(cx); + }) + .ok(); + + Ok(()) + }) + .detach_and_prompt_err("Failed to apply stash", window, cx, |error, _, _| { + Some(error.to_string()) + }); + } + + fn pop_stash( + &mut self, + stash_index: Option, + window: &mut Window, + cx: &mut Context, + ) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + + let confirm = self.prompt_confirmation( + PromptLevel::Warning, + "Pop stash? This will apply and remove the stash entry.", + None, + "Pop", + window, + cx, + ); + + cx.spawn_in(window, async move |this, cx| { + if !confirm.await? { + return Ok(()); + } + + this.update_in(cx, |this, window, cx| { + this.context_menu = None; + this.commit_context_menu_state = None; + + let task = + repository.update(cx, |repository, cx| repository.stash_pop(stash_index, cx)); + + cx.spawn(async move |this, cx| { + task.await?; + + this.update(cx, |this, cx| { + this.reload_graph(cx); + }) + .ok(); + + Ok(()) + }) + .detach_and_prompt_err( + "Failed to pop stash", + window, + cx, + |error, _, _| Some(error.to_string()), + ); + })?; + + Ok(()) + }) + .detach_and_prompt_err("Failed to pop stash", window, cx, |error, _, _| { + Some(error.to_string()) + }); + } + + fn drop_stash( + &mut self, + stash_index: Option, + window: &mut Window, + cx: &mut Context, + ) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + + let confirm = self.prompt_confirmation( + PromptLevel::Warning, + "Drop stash? This will permanently remove the stash entry.", + None, + "Drop", + window, + cx, + ); + + cx.spawn_in(window, async move |this, cx| { + if !confirm.await? { + return Ok(()); + } + + this.update_in(cx, |this, window, cx| { + this.context_menu = None; + this.commit_context_menu_state = None; + + let receiver = + repository.update(cx, |repository, cx| repository.stash_drop(stash_index, cx)); + + cx.spawn(async move |this, cx| { + receiver + .await + .map_err(|_| anyhow::anyhow!("Operation was canceled"))??; + + this.update(cx, |this, cx| { + this.reload_graph(cx); + }) + .ok(); + + Ok::<(), anyhow::Error>(()) + }) + .detach_and_prompt_err( + "Failed to drop stash", + window, + cx, + |error, _, _| Some(error.to_string()), + ); + })?; + + Ok(()) + }) + .detach_and_prompt_err("Failed to drop stash", window, cx, |error, _, _| { + Some(error.to_string()) + }); + } + + fn show_create_branch_from_tag_modal(&mut self, window: &mut Window, cx: &mut Context) { + self.show_create_branch_modal_with_options( + None, + "Create Branch from Tag".into(), + window, + cx, + ); + } + + fn show_create_branch_from_stash_modal( + &mut self, + stash_name: String, + window: &mut Window, + cx: &mut Context, + ) { + let suggested_name = if let Some(index) = stash_name + .strip_prefix("stash@{") + .and_then(|rest| rest.strip_suffix('}')) + { + format!("stash-{}", index) + } else { + "stash-branch".to_string() + }; + + self.show_create_branch_modal_with_options( + Some(suggested_name), + "Create Branch from Stash".into(), + window, + cx, + ); + } + + fn show_create_branch_modal_with_options( + &mut self, + initial_name: Option, + title: SharedString, + window: &mut Window, + cx: &mut Context, + ) { + let Some(repository) = self.get_repository(cx) else { + return; + }; + let Some(commit) = self.context_menu_commit_info(cx) else { + return; + }; + let workspace = self.workspace.clone(); + let graph = cx.weak_entity(); + + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + CreateBranchAtCommitModal::new( + graph, + repository, + commit.sha.clone(), + initial_name, + title, + window, + cx, + ) + }); + }); + } + } + + fn get_remote( + &self, + repository: &Repository, + _window: &mut Window, + cx: &mut App, + ) -> Option { + let remote_url = repository.default_remote_url()?; + let provider_registry = GitHostingProviderRegistry::default_global(cx); + let (provider, parsed) = parse_git_remote_url(provider_registry, &remote_url)?; + Some(GitRemote { + host: provider, + owner: parsed.owner.into(), + repo: parsed.repo.into(), + }) + } + + fn render_search_bar(&self, cx: &mut Context) -> impl IntoElement { + let color = cx.theme().colors(); + let query_focus_handle = self + .search_state + .editor + .focus_handle(cx) + .tab_index(1) + .tab_stop(true); + let search_options = { + let mut options = SearchOptions::NONE; + options.set( + SearchOptions::CASE_SENSITIVE, + self.search_state.case_sensitive, + ); + options + }; + + h_flex() + .key_context("GitGraphSearchBar") + .tab_index(1) + .tab_group() + .tab_stop(false) + .w_full() + .p_1p5() + .gap_1p5() + .border_b_1() + .border_color(color.border_variant) + .child( + h_flex() + .h_8() + .flex_1() + .min_w_0() + .px_1p5() + .gap_1() + .track_focus(&query_focus_handle) + .border_1() + .border_color(color.border_variant) + .rounded_md() + .bg(color.toolbar_background) + .on_action(cx.listener(Self::confirm_search)) + .child(self.search_state.editor.clone()) + .child(SearchOption::CaseSensitive.as_button( + search_options, + SearchSource::Buffer, + query_focus_handle, + )), + ) + .child( + h_flex() + .min_w_64() + .gap_1() + .child({ + let focus_handle = self.focus_handle.clone(); + IconButton::new("git-graph-search-prev", IconName::ChevronLeft) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action_in( + "Select Previous Match", + &SelectPreviousMatch, + &focus_handle, + cx, + ) + }) + .map(|this| { + if self.search_state.matches.is_empty() { + this.disabled(true) + } else { + this.disabled(false).on_click(cx.listener(|this, _, _, cx| { + this.select_previous_match(cx); + })) + } + }) + }) + .child({ + let focus_handle = self.focus_handle.clone(); + IconButton::new("git-graph-search-next", IconName::ChevronRight) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::for_action_in( + "Select Next Match", + &SelectNextMatch, + &focus_handle, + cx, + ) + }) + .map(|this| { + if self.search_state.matches.is_empty() { + this.disabled(true) + } else { + this.disabled(false).on_click(cx.listener(|this, _, _, cx| { + this.select_next_match(cx); + })) + } + }) + }) + .child( + h_flex() + .gap_1p5() + .child( + Label::new(format!( + "{}/{}", + self.search_state + .selected_index + .map(|index| index + 1) + .unwrap_or(0), + self.search_state.matches.len() + )) + .size(LabelSize::Small) + .when(self.search_state.matches.is_empty(), |this| { + this.color(Color::Disabled) + }), + ) + .when( + matches!( + &self.search_state.state, + QueryState::Confirmed((_, task)) if !task.is_ready() + ), + |this| { + this.child( + Icon::new(IconName::ArrowCircle) + .color(Color::Accent) + .size(IconSize::Small) + .with_rotate_animation(2) + .into_any_element(), + ) + }, + ), + ), + ) + } + + fn render_loading_spinner(&self, cx: &App) -> AnyElement { + let rems = TextSize::Large.rems(cx); + Icon::new(IconName::LoadCircle) + .size(IconSize::Custom(rems)) + .color(Color::Accent) + .with_rotate_animation(3) + .into_any_element() + } + + fn render_commit_detail_panel( + &self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let Some(selected_idx) = self.selected_entry_idx else { + return Empty.into_any_element(); + }; + + let Some(commit_entry) = self.graph_data.commits.get(selected_idx) else { + return Empty.into_any_element(); + }; + + let Some(repository) = self.get_repository(cx) else { + return Empty.into_any_element(); + }; + + let data = repository.update(cx, |repository, cx| { + repository + .fetch_commit_data(commit_entry.data.sha, false, cx) + .clone() + }); + + let full_sha: SharedString = commit_entry.data.sha.to_string().into(); + let ref_names = commit_entry.data.ref_names.clone(); + + let head_branch_name: Option = repository + .read(cx) + .snapshot() + .branch + .as_ref() + .map(|branch| SharedString::from(branch.name().to_string())); + + let accent_colors = cx.theme().accents(); + let accent_color = accent_colors + .0 + .get(commit_entry.color_idx) + .copied() + .unwrap_or_else(|| accent_colors.0.first().copied().unwrap_or_default()); + + // todo(git graph): We should use the full commit message here + let (author_name, author_email, commit_timestamp, commit_message) = match &data { + CommitDataState::Loaded(data) => ( + data.author_name.clone(), + data.author_email.clone(), + Some(data.commit_timestamp), + data.subject.clone(), + ), + CommitDataState::Loading(_) => ("Loading…".into(), "".into(), None, "Loading…".into()), + }; + + let date_string = commit_timestamp + .and_then(|ts| OffsetDateTime::from_unix_timestamp(ts).ok()) + .map(|datetime| { + let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC); + let local_datetime = datetime.to_offset(local_offset); + let format = + time::format_description::parse("[month repr:short] [day], [year]").ok(); + format + .and_then(|f| local_datetime.format(&f).ok()) + .unwrap_or_default() + }) + .unwrap_or_default(); + + let remote = repository.update(cx, |repo, cx| self.get_remote(repo, window, cx)); + + let avatar = { + let author_email_for_avatar = if author_email.is_empty() { + None + } else { + Some(author_email.clone()) + }; + + CommitAvatar::new(&full_sha, author_email_for_avatar, remote.as_ref()) + .size(px(40.)) + .render(window, cx) + }; + + let changed_files_count = self + .selected_commit_diff + .as_ref() + .map(|diff| diff.files.len()) + .unwrap_or(0); + + let (total_lines_added, total_lines_removed) = + self.selected_commit_diff_stats.unwrap_or((0, 0)); + + let sorted_file_entries: Rc> = Rc::new( + self.selected_commit_diff + .as_ref() + .map(|diff| { + let mut files: Vec<_> = diff.files.iter().collect(); + files.sort_by_key(|file| file.status()); + files + .into_iter() + .map(|file| ChangedFileEntry::from_commit_file(file, cx)) + .collect() + }) + .unwrap_or_default(), + ); + + v_flex() + .min_w(px(300.)) + .h_full() + .bg(cx.theme().colors().editor_background) + .flex_basis(DefiniteLength::Fraction( + self.commit_details_split_state.read(cx).right_ratio(), + )) + .child( + v_flex() + .relative() + .w_full() + .p_2() + .gap_2() + .child( + div().absolute().top_2().right_2().child( + IconButton::new("close-detail", IconName::Close) + .icon_size(IconSize::Small) + .on_click(cx.listener(move |this, _, _, cx| { + this.selected_entry_idx = None; + this.selected_commit_diff = None; + this.selected_commit_diff_stats = None; + this._commit_diff_task = None; + cx.notify(); + })), + ), + ) + .child( + v_flex() + .py_1() + .w_full() + .items_center() + .gap_1() + .child(avatar) + .child( + v_flex() + .items_center() + .child(Label::new(author_name)) + .child( + Label::new(date_string) + .color(Color::Muted) + .size(LabelSize::Small), + ), + ), + ) + .children((!ref_names.is_empty()).then(|| { + 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_interactive_chip( + name, + accent_color, + is_head, + selected_idx, + cx, + ) + }), + ) + })) + .child( + v_flex() + .ml_neg_1() + .gap_1p5() + .when(!author_email.is_empty(), |this| { + let copied_state: Entity = window.use_keyed_state( + "author-email-copy", + cx, + CopiedState::new, + ); + let is_copied = copied_state.read(cx).is_copied(); + + let (icon, icon_color, tooltip_label) = if is_copied { + (IconName::Check, Color::Success, "Email Copied!") + } else { + (IconName::Envelope, Color::Muted, "Copy Email") + }; + + let copy_email = author_email.clone(); + let author_email_for_tooltip = author_email.clone(); + + this.child( + Button::new("author-email-copy", author_email.clone()) + .start_icon( + Icon::new(icon).size(IconSize::Small).color(icon_color), + ) + .label_size(LabelSize::Small) + .truncate(true) + .color(Color::Muted) + .tooltip(move |_, cx| { + Tooltip::with_meta( + tooltip_label, + None, + author_email_for_tooltip.clone(), + cx, + ) + }) + .on_click(move |_, _, cx| { + copied_state.update(cx, |state, _cx| { + state.mark_copied(); + }); + cx.write_to_clipboard(ClipboardItem::new_string( + copy_email.to_string(), + )); + let state_id = copied_state.entity_id(); + cx.spawn(async move |cx| { + cx.background_executor() + .timer(COPIED_STATE_DURATION) + .await; + cx.update(|cx| { + cx.notify(state_id); + }) + }) + .detach(); + }), + ) + }) + .child({ + let copy_sha = full_sha.clone(); + let copied_state: Entity = + window.use_keyed_state("sha-copy", cx, CopiedState::new); + let is_copied = copied_state.read(cx).is_copied(); + + let (icon, icon_color, tooltip_label) = if is_copied { + (IconName::Check, Color::Success, "Commit SHA Copied!") + } else { + (IconName::Hash, Color::Muted, "Copy Commit SHA") + }; + + Button::new("sha-button", &full_sha) + .start_icon( + Icon::new(icon).size(IconSize::Small).color(icon_color), + ) + .label_size(LabelSize::Small) + .truncate(true) + .color(Color::Muted) + .tooltip({ + let full_sha = full_sha.clone(); + move |_, cx| { + Tooltip::with_meta( + tooltip_label, + None, + full_sha.clone(), + cx, + ) + } + }) + .on_click(move |_, _, cx| { + copied_state.update(cx, |state, _cx| { + state.mark_copied(); + }); + cx.write_to_clipboard(ClipboardItem::new_string( + copy_sha.to_string(), + )); + let state_id = copied_state.entity_id(); + cx.spawn(async move |cx| { + cx.background_executor() + .timer(COPIED_STATE_DURATION) + .await; + cx.update(|cx| { + cx.notify(state_id); + }) + }) + .detach(); + }) + }) + .when_some(remote.clone(), |this, remote| { + let provider_name = remote.host.name(); + let icon = match provider_name.as_str() { + "GitHub" => IconName::Github, + _ => IconName::Link, + }; + let parsed_remote = ParsedGitRemote { + owner: remote.owner.as_ref().into(), repo: remote.repo.as_ref().into(), }; let params = BuildCommitPermalinkParams { @@ -2991,575 +4017,1263 @@ impl GitGraph { .build_commit_permalink(&parsed_remote, params) .to_string(); - this.child( - Button::new( - "view-on-provider", - format!("View on {}", provider_name), - ) - .start_icon( - Icon::new(icon).size(IconSize::Small).color(Color::Muted), - ) - .label_size(LabelSize::Small) - .truncate(true) - .color(Color::Muted) - .on_click( - move |_, _, cx| { - cx.open_url(&url); - }, - ), - ) - }), - ), - ) - .child(Divider::horizontal()) - .child(div().p_2().child(Label::new(commit_message))) - .child(Divider::horizontal()) - .child( - v_flex() - .min_w_0() - .p_2() - .flex_1() - .gap_1() - .child( - h_flex() - .gap_1() - .w_full() - .justify_between() - .child( - Label::new(format!( - "{} Changed {}", - changed_files_count, - if changed_files_count == 1 { - "File" + this.child( + Button::new( + "view-on-provider", + format!("View on {}", provider_name), + ) + .start_icon( + Icon::new(icon).size(IconSize::Small).color(Color::Muted), + ) + .label_size(LabelSize::Small) + .truncate(true) + .color(Color::Muted) + .on_click( + move |_, _, cx| { + cx.open_url(&url); + }, + ), + ) + }), + ), + ) + .child(Divider::horizontal()) + .child(div().p_2().child(Label::new(commit_message))) + .child(Divider::horizontal()) + .child( + v_flex() + .min_w_0() + .p_2() + .flex_1() + .gap_1() + .child( + h_flex() + .gap_1() + .w_full() + .justify_between() + .child( + Label::new(format!( + "{} Changed {}", + changed_files_count, + if changed_files_count == 1 { + "File" + } else { + "Files" + } + )) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(DiffStat::new( + "commit-diff-stat", + total_lines_added, + total_lines_removed, + )), + ) + .child( + div() + .id("changed-files-container") + .flex_1() + .min_h_0() + .child({ + let entries = sorted_file_entries; + let entry_count = entries.len(); + let commit_sha = full_sha.clone(); + let repository = repository.downgrade(); + let workspace = self.workspace.clone(); + uniform_list( + "changed-files-list", + entry_count, + move |range, _window, cx| { + range + .map(|ix| { + entries[ix].render( + ix, + commit_sha.clone(), + repository.clone(), + workspace.clone(), + cx, + ) + }) + .collect() + }, + ) + .size_full() + .ml_neg_1() + .track_scroll(&self.changed_files_scroll_handle) + }) + .vertical_scrollbar_for(&self.changed_files_scroll_handle, window, cx), + ), + ) + .child(Divider::horizontal()) + .child( + h_flex().p_1p5().w_full().child( + Button::new("view-commit", "View Commit") + .full_width() + .style(ButtonStyle::OutlinedGhost) + .on_click(cx.listener(|this, _, window, cx| { + this.open_selected_commit_view(window, cx); + })), + ), + ) + .into_any_element() + } + + fn render_graph_canvas(&self, window: &Window, cx: &mut Context) -> impl IntoElement { + let row_height = Self::row_height(window, cx); + let visible_row_count = self.visible_row_count(window, cx); + let table_state = self.table_interaction_state.read(cx); + let viewport_height = table_state + .scroll_handle + .0 + .borrow() + .last_item_size + .map(|size| size.item.height) + .unwrap_or(window.viewport_size().height); + let loaded_commit_count = self.graph_data.commits.len(); + + let content_height = row_height * loaded_commit_count; + let max_scroll = (content_height - viewport_height).max(px(0.)); + let scroll_offset_y = (-table_state.scroll_offset().y).clamp(px(0.), max_scroll); + + let first_visible_row = (scroll_offset_y / row_height).floor() as usize; + let vertical_scroll_offset = scroll_offset_y - (first_visible_row as f32 * row_height); + + let graph_viewport_width = self.graph_viewport_width(window, cx); + let graph_width = if self.graph_canvas_content_width() > graph_viewport_width { + self.graph_canvas_content_width() + } else { + graph_viewport_width + }; + let last_visible_row = first_visible_row + visible_row_count + 1; + + let viewport_range = first_visible_row.min(loaded_commit_count.saturating_sub(1)) + ..(last_visible_row).min(loaded_commit_count); + let rows = self.graph_data.commits[viewport_range.clone()].to_vec(); + let commit_lines: Vec<_> = self + .graph_data + .lines + .iter() + .filter(|line| { + line.full_interval.start <= viewport_range.end + && line.full_interval.end >= viewport_range.start + }) + .cloned() + .collect(); + + let mut lines: BTreeMap> = BTreeMap::new(); + + let hovered_entry_idx = self.hovered_entry_idx; + let selected_entry_idx = self.selected_entry_idx; + let context_menu_entry_idx = self.context_menu.as_ref().map(|menu| menu.entry_idx); + let is_focused = self.focus_handle.is_focused(window); + let graph_canvas_bounds = self.graph_canvas_bounds.clone(); + + gpui::canvas( + move |_bounds, _window, _cx| {}, + move |bounds: Bounds, _: (), window: &mut Window, cx: &mut App| { + graph_canvas_bounds.set(Some(bounds)); + + window.paint_layer(bounds, |window| { + let accent_colors = cx.theme().accents(); + + let hover_bg = cx.theme().colors().element_hover.opacity(0.6); + let selected_bg = if is_focused { + cx.theme().colors().element_selected + } else { + cx.theme().colors().element_hover + }; + + for visible_row_idx in 0..rows.len() { + let absolute_row_idx = first_visible_row + visible_row_idx; + let is_hovered = hovered_entry_idx == Some(absolute_row_idx); + let is_selected = selected_entry_idx == Some(absolute_row_idx); + let is_context_menu_target = + context_menu_entry_idx == Some(absolute_row_idx); + + if is_hovered || is_selected || is_context_menu_target { + let row_y = bounds.origin.y + visible_row_idx as f32 * row_height + - vertical_scroll_offset; + + let row_bounds = Bounds::new( + point(bounds.origin.x, row_y), + gpui::Size { + width: bounds.size.width, + height: row_height, + }, + ); + + let bg_color = if is_selected || is_context_menu_target { + selected_bg + } else { + hover_bg + }; + window.paint_quad(gpui::fill(row_bounds, bg_color)); + } + } + + for (row_idx, row) in rows.into_iter().enumerate() { + let row_color = accent_colors.color_for_index(row.color_idx as u32); + let row_y_center = + bounds.origin.y + row_idx as f32 * row_height + row_height / 2.0 + - vertical_scroll_offset; + + let commit_x = lane_center_x(bounds, row.lane as f32); + + draw_commit_circle(commit_x, row_y_center, row_color, window); + } + + for line in commit_lines { + let Some((start_segment_idx, start_column)) = + line.get_first_visible_segment_idx(first_visible_row) + else { + continue; + }; + + let line_x = lane_center_x(bounds, start_column as f32); + + let start_row = line.full_interval.start as i32 - first_visible_row as i32; + + let from_y = + bounds.origin.y + start_row as f32 * row_height + row_height / 2.0 + - vertical_scroll_offset + + COMMIT_CIRCLE_RADIUS; + + let mut current_row = from_y; + let mut current_column = line_x; + + let mut builder = PathBuilder::stroke(LINE_WIDTH); + builder.move_to(point(line_x, from_y)); + + let segments = &line.segments[start_segment_idx..]; + let desired_curve_height = row_height / 3.0; + let desired_curve_width = LANE_WIDTH / 3.0; + + for (segment_idx, segment) in segments.iter().enumerate() { + let is_last = segment_idx + 1 == segments.len(); + + match segment { + CommitLineSegment::Straight { to_row } => { + let mut dest_row = to_row_center( + to_row - first_visible_row, + row_height, + vertical_scroll_offset, + bounds, + ); + if is_last { + dest_row -= COMMIT_CIRCLE_RADIUS; + } + + let dest_point = point(current_column, dest_row); + + current_row = dest_point.y; + builder.line_to(dest_point); + builder.move_to(dest_point); + } + CommitLineSegment::Curve { + to_column, + on_row, + curve_kind, + } => { + let mut to_column = lane_center_x(bounds, *to_column as f32); + + let mut to_row = to_row_center( + *on_row - first_visible_row, + row_height, + vertical_scroll_offset, + bounds, + ); + + // This means that this branch was a checkout + let going_right = to_column > current_column; + let column_shift = if going_right { + COMMIT_CIRCLE_RADIUS + COMMIT_CIRCLE_STROKE_WIDTH } else { - "Files" + -COMMIT_CIRCLE_RADIUS - COMMIT_CIRCLE_STROKE_WIDTH + }; + + match curve_kind { + CurveKind::Checkout => { + if is_last { + to_column -= column_shift; + } + + let available_curve_width = + (to_column - current_column).abs(); + let available_curve_height = + (to_row - current_row).abs(); + let curve_width = + desired_curve_width.min(available_curve_width); + let curve_height = + desired_curve_height.min(available_curve_height); + let signed_curve_width = if going_right { + curve_width + } else { + -curve_width + }; + let curve_start = + point(current_column, to_row - curve_height); + let curve_end = + point(current_column + signed_curve_width, to_row); + let curve_control = point(current_column, to_row); + + builder.move_to(point(current_column, current_row)); + builder.line_to(curve_start); + builder.move_to(curve_start); + builder.curve_to(curve_end, curve_control); + builder.move_to(curve_end); + builder.line_to(point(to_column, to_row)); + } + CurveKind::Merge => { + if is_last { + to_row -= COMMIT_CIRCLE_RADIUS; + } + + let merge_start = point( + current_column + column_shift, + current_row - COMMIT_CIRCLE_RADIUS, + ); + let available_curve_width = + (to_column - merge_start.x).abs(); + let available_curve_height = + (to_row - merge_start.y).abs(); + let curve_width = + desired_curve_width.min(available_curve_width); + let curve_height = + desired_curve_height.min(available_curve_height); + let signed_curve_width = if going_right { + curve_width + } else { + -curve_width + }; + let curve_start = point( + to_column - signed_curve_width, + merge_start.y, + ); + let curve_end = + point(to_column, merge_start.y + curve_height); + let curve_control = point(to_column, merge_start.y); + + builder.move_to(merge_start); + builder.line_to(curve_start); + builder.move_to(curve_start); + builder.curve_to(curve_end, curve_control); + builder.move_to(curve_end); + builder.line_to(point(to_column, to_row)); + } } - )) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child(DiffStat::new( - "commit-diff-stat", - total_lines_added, - total_lines_removed, - )), - ) - .child( - div() - .id("changed-files-container") - .flex_1() - .min_h_0() - .child({ - let entries = sorted_file_entries; - let entry_count = entries.len(); - let commit_sha = full_sha.clone(); - let repository = repository.downgrade(); - let workspace = self.workspace.clone(); - uniform_list( - "changed-files-list", - entry_count, - move |range, _window, cx| { - range - .map(|ix| { - entries[ix].render( - ix, - commit_sha.clone(), - repository.clone(), - workspace.clone(), - cx, - ) - }) - .collect() - }, - ) - .size_full() - .ml_neg_1() - .track_scroll(&self.changed_files_scroll_handle) - }) - .vertical_scrollbar_for(&self.changed_files_scroll_handle, window, cx), - ), - ) - .child(Divider::horizontal()) - .child( - h_flex().p_1p5().w_full().child( - Button::new("view-commit", "View Commit") - .full_width() - .style(ButtonStyle::OutlinedGhost) - .on_click(cx.listener(|this, _, window, cx| { - this.open_selected_commit_view(window, cx); - })), - ), - ) - .into_any_element() + current_row = to_row; + current_column = to_column; + builder.move_to(point(current_column, current_row)); + } + } + } + + builder.close(); + lines.entry(line.color_idx).or_default().push(builder); + } + + for (color_idx, builders) in lines { + let line_color = accent_colors.color_for_index(color_idx as u32); + + for builder in builders { + if let Ok(path) = builder.build() { + // we paint each color on it's own layer to stop overlapping lines + // of different colors changing the color of a line + window.paint_layer(bounds, |window| { + window.paint_path(path, line_color); + }); + } + } + } + }) + }, + ) + .w(graph_width) + .h_full() } - fn render_graph_canvas(&self, window: &Window, cx: &mut Context) -> impl IntoElement { - let row_height = Self::row_height(window, cx); - let visible_row_count = self.visible_row_count(window, cx); + fn row_at_position( + &self, + position_y: Pixels, + window: &Window, + cx: &Context, + ) -> Option { + let canvas_bounds = self.graph_canvas_bounds.get()?; let table_state = self.table_interaction_state.read(cx); - let viewport_height = table_state - .scroll_handle - .0 - .borrow() - .last_item_size - .map(|size| size.item.height) - .unwrap_or(window.viewport_size().height); - let loaded_commit_count = self.graph_data.commits.len(); + let scroll_offset_y = -table_state.scroll_offset().y; - let content_height = row_height * loaded_commit_count; - let max_scroll = (content_height - viewport_height).max(px(0.)); - let scroll_offset_y = (-table_state.scroll_offset().y).clamp(px(0.), max_scroll); + let local_y = position_y - canvas_bounds.origin.y; - let first_visible_row = (scroll_offset_y / row_height).floor() as usize; - let vertical_scroll_offset = scroll_offset_y - (first_visible_row as f32 * row_height); + if local_y >= px(0.) && local_y < canvas_bounds.size.height { + let absolute_y = local_y + scroll_offset_y; + let row_height = Self::row_height(window, cx); + let absolute_row = (absolute_y / row_height).floor() as usize; - let graph_viewport_width = self.graph_viewport_width(window, cx); - let graph_width = if self.graph_canvas_content_width() > graph_viewport_width { - self.graph_canvas_content_width() - } else { - graph_viewport_width + if absolute_row < self.graph_data.commits.len() { + return Some(absolute_row); + } + } + + None + } + + fn handle_graph_mouse_move( + &mut self, + event: &gpui::MouseMoveEvent, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(row) = self.row_at_position(event.position.y, window, cx) { + if self.hovered_entry_idx != Some(row) { + self.hovered_entry_idx = Some(row); + cx.notify(); + } + } else if self.hovered_entry_idx.is_some() { + self.hovered_entry_idx = None; + cx.notify(); + } + } + + fn handle_entry_click( + &mut self, + entry_idx: usize, + event: &ClickEvent, + scroll_strategy: ScrollStrategy, + focus_handle: Option<&FocusHandle>, + window: &mut Window, + cx: &mut Context, + ) { + // Right-clicks open the context menu, not the details panel. + if event.is_right_click() { + return; + } + + if let Some(focus_handle) = focus_handle { + focus_handle.focus(window, cx); + } + + self.select_entry(entry_idx, scroll_strategy, cx); + + if event.click_count() >= 2 { + self.open_commit_view(entry_idx, window, cx); + } + } + + fn handle_graph_click( + &mut self, + event: &ClickEvent, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(row) = self.row_at_position(event.position().y, window, cx) { + self.handle_entry_click(row, event, ScrollStrategy::Nearest, None, window, cx); + } + } + + fn handle_entry_secondary_mouse_down( + &mut self, + entry_idx: usize, + event: &MouseDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + self.deploy_entry_context_menu(event.position, entry_idx, window, cx); + cx.stop_propagation(); + } + + fn handle_graph_secondary_mouse_down( + &mut self, + event: &MouseDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + let Some(row) = self.row_at_position(event.position.y, window, cx) else { + return; }; - let last_visible_row = first_visible_row + visible_row_count + 1; - let viewport_range = first_visible_row.min(loaded_commit_count.saturating_sub(1)) - ..(last_visible_row).min(loaded_commit_count); - let rows = self.graph_data.commits[viewport_range.clone()].to_vec(); - let commit_lines: Vec<_> = self - .graph_data - .lines - .iter() - .filter(|line| { - line.full_interval.start <= viewport_range.end - && line.full_interval.end >= viewport_range.start - }) - .cloned() - .collect(); + self.handle_entry_secondary_mouse_down(row, event, window, cx); + } - let mut lines: BTreeMap> = BTreeMap::new(); + fn handle_graph_scroll( + &mut self, + event: &ScrollWheelEvent, + window: &mut Window, + cx: &mut Context, + ) { + let line_height = window.line_height(); + let delta = event.delta.pixel_delta(line_height); - let hovered_entry_idx = self.hovered_entry_idx; - let selected_entry_idx = self.selected_entry_idx; - let context_menu_entry_idx = self.context_menu.as_ref().map(|menu| menu.entry_idx); - let is_focused = self.focus_handle.is_focused(window); - let graph_canvas_bounds = self.graph_canvas_bounds.clone(); + let table_state = self.table_interaction_state.read(cx); + let current_offset = table_state.scroll_offset(); - gpui::canvas( - move |_bounds, _window, _cx| {}, - move |bounds: Bounds, _: (), window: &mut Window, cx: &mut App| { - graph_canvas_bounds.set(Some(bounds)); + let viewport_height = table_state.scroll_handle.viewport().size.height; - window.paint_layer(bounds, |window| { - let accent_colors = cx.theme().accents(); + let commit_count = match self.graph_data.max_commit_count { + AllCommitCount::Loaded(count) => count, + AllCommitCount::NotLoaded => self.graph_data.commits.len(), + }; + let content_height = Self::row_height(window, cx) * commit_count; + let max_vertical_scroll = (viewport_height - content_height).min(px(0.)); - let hover_bg = cx.theme().colors().element_hover.opacity(0.6); - let selected_bg = if is_focused { - cx.theme().colors().element_selected - } else { - cx.theme().colors().element_hover - }; + let new_y = (current_offset.y + delta.y).clamp(max_vertical_scroll, px(0.)); + let new_offset = Point::new(current_offset.x, new_y); - for visible_row_idx in 0..rows.len() { - let absolute_row_idx = first_visible_row + visible_row_idx; - let is_hovered = hovered_entry_idx == Some(absolute_row_idx); - let is_selected = selected_entry_idx == Some(absolute_row_idx); - let is_context_menu_target = - context_menu_entry_idx == Some(absolute_row_idx); + if new_offset != current_offset { + table_state.set_scroll_offset(new_offset); + cx.notify(); + } + } + + fn render_commit_view_resize_handle( + &self, + _window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + div() + .id("commit-view-split-resize-container") + .relative() + .h_full() + .flex_shrink_0() + .w(px(1.)) + .bg(cx.theme().colors().border_variant) + .child( + div() + .id("commit-view-split-resize-handle") + .absolute() + .left(px(-RESIZE_HANDLE_WIDTH / 2.0)) + .w(px(RESIZE_HANDLE_WIDTH)) + .h_full() + .cursor_col_resize() + .block_mouse_except_scroll() + .on_click(cx.listener(|this, event: &ClickEvent, _window, cx| { + if event.click_count() >= 2 { + this.commit_details_split_state.update(cx, |state, _| { + state.on_double_click(); + }); + } + cx.stop_propagation(); + })) + .on_drag(DraggedSplitHandle, |_, _, _, cx| cx.new(|_| gpui::Empty)), + ) + .into_any_element() + } +} + +struct CreateBranchAtCommitModal { + graph: WeakEntity, + repository: Entity, + commit_sha: SharedString, + title: SharedString, + editor: Entity, + checkout_after_create: bool, +} + +impl CreateBranchAtCommitModal { + fn new( + graph: WeakEntity, + repository: Entity, + commit_sha: SharedString, + initial_name: Option, + title: SharedString, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + if let Some(initial_name) = initial_name.clone() { + editor.set_text(initial_name, window, cx); + } else { + editor.set_placeholder_text("Enter branch name...", window, cx); + } + editor + }); + + Self { + graph, + repository, + commit_sha, + title, + editor, + checkout_after_create: false, + } + } - if is_hovered || is_selected || is_context_menu_target { - let row_y = bounds.origin.y + visible_row_idx as f32 * row_height - - vertical_scroll_offset; + fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } - let row_bounds = Bounds::new( - point(bounds.origin.x, row_y), - gpui::Size { - width: bounds.size.width, - height: row_height, - }, - ); + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let branch_name = self.editor.read(cx).text(cx).trim().replace(' ', "-"); + if branch_name.is_empty() { + return; + } - let bg_color = if is_selected || is_context_menu_target { - selected_bg - } else { - hover_bg - }; - window.paint_quad(gpui::fill(row_bounds, bg_color)); - } - } + let repository = self.repository.clone(); + let graph = self.graph.clone(); + let commit_sha = self.commit_sha.to_string(); + let checkout_after_create = self.checkout_after_create; - for (row_idx, row) in rows.into_iter().enumerate() { - let row_color = accent_colors.color_for_index(row.color_idx as u32); - let row_y_center = - bounds.origin.y + row_idx as f32 * row_height + row_height / 2.0 - - vertical_scroll_offset; + cx.spawn(async move |_, cx| { + repository + .update(cx, |repository, _| { + repository.create_branch_at(commit_sha, branch_name.clone()) + }) + .await??; - let commit_x = lane_center_x(bounds, row.lane as f32); + if checkout_after_create { + repository + .update(cx, |repository, _| repository.change_branch(branch_name)) + .await??; + } - draw_commit_circle(commit_x, row_y_center, row_color, window); - } + let _ = graph.update(cx, |graph, cx| { + graph.reload_graph(cx); + }); - for line in commit_lines { - let Some((start_segment_idx, start_column)) = - line.get_first_visible_segment_idx(first_visible_row) - else { - continue; - }; + Ok(()) + }) + .detach_and_prompt_err("Failed to create branch", window, cx, |error, _, _| { + Some(error.to_string()) + }); - let line_x = lane_center_x(bounds, start_column as f32); + cx.emit(DismissEvent); + } +} - let start_row = line.full_interval.start as i32 - first_visible_row as i32; +impl EventEmitter for CreateBranchAtCommitModal {} +impl ModalView for CreateBranchAtCommitModal {} +impl Focusable for CreateBranchAtCommitModal { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.editor.focus_handle(cx) + } +} - let from_y = - bounds.origin.y + start_row as f32 * row_height + row_height / 2.0 - - vertical_scroll_offset - + COMMIT_CIRCLE_RADIUS; +impl Render for CreateBranchAtCommitModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("CreateBranchAtCommitModal") + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .elevation_2(cx) + .w(ui::rems(34.)) + .child( + h_flex() + .px_3() + .pt_2() + .pb_1() + .gap_1p5() + .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall)) + .child(Label::new(self.title.clone())), + ) + .child( + v_flex() + .px_3() + .pb_3() + .w_full() + .gap_2() + .child(self.editor.clone()) + .child( + Checkbox::new( + "create-branch-checkout-after-create", + if self.checkout_after_create { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .label("Checkout after create") + .label_size(LabelSize::Small) + .on_click(cx.listener( + |this: &mut CreateBranchAtCommitModal, _, _window, cx| { + this.checkout_after_create = !this.checkout_after_create; + cx.notify(); + }, + )), + ), + ) + } +} - let mut current_row = from_y; - let mut current_column = line_x; +struct CherryPickModal { + graph: WeakEntity, + repository: Entity, + commit_sha: SharedString, + record_origin: bool, + no_commit: bool, + focus_handle: FocusHandle, +} - let mut builder = PathBuilder::stroke(LINE_WIDTH); - builder.move_to(point(line_x, from_y)); +impl CherryPickModal { + fn new( + graph: WeakEntity, + repository: Entity, + commit_sha: SharedString, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + Self { + graph, + repository, + commit_sha, + record_origin: false, + no_commit: false, + focus_handle: cx.focus_handle(), + } + } - let segments = &line.segments[start_segment_idx..]; - let desired_curve_height = row_height / 3.0; - let desired_curve_width = LANE_WIDTH / 3.0; + fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } - for (segment_idx, segment) in segments.iter().enumerate() { - let is_last = segment_idx + 1 == segments.len(); + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let repository = self.repository.clone(); + let graph = self.graph.clone(); + let sha = self.commit_sha.to_string(); + let record_origin = self.record_origin; + let no_commit = self.no_commit; - match segment { - CommitLineSegment::Straight { to_row } => { - let mut dest_row = to_row_center( - to_row - first_visible_row, - row_height, - vertical_scroll_offset, - bounds, - ); - if is_last { - dest_row -= COMMIT_CIRCLE_RADIUS; - } + cx.spawn(async move |_, cx| { + repository + .update(cx, |repository, _| { + repository.cherry_pick(sha, record_origin, no_commit) + }) + .await??; - let dest_point = point(current_column, dest_row); + let _ = graph.update(cx, |graph, cx| { + graph.reload_graph(cx); + }); - current_row = dest_point.y; - builder.line_to(dest_point); - builder.move_to(dest_point); - } - CommitLineSegment::Curve { - to_column, - on_row, - curve_kind, - } => { - let mut to_column = lane_center_x(bounds, *to_column as f32); + Ok(()) + }) + .detach_and_prompt_err( + "Failed to cherry-pick commit", + window, + cx, + |error, _, _| Some(error.to_string()), + ); - let mut to_row = to_row_center( - *on_row - first_visible_row, - row_height, - vertical_scroll_offset, - bounds, - ); + cx.emit(DismissEvent); + } +} - // This means that this branch was a checkout - let going_right = to_column > current_column; - let column_shift = if going_right { - COMMIT_CIRCLE_RADIUS + COMMIT_CIRCLE_STROKE_WIDTH - } else { - -COMMIT_CIRCLE_RADIUS - COMMIT_CIRCLE_STROKE_WIDTH - }; +impl EventEmitter for CherryPickModal {} +impl ModalView for CherryPickModal {} +impl Focusable for CherryPickModal { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} - match curve_kind { - CurveKind::Checkout => { - if is_last { - to_column -= column_shift; - } +impl Render for CherryPickModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("CherryPickModal") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .elevation_2(cx) + .w(ui::rems(34.)) + .child( + h_flex() + .px_3() + .pt_2() + .pb_1() + .gap_1p5() + .child(Icon::new(IconName::GitCommit).size(IconSize::XSmall)) + .child(Label::new(format!("Cherry Pick {}", self.commit_sha))), + ) + .child( + v_flex() + .px_3() + .pb_2() + .gap_1() + .child( + Checkbox::new( + "cherry-pick-record-origin", + if self.record_origin { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .label("Record origin (-x)") + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.record_origin = !this.record_origin; + cx.notify(); + })), + ) + .child( + Checkbox::new( + "cherry-pick-no-commit", + if self.no_commit { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .label("No commit (--no-commit)") + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.no_commit = !this.no_commit; + cx.notify(); + })), + ), + ) + .child( + h_flex() + .px_3() + .pb_3() + .gap_2() + .justify_end() + .child( + Button::new("cherry-pick-cancel", "Cancel") + .style(ButtonStyle::Subtle) + .on_click(cx.listener(|this, _, window, cx| { + this.cancel(&Cancel, window, cx); + })), + ) + .child( + Button::new("cherry-pick-confirm", "Cherry Pick") + .style(ButtonStyle::Filled) + .on_click(cx.listener(|this, _, window, cx| { + this.confirm(&Confirm, window, cx); + })), + ), + ) + } +} - let available_curve_width = - (to_column - current_column).abs(); - let available_curve_height = - (to_row - current_row).abs(); - let curve_width = - desired_curve_width.min(available_curve_width); - let curve_height = - desired_curve_height.min(available_curve_height); - let signed_curve_width = if going_right { - curve_width - } else { - -curve_width - }; - let curve_start = - point(current_column, to_row - curve_height); - let curve_end = - point(current_column + signed_curve_width, to_row); - let curve_control = point(current_column, to_row); +struct AddTagModal { + graph: WeakEntity, + repository: Entity, + commit_sha: SharedString, + name_editor: Entity, + message_editor: Entity, +} - builder.move_to(point(current_column, current_row)); - builder.line_to(curve_start); - builder.move_to(curve_start); - builder.curve_to(curve_end, curve_control); - builder.move_to(curve_end); - builder.line_to(point(to_column, to_row)); - } - CurveKind::Merge => { - if is_last { - to_row -= COMMIT_CIRCLE_RADIUS; - } +impl AddTagModal { + fn new( + graph: WeakEntity, + repository: Entity, + commit_sha: SharedString, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let name_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Enter tag name...", window, cx); + editor + }); + let message_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Optional tag message...", window, cx); + editor + }); - let merge_start = point( - current_column + column_shift, - current_row - COMMIT_CIRCLE_RADIUS, - ); - let available_curve_width = - (to_column - merge_start.x).abs(); - let available_curve_height = - (to_row - merge_start.y).abs(); - let curve_width = - desired_curve_width.min(available_curve_width); - let curve_height = - desired_curve_height.min(available_curve_height); - let signed_curve_width = if going_right { - curve_width - } else { - -curve_width - }; - let curve_start = point( - to_column - signed_curve_width, - merge_start.y, - ); - let curve_end = - point(to_column, merge_start.y + curve_height); - let curve_control = point(to_column, merge_start.y); + Self { + graph, + repository, + commit_sha, + name_editor, + message_editor, + } + } - builder.move_to(merge_start); - builder.line_to(curve_start); - builder.move_to(curve_start); - builder.curve_to(curve_end, curve_control); - builder.move_to(curve_end); - builder.line_to(point(to_column, to_row)); - } - } - current_row = to_row; - current_column = to_column; - builder.move_to(point(current_column, current_row)); - } - } - } + fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } - builder.close(); - lines.entry(line.color_idx).or_default().push(builder); - } + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let tag_name = self.name_editor.read(cx).text(cx).trim().to_string(); + if tag_name.is_empty() { + return; + } - for (color_idx, builders) in lines { - let line_color = accent_colors.color_for_index(color_idx as u32); + let tag_message = self.message_editor.read(cx).text(cx).trim().to_string(); + let repository = self.repository.clone(); + let graph = self.graph.clone(); + let commit_sha = self.commit_sha.to_string(); - for builder in builders { - if let Ok(path) = builder.build() { - // we paint each color on it's own layer to stop overlapping lines - // of different colors changing the color of a line - window.paint_layer(bounds, |window| { - window.paint_path(path, line_color); - }); - } - } - } + cx.spawn(async move |_, cx| { + repository + .update(cx, |repository, _| { + repository.create_tag( + commit_sha, + tag_name, + (!tag_message.is_empty()).then_some(tag_message), + ) }) - }, - ) - .w(graph_width) - .h_full() - } + .await??; - fn row_at_position( - &self, - position_y: Pixels, - window: &Window, - cx: &Context, - ) -> Option { - let canvas_bounds = self.graph_canvas_bounds.get()?; - let table_state = self.table_interaction_state.read(cx); - let scroll_offset_y = -table_state.scroll_offset().y; + let _ = graph.update(cx, |graph, cx| { + graph.reload_graph(cx); + }); - let local_y = position_y - canvas_bounds.origin.y; + Ok(()) + }) + .detach_and_prompt_err("Failed to add tag", window, cx, |error, _, _| { + Some(error.to_string()) + }); - if local_y >= px(0.) && local_y < canvas_bounds.size.height { - let absolute_y = local_y + scroll_offset_y; - let row_height = Self::row_height(window, cx); - let absolute_row = (absolute_y / row_height).floor() as usize; + cx.emit(DismissEvent); + } +} - if absolute_row < self.graph_data.commits.len() { - return Some(absolute_row); - } - } +impl EventEmitter for AddTagModal {} +impl ModalView for AddTagModal {} +impl Focusable for AddTagModal { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.name_editor.focus_handle(cx) + } +} - None +impl Render for AddTagModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("AddTagModal") + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .elevation_2(cx) + .w(ui::rems(34.)) + .child( + h_flex() + .px_3() + .pt_2() + .pb_1() + .gap_1p5() + .child(Icon::new(IconName::GitCommit).size(IconSize::XSmall)) + .child(Label::new(format!("Add Tag at {}", self.commit_sha))), + ) + .child( + v_flex() + .px_3() + .pb_3() + .w_full() + .gap_2() + .child(self.name_editor.clone()) + .child(self.message_editor.clone()), + ) } +} - fn handle_graph_mouse_move( - &mut self, - event: &gpui::MouseMoveEvent, +struct RenameBranchModal { + graph: WeakEntity, + repository: Entity, + branch_name: SharedString, + editor: Entity, +} + +impl RenameBranchModal { + fn new( + branch_name: String, + repository: Entity, + graph: WeakEntity, window: &mut Window, cx: &mut Context, - ) { - if let Some(row) = self.row_at_position(event.position.y, window, cx) { - if self.hovered_entry_idx != Some(row) { - self.hovered_entry_idx = Some(row); - cx.notify(); - } - } else if self.hovered_entry_idx.is_some() { - self.hovered_entry_idx = None; - cx.notify(); + ) -> Self { + let editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_text(branch_name.clone(), window, cx); + editor + }); + Self { + graph, + repository, + branch_name: branch_name.into(), + editor, } } - fn handle_entry_click( - &mut self, - entry_idx: usize, - event: &ClickEvent, - scroll_strategy: ScrollStrategy, - focus_handle: Option<&FocusHandle>, - window: &mut Window, - cx: &mut Context, - ) { - // Right-clicks open the context menu, not the details panel. - if event.is_right_click() { + fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } + + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let new_name = self.editor.read(cx).text(cx); + if new_name.is_empty() || new_name == self.branch_name.as_ref() { + cx.emit(DismissEvent); return; } - if let Some(focus_handle) = focus_handle { - focus_handle.focus(window, cx); - } + let repository = self.repository.clone(); + let graph = self.graph.clone(); + let old_name = self.branch_name.to_string(); - self.select_entry(entry_idx, scroll_strategy, cx); + cx.spawn(async move |_, cx| { + repository + .update(cx, |repository, _| { + repository.rename_branch(old_name.clone(), new_name.clone()) + }) + .await??; - if event.click_count() >= 2 { - self.open_commit_view(entry_idx, window, cx); - } - } + let _ = graph.update(cx, |graph, cx| { + graph.reload_graph(cx); + }); - fn handle_graph_click( - &mut self, - event: &ClickEvent, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(row) = self.row_at_position(event.position().y, window, cx) { - self.handle_entry_click(row, event, ScrollStrategy::Nearest, None, window, cx); - } - } + Ok(()) + }) + .detach_and_prompt_err("Failed to rename branch", window, cx, |error, _, _| { + Some(error.to_string()) + }); - fn handle_entry_secondary_mouse_down( - &mut self, - entry_idx: usize, - event: &MouseDownEvent, - window: &mut Window, - cx: &mut Context, - ) { - self.deploy_entry_context_menu(event.position, entry_idx, window, cx); - cx.stop_propagation(); + cx.emit(DismissEvent); } +} - fn handle_graph_secondary_mouse_down( - &mut self, - event: &MouseDownEvent, - window: &mut Window, - cx: &mut Context, - ) { - let Some(row) = self.row_at_position(event.position.y, window, cx) else { - return; - }; +impl EventEmitter for RenameBranchModal {} +impl ModalView for RenameBranchModal {} +impl Focusable for RenameBranchModal { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.editor.focus_handle(cx) + } +} - self.handle_entry_secondary_mouse_down(row, event, window, cx); +impl Render for RenameBranchModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("RenameBranchModal") + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .elevation_2(cx) + .w(ui::rems(34.)) + .child( + h_flex() + .px_3() + .pt_2() + .pb_1() + .gap_1p5() + .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall)) + .child(Label::new(format!("Rename Branch ({})", self.branch_name))), + ) + .child(div().px_3().pb_3().w_full().child(self.editor.clone())) } +} - fn handle_graph_scroll( - &mut self, - event: &ScrollWheelEvent, - window: &mut Window, +struct PushBranchModal { + graph: WeakEntity, + state: PushBranchDialogState, + focus_handle: FocusHandle, +} + +impl PushBranchModal { + fn new( + graph: WeakEntity, + state: PushBranchDialogState, + _window: &mut Window, cx: &mut Context, - ) { - let line_height = window.line_height(); - let delta = event.delta.pixel_delta(line_height); + ) -> Self { + Self { + graph, + state, + focus_handle: cx.focus_handle(), + } + } - let table_state = self.table_interaction_state.read(cx); - let current_offset = table_state.scroll_offset(); + fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } - let viewport_height = table_state.scroll_handle.viewport().size.height; + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let target = self.state.push_target(); + if let Some(graph) = self.graph.upgrade() { + graph.update(cx, |graph, cx| { + graph.perform_push_branch(target, window, cx); + }); + } - let commit_count = match self.graph_data.max_commit_count { - AllCommitCount::Loaded(count) => count, - AllCommitCount::NotLoaded => self.graph_data.commits.len(), - }; - let content_height = Self::row_height(window, cx) * commit_count; - let max_vertical_scroll = (viewport_height - content_height).min(px(0.)); + cx.emit(DismissEvent); + } - let new_y = (current_offset.y + delta.y).clamp(max_vertical_scroll, px(0.)); - let new_offset = Point::new(current_offset.x, new_y); + fn render_remote_dropdown(&self, window: &mut Window, cx: &mut Context) -> DropdownMenu { + let weak = cx.weak_entity(); + let remotes = self.state.available_remotes.clone(); + let menu = ContextMenu::build(window, cx, move |mut menu, _, _| { + for remote_name in remotes.clone() { + let weak = weak.clone(); + menu = menu.entry(remote_name.clone(), None, move |_window, cx| { + if let Some(entity) = weak.upgrade() { + entity.update(cx, |this, cx| { + this.state.select_remote(remote_name.clone()); + cx.notify(); + }); + } + }); + } + menu + }); - if new_offset != current_offset { - table_state.set_scroll_offset(new_offset); - cx.notify(); - } + DropdownMenu::new( + "push-branch-remote-dropdown", + self.state.selected_remote.clone(), + menu, + ) + .style(DropdownStyle::Outlined) + .full_width(true) } - fn render_commit_view_resize_handle( + fn render_push_mode_option( &self, - _window: &mut Window, + id: &'static str, + label: &'static str, + push_mode: PushMode, cx: &mut Context, - ) -> AnyElement { - div() - .id("commit-view-split-resize-container") - .relative() - .h_full() - .flex_shrink_0() - .w(px(1.)) - .bg(cx.theme().colors().border_variant) + ) -> impl IntoElement { + Checkbox::new( + id, + if self.state.push_mode == push_mode { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .label(label) + .label_size(LabelSize::Small) + .on_click( + cx.listener(move |this: &mut PushBranchModal, _, _window, cx| { + this.state.push_mode = push_mode; + cx.notify(); + }), + ) + } +} + +impl EventEmitter for PushBranchModal {} +impl ModalView for PushBranchModal {} +impl Focusable for PushBranchModal { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for PushBranchModal { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("PushBranchModal") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .elevation_2(cx) + .w(ui::rems(36.)) .child( - div() - .id("commit-view-split-resize-handle") - .absolute() - .left(px(-RESIZE_HANDLE_WIDTH / 2.0)) - .w(px(RESIZE_HANDLE_WIDTH)) - .h_full() - .cursor_col_resize() - .block_mouse_except_scroll() - .on_click(cx.listener(|this, event: &ClickEvent, _window, cx| { - if event.click_count() >= 2 { - this.commit_details_split_state.update(cx, |state, _| { - state.on_double_click(); - }); - } - cx.stop_propagation(); - })) - .on_drag(DraggedSplitHandle, |_, _, _, cx| cx.new(|_| gpui::Empty)), + h_flex() + .px_3() + .pt_2() + .pb_1() + .gap_1p5() + .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall)) + .child(Label::new(format!( + "Push Branch ({})", + self.state.branch.name() + ))), + ) + .child( + v_flex() + .px_3() + .pb_3() + .w_full() + .gap_3() + .child( + v_flex() + .gap_1() + .child(Label::new("Push to Remote(s):").size(LabelSize::Small)) + .child(self.render_remote_dropdown(window, cx)), + ) + .child( + Checkbox::new( + "push-branch-set-upstream", + if self.state.set_upstream { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .label("Set Upstream") + .label_size(LabelSize::Small) + .on_click(cx.listener( + |this: &mut PushBranchModal, _, _window, cx| { + this.state.set_upstream = !this.state.set_upstream; + cx.notify(); + }, + )), + ) + .child( + v_flex() + .gap_1() + .child(Label::new("Push Mode:").size(LabelSize::Small)) + .child( + v_flex() + .gap_1() + .child(self.render_push_mode_option( + "push-branch-mode-normal", + "Normal", + PushMode::Normal, + cx, + )) + .child(self.render_push_mode_option( + "push-branch-mode-force-with-lease", + "Force With Lease", + PushMode::ForceWithLease, + cx, + )) + .child(self.render_push_mode_option( + "push-branch-mode-force", + "Force", + PushMode::Force, + cx, + )), + ), + ) + .child( + h_flex() + .justify_end() + .gap_2() + .child( + Button::new("push-branch-cancel", "Cancel") + .style(ButtonStyle::Subtle) + .on_click(cx.listener( + |this: &mut PushBranchModal, _, window, cx| { + this.cancel(&Cancel, window, cx); + }, + )), + ) + .child( + Button::new("push-branch-confirm", "Push") + .style(ButtonStyle::Filled) + .on_click(cx.listener( + |this: &mut PushBranchModal, _, window, cx| { + this.confirm(&Confirm, window, cx); + }, + )), + ), + ), ) - .into_any_element() } } -struct CreateBranchAtCommitModal { +struct DeleteBranchModal { graph: WeakEntity, - repository: Entity, - commit_sha: SharedString, - editor: Entity, + branch_name: SharedString, + is_remote: bool, + force_delete: bool, + focus_handle: FocusHandle, } -impl CreateBranchAtCommitModal { +impl DeleteBranchModal { fn new( graph: WeakEntity, - repository: Entity, - commit_sha: SharedString, - window: &mut Window, + branch_name: String, + is_remote: bool, + _window: &mut Window, cx: &mut Context, ) -> Self { - let editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Enter branch name...", window, cx); - editor - }); - Self { graph, - repository, - commit_sha, - editor, + branch_name: branch_name.into(), + is_remote, + force_delete: false, + focus_handle: cx.focus_handle(), } } @@ -3568,48 +5282,32 @@ impl CreateBranchAtCommitModal { } fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { - let branch_name = self.editor.read(cx).text(cx).trim().replace(' ', "-"); - if branch_name.is_empty() { - return; - } - - let repository = self.repository.clone(); - let graph = self.graph.clone(); - let commit_sha = self.commit_sha.to_string(); - - cx.spawn(async move |_, cx| { - repository - .update(cx, |repository, _| { - repository.create_branch_at(commit_sha, branch_name) - }) - .await??; - - let _ = graph.update(cx, |graph, cx| { - graph.reload_graph(cx); + if let Some(graph) = self.graph.upgrade() { + let branch_name = self.branch_name.to_string(); + let is_remote = self.is_remote; + let force_delete = self.force_delete; + graph.update(cx, |graph, cx| { + graph.perform_delete_branch(branch_name, is_remote, force_delete, window, cx); }); - - Ok(()) - }) - .detach_and_prompt_err("Failed to create branch", window, cx, |error, _, _| { - Some(error.to_string()) - }); + } cx.emit(DismissEvent); } } -impl EventEmitter for CreateBranchAtCommitModal {} -impl ModalView for CreateBranchAtCommitModal {} -impl Focusable for CreateBranchAtCommitModal { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.editor.focus_handle(cx) +impl EventEmitter for DeleteBranchModal {} +impl ModalView for DeleteBranchModal {} +impl Focusable for DeleteBranchModal { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() } } -impl Render for CreateBranchAtCommitModal { +impl Render for DeleteBranchModal { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() - .key_context("CreateBranchAtCommitModal") + .key_context("DeleteBranchModal") + .track_focus(&self.focus_handle) .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::confirm)) .elevation_2(cx) @@ -3620,23 +5318,76 @@ impl Render for CreateBranchAtCommitModal { .pt_2() .pb_1() .gap_1p5() - .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall)) - .child(Label::new(format!("Create Branch at {}", self.commit_sha))), + .child(Icon::new(IconName::Trash).size(IconSize::XSmall)) + .child(Label::new(if self.is_remote { + format!("Delete Remote-Tracking Branch ({})", self.branch_name) + } else { + format!("Delete Branch ({})", self.branch_name) + })), + ) + .child( + v_flex() + .px_3() + .pb_3() + .w_full() + .gap_2() + .child(Label::new("This cannot be undone.")) + .when(!self.is_remote, |this| { + this.child( + Checkbox::new( + "delete-branch-force-delete", + if self.force_delete { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .label("Force delete") + .label_size(LabelSize::Small) + .on_click(cx.listener( + |this: &mut DeleteBranchModal, _, _window, cx| { + this.force_delete = !this.force_delete; + cx.notify(); + }, + )), + ) + }) + .child( + h_flex() + .justify_end() + .gap_2() + .child( + Button::new("delete-branch-cancel", "Cancel") + .style(ButtonStyle::Subtle) + .on_click(cx.listener( + |this: &mut DeleteBranchModal, _, window, cx| { + this.cancel(&Cancel, window, cx); + }, + )), + ) + .child( + Button::new("delete-branch-confirm", "Delete") + .style(ButtonStyle::Filled) + .on_click(cx.listener( + |this: &mut DeleteBranchModal, _, window, cx| { + this.confirm(&Confirm, window, cx); + }, + )), + ), + ), ) - .child(div().px_3().pb_3().w_full().child(self.editor.clone())) } } -struct CherryPickModal { +struct RevertCommitModal { graph: WeakEntity, repository: Entity, commit_sha: SharedString, - record_origin: bool, no_commit: bool, focus_handle: FocusHandle, } -impl CherryPickModal { +impl RevertCommitModal { fn new( graph: WeakEntity, repository: Entity, @@ -3648,7 +5399,6 @@ impl CherryPickModal { graph, repository, commit_sha, - record_origin: false, no_commit: false, focus_handle: cx.focus_handle(), } @@ -3662,14 +5412,11 @@ impl CherryPickModal { let repository = self.repository.clone(); let graph = self.graph.clone(); let sha = self.commit_sha.to_string(); - let record_origin = self.record_origin; let no_commit = self.no_commit; cx.spawn(async move |_, cx| { repository - .update(cx, |repository, _| { - repository.cherry_pick(sha, record_origin, no_commit) - }) + .update(cx, |repository, _| repository.revert_commit(sha, no_commit)) .await??; let _ = graph.update(cx, |graph, cx| { @@ -3678,29 +5425,26 @@ impl CherryPickModal { Ok(()) }) - .detach_and_prompt_err( - "Failed to cherry-pick commit", - window, - cx, - |error, _, _| Some(error.to_string()), - ); + .detach_and_prompt_err("Failed to revert commit", window, cx, |error, _, _| { + Some(error.to_string()) + }); cx.emit(DismissEvent); } } -impl EventEmitter for CherryPickModal {} -impl ModalView for CherryPickModal {} -impl Focusable for CherryPickModal { +impl EventEmitter for RevertCommitModal {} +impl ModalView for RevertCommitModal {} +impl Focusable for RevertCommitModal { fn focus_handle(&self, _cx: &App) -> FocusHandle { self.focus_handle.clone() } } -impl Render for CherryPickModal { +impl Render for RevertCommitModal { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() - .key_context("CherryPickModal") + .key_context("RevertCommitModal") .track_focus(&self.focus_handle) .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::confirm)) @@ -3713,103 +5457,89 @@ impl Render for CherryPickModal { .pb_1() .gap_1p5() .child(Icon::new(IconName::GitCommit).size(IconSize::XSmall)) - .child(Label::new(format!("Cherry Pick {}", self.commit_sha))), + .child(Label::new(format!("Revert Commit {}", self.commit_sha))), ) .child( v_flex() .px_3() - .pb_2() - .gap_1() - .child( - Checkbox::new( - "cherry-pick-record-origin", - if self.record_origin { - ToggleState::Selected - } else { - ToggleState::Unselected - }, - ) - .label("Record origin (-x)") - .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, _window, cx| { - this.record_origin = !this.record_origin; - cx.notify(); - })), - ) + .pb_3() + .w_full() + .gap_2() .child( Checkbox::new( - "cherry-pick-no-commit", + "revert-commit-no-commit", if self.no_commit { ToggleState::Selected } else { ToggleState::Unselected }, ) - .label("No commit (--no-commit)") + .label("Do not commit (--no-commit)") .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, _window, cx| { - this.no_commit = !this.no_commit; - cx.notify(); - })), - ), - ) - .child( - h_flex() - .px_3() - .pb_3() - .gap_2() - .justify_end() - .child( - Button::new("cherry-pick-cancel", "Cancel") - .style(ButtonStyle::Subtle) - .on_click(cx.listener(|this, _, window, cx| { - this.cancel(&Cancel, window, cx); - })), + .on_click(cx.listener( + |this: &mut RevertCommitModal, _, _window, cx| { + this.no_commit = !this.no_commit; + cx.notify(); + }, + )), ) .child( - Button::new("cherry-pick-confirm", "Cherry Pick") - .style(ButtonStyle::Filled) - .on_click(cx.listener(|this, _, window, cx| { - this.confirm(&Confirm, window, cx); - })), + h_flex() + .justify_end() + .gap_2() + .child( + Button::new("revert-commit-cancel", "Cancel") + .style(ButtonStyle::Subtle) + .on_click(cx.listener( + |this: &mut RevertCommitModal, _, window, cx| { + this.cancel(&Cancel, window, cx); + }, + )), + ) + .child( + Button::new("revert-commit-confirm", "Revert") + .style(ButtonStyle::Filled) + .on_click(cx.listener( + |this: &mut RevertCommitModal, _, window, cx| { + this.confirm(&Confirm, window, cx); + }, + )), + ), ), ) } } -struct AddTagModal { - graph: WeakEntity, - repository: Entity, - commit_sha: SharedString, - name_editor: Entity, - message_editor: Entity, +struct GitGraphAskPassModal { + operation: SharedString, + prompt: SharedString, + editor: Entity, + tx: Option>, } -impl AddTagModal { +impl GitGraphAskPassModal { fn new( - graph: WeakEntity, - repository: Entity, - commit_sha: SharedString, + operation: SharedString, + prompt: SharedString, + tx: oneshot::Sender, window: &mut Window, cx: &mut Context, ) -> Self { - let name_editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Enter tag name...", window, cx); - editor - }); - let message_editor = cx.new(|cx| { + let editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Optional tag message...", window, cx); + if prompt.contains("yes/no") || prompt.contains("Username") { + editor.set_masked(false, cx); + } else { + editor.set_masked(true, cx); + } editor }); Self { - graph, - repository, - commit_sha, - name_editor, - message_editor, + operation, + prompt, + editor, + tx: Some(tx), } } @@ -3818,53 +5548,34 @@ impl AddTagModal { } fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { - let tag_name = self.name_editor.read(cx).text(cx).trim().to_string(); - if tag_name.is_empty() { - return; - } - - let tag_message = self.message_editor.read(cx).text(cx).trim().to_string(); - let repository = self.repository.clone(); - let graph = self.graph.clone(); - let commit_sha = self.commit_sha.to_string(); - - cx.spawn(async move |_, cx| { - repository - .update(cx, |repository, _| { - repository.create_tag( - commit_sha, - tag_name, - (!tag_message.is_empty()).then_some(tag_message), - ) - }) - .await??; - - let _ = graph.update(cx, |graph, cx| { - graph.reload_graph(cx); + if let Some(tx) = self.tx.take() { + let mut text = self.editor.update(cx, |editor, cx| { + let text = editor.text(cx); + editor.clear(window, cx); + text }); - - Ok(()) - }) - .detach_and_prompt_err("Failed to add tag", window, cx, |error, _, _| { - Some(error.to_string()) - }); + if let Ok(password) = EncryptedPassword::try_from(text.as_ref()) { + tx.send(password).ok(); + } + text.zeroize(); + } cx.emit(DismissEvent); } } -impl EventEmitter for AddTagModal {} -impl ModalView for AddTagModal {} -impl Focusable for AddTagModal { +impl EventEmitter for GitGraphAskPassModal {} +impl ModalView for GitGraphAskPassModal {} +impl Focusable for GitGraphAskPassModal { fn focus_handle(&self, cx: &App) -> FocusHandle { - self.name_editor.focus_handle(cx) + self.editor.focus_handle(cx) } } -impl Render for AddTagModal { +impl Render for GitGraphAskPassModal { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() - .key_context("AddTagModal") + .key_context("GitGraphAskPassModal") .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::confirm)) .elevation_2(cx) @@ -3875,8 +5586,8 @@ impl Render for AddTagModal { .pt_2() .pb_1() .gap_1p5() - .child(Icon::new(IconName::GitCommit).size(IconSize::XSmall)) - .child(Label::new(format!("Add Tag at {}", self.commit_sha))), + .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall)) + .child(Label::new(self.operation.clone())), ) .child( v_flex() @@ -3884,8 +5595,8 @@ impl Render for AddTagModal { .pb_3() .w_full() .gap_2() - .child(self.name_editor.clone()) - .child(self.message_editor.clone()), + .child(Label::new(self.prompt.clone())) + .child(self.editor.clone()), ) } } @@ -4730,6 +6441,61 @@ mod tests { }); } + fn local_branch(name: &str, upstream: Option<&str>) -> Branch { + Branch { + is_head: false, + ref_name: format!("refs/heads/{name}").into(), + upstream: upstream.map(|upstream| git::repository::Upstream { + ref_name: upstream.into(), + tracking: UpstreamTracking::Tracked(git::repository::UpstreamTrackingStatus { + ahead: 0, + behind: 0, + }), + }), + most_recent_commit: None, + } + } + + #[test] + fn push_branch_dialog_defaults_to_tracked_remote() { + let state = PushBranchDialogState::new( + local_branch("feature", Some("refs/remotes/upstream/feature")), + vec!["origin".into(), "upstream".into()], + ) + .expect("tracked remote should be available"); + + assert_eq!(state.selected_remote.as_ref(), "upstream"); + assert!(!state.set_upstream); + + let target = state.push_target(); + assert_eq!(target.remote.name.as_ref(), "upstream"); + assert_eq!(target.remote_branch_name.as_ref(), "feature"); + assert_eq!(target.options, None); + } + + #[test] + fn push_branch_dialog_uses_selected_remote_and_options() { + let mut state = PushBranchDialogState::new( + local_branch("feature", Some("refs/remotes/upstream/main")), + vec!["origin".into(), "upstream".into()], + ) + .expect("remote should be available"); + + state.select_remote("origin".into()); + state.push_mode = PushMode::ForceWithLease; + + let target = state.push_target(); + assert_eq!(target.remote.name.as_ref(), "origin"); + assert_eq!(target.remote_branch_name.as_ref(), "feature"); + assert_eq!( + target.options, + Some(PushOptions { + set_upstream: true, + push_mode: PushMode::ForceWithLease, + }) + ); + } + /// Generates a random commit DAG suitable for testing git graph rendering. /// /// The commits are ordered newest-first (like git log output), so: diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 69829231619175..f0769017d2bddf 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -529,7 +529,7 @@ impl BranchListDelegate { is_remote = branch.is_remote(); repo.update(cx, |repo, _| { - repo.delete_branch(is_remote, branch.name().to_string()) + repo.delete_branch(is_remote, branch.name().to_string(), false) }) .await? } diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index d4cf03c853848a..cbf274487c4b79 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -3060,14 +3060,20 @@ impl GitPanel { let branch = branch.clone(); let options = if force_push { - Some(PushOptions::Force) + Some(PushOptions { + set_upstream: false, + push_mode: git::repository::PushMode::ForceWithLease, + }) } else { match branch.upstream { Some(Upstream { tracking: UpstreamTracking::Gone, .. }) - | None => Some(PushOptions::SetUpstream), + | None => Some(PushOptions { + set_upstream: true, + push_mode: git::repository::PushMode::Normal, + }), _ => None, } }; diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index eb78d96eae8fed..ef33c58f041a0d 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -2222,14 +2222,25 @@ impl GitStore { &mut cx, ); - let options = envelope - .payload - .options - .as_ref() - .map(|_| match envelope.payload.options() { - proto::push::PushOptions::SetUpstream => git::repository::PushOptions::SetUpstream, - proto::push::PushOptions::Force => git::repository::PushOptions::Force, - }); + let options = + envelope + .payload + .options + .as_ref() + .map(|options| git::repository::PushOptions { + set_upstream: options.set_upstream, + push_mode: match options.push_mode() { + proto::push::push_options::PushMode::Normal => { + git::repository::PushMode::Normal + } + proto::push::push_options::PushMode::ForceWithLease => { + git::repository::PushMode::ForceWithLease + } + proto::push::push_options::PushMode::Force => { + git::repository::PushMode::Force + } + }, + }); let branch_name = envelope.payload.branch_name.into(); let remote_branch_name = envelope.payload.remote_branch_name.into(); @@ -2887,10 +2898,11 @@ impl GitStore { let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; let is_remote = envelope.payload.is_remote; let branch_name = envelope.payload.branch_name; + let force_delete = envelope.payload.force_delete; repository_handle .update(&mut cx, |repository_handle, _| { - repository_handle.delete_branch(is_remote, branch_name) + repository_handle.delete_branch(is_remote, branch_name, force_delete) }) .await??; @@ -5036,11 +5048,11 @@ impl Repository { }) } - pub fn revert_commit(&mut self, sha: String) -> oneshot::Receiver> { + pub fn revert_commit(&mut self, sha: String, no_commit: bool) -> oneshot::Receiver> { self.send_job(None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { - backend.revert_commit(sha).await + backend.revert_commit(sha, no_commit).await } RepositoryState::Remote(_) => { bail!("Git graph commit operations are not supported for collab repositories") @@ -6407,11 +6419,14 @@ impl Repository { let id = self.id; let args = options - .map(|option| match option { - PushOptions::SetUpstream => " --set-upstream", - PushOptions::Force => " --force-with-lease", + .map(|options| { + options + .command_args() + .into_iter() + .map(|arg| format!(" {arg}")) + .collect::() }) - .unwrap_or(""); + .unwrap_or_default(); let updates_tx = self .git_store() @@ -6475,13 +6490,20 @@ impl Repository { branch_name: branch.to_string(), remote_branch_name: remote_branch.to_string(), remote_name: remote.to_string(), - options: options.map(|options| match options { - PushOptions::Force => proto::push::PushOptions::Force, - PushOptions::SetUpstream => { - proto::push::PushOptions::SetUpstream - } - } - as i32), + options: options.map(|options| proto::push::PushOptions { + set_upstream: options.set_upstream, + push_mode: match options.push_mode { + git::repository::PushMode::Normal => { + proto::push::push_options::PushMode::Normal + } + git::repository::PushMode::ForceWithLease => { + proto::push::push_options::PushMode::ForceWithLease + } + git::repository::PushMode::Force => { + proto::push::push_options::PushMode::Force + } + } as i32, + }), }) .await?; @@ -6972,6 +6994,88 @@ impl Repository { self.edit_ref(ref_name, None) } + pub fn delete_tag(&mut self, name: String) -> oneshot::Receiver> { + let this = self.this.clone(); + self.send_job(None, move |repo, mut cx| async move { + let result = match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.delete_tag(name).await + } + RepositoryState::Remote(_) => { + anyhow::bail!( + "Git graph commit operations are not supported for collab repositories" + ) + } + }; + if result.is_ok() { + this.update(&mut cx, |this, cx| { + this.initial_graph_data.clear(); + cx.notify(); + }) + .ok(); + } + result + }) + } + + pub fn push_tag( + &mut self, + name: SharedString, + remote: SharedString, + askpass: AskPassDelegate, + _cx: &mut Context, + ) -> oneshot::Receiver> { + let askpass_delegates = self.askpass_delegates.clone(); + let askpass_id = util::post_inc(&mut self.latest_askpass_id); + let id = self.id; + + self.send_job( + Some(format!("git push {} refs/tags/{}", remote, name).into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { + backend, + environment, + .. + }) => { + backend + .push_tag( + name.to_string(), + remote.to_string(), + askpass, + environment, + _cx, + ) + .await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + askpass_delegates.lock().insert(askpass_id, askpass); + let _defer = util::defer(|| { + let askpass_delegate = askpass_delegates.lock().remove(&askpass_id); + debug_assert!(askpass_delegate.is_some()); + }); + + client + .request(proto::Push { + project_id: project_id.0, + repository_id: id.to_proto(), + askpass_id, + remote_name: remote.to_string(), + branch_name: format!("refs/tags/{name}"), + remote_branch_name: format!("refs/tags/{name}"), + options: None, + }) + .await + .map(|response| RemoteCommandOutput { + stdout: response.stdout, + stderr: response.stderr, + }) + } + } + }, + ) + } + pub fn repair_worktrees(&mut self) -> oneshot::Receiver> { let id = self.id; self.send_job(None, move |repo, _cx| async move { @@ -7330,13 +7434,19 @@ impl Repository { &mut self, is_remote: bool, branch_name: String, + force_delete: bool, ) -> oneshot::Receiver> { let id = self.id; self.send_job( Some( format!( "git branch {} {}", - if is_remote { "-dr" } else { "-d" }, + match (is_remote, force_delete) { + (true, true) => "-Dr", + (true, false) => "-dr", + (false, true) => "-D", + (false, false) => "-d", + }, branch_name ) .into(), @@ -7344,7 +7454,10 @@ impl Repository { move |repo, _cx| async move { match repo { RepositoryState::Local(state) => { - state.backend.delete_branch(is_remote, branch_name).await + state + .backend + .delete_branch(is_remote, branch_name, force_delete) + .await } RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { client @@ -7353,6 +7466,7 @@ impl Repository { repository_id: id.to_proto(), is_remote, branch_name, + force_delete, }) .await?; diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index afea6cf34a3eaa..448cbc49a9944f 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -215,6 +215,7 @@ message GitDeleteBranch { uint64 repository_id = 2; string branch_name = 3; bool is_remote = 4; + bool force_delete = 5; } message GitDiff { @@ -404,9 +405,15 @@ message Push { uint64 askpass_id = 7; string remote_branch_name = 8; - enum PushOptions { - SET_UPSTREAM = 0; - FORCE = 1; + message PushOptions { + bool set_upstream = 1; + PushMode push_mode = 2; + + enum PushMode { + NORMAL = 0; + FORCE_WITH_LEASE = 1; + FORCE = 2; + } } } From 026e3576656afa52ec89f68d993db1ac5a7b07c5 Mon Sep 17 00:00:00 2001 From: Trong Nguyen Date: Wed, 6 May 2026 09:41:32 +0700 Subject: [PATCH 03/11] perf(git_graph): speed up context menus --- crates/git_graph/src/git_graph.rs | 342 ++++++++++++++++++++---------- 1 file changed, 232 insertions(+), 110 deletions(-) diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index d318bb9588586c..9e1fb28bfae082 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -1,13 +1,13 @@ use askpass::EncryptedPassword; -use collections::{BTreeMap, HashMap, IndexSet}; +use collections::{BTreeMap, HashMap, HashSet, IndexSet}; use editor::Editor; use futures::channel::oneshot; use git::{ BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, Oid, ParsedGitRemote, parse_git_remote_url, repository::{ - AskPassDelegate, Branch, CommitDiff, CommitFile, DropCommitSupport, InitialGraphCommitData, - LogOrder, LogSource, PushMode, PushOptions, Remote, RepoPath, ResetMode, SearchCommitArgs, + AskPassDelegate, Branch, CommitDiff, CommitFile, InitialGraphCommitData, LogOrder, + LogSource, PushMode, PushOptions, Remote, RepoPath, ResetMode, SearchCommitArgs, UpstreamTracking, }, status::{FileStatus, StatusCode, TrackedStatus}, @@ -325,7 +325,6 @@ impl RefNameKind { #[derive(Clone)] struct CommitContextMenuState { row_index: usize, - drop_support: DropCommitSupport, } #[derive(Clone, Copy)] @@ -733,6 +732,7 @@ struct CommitEntry { type ActiveLaneIdx = usize; +#[derive(Debug, PartialEq, Eq)] enum AllCommitCount { NotLoaded, Loaded(usize), @@ -870,10 +870,19 @@ impl GraphData { } fn add_commits(&mut self, commits: &[Arc]) { + let existing_commits = self + .commits + .iter() + .map(|commit| commit.data.sha) + .collect::>(); self.commits.reserve(commits.len()); self.lines.reserve(commits.len() / 2); for commit in commits.iter() { + if existing_commits.contains(&commit.sha) { + continue; + } + let commit_row = self.commits.len(); let commit_lane = self @@ -977,7 +986,9 @@ impl GraphData { color_idx: commit_color.0 as usize, })); } + } + fn mark_fully_loaded(&mut self) { self.max_commit_count = AllCommitCount::Loaded(self.commits.len()); } } @@ -1504,6 +1515,7 @@ impl GitGraph { { match event { GitGraphEvent::FullyLoaded => { + self.graph_data.mark_fully_loaded(); if let Some(pending_sha_index) = self.pending_select_sha.take().and_then(|oid| { repository @@ -1514,6 +1526,7 @@ impl GitGraph { { self.select_entry(pending_sha_index, ScrollStrategy::Nearest, cx); } + cx.notify(); } GitGraphEvent::LoadingError => { // todo(git_graph): Wire this up with the UI @@ -2403,20 +2416,33 @@ impl GitGraph { let Some(context_state) = self.commit_context_menu_state.as_ref() else { return; }; - if context_state.row_index != commit.index || !context_state.drop_support.can_drop { + if context_state.row_index != commit.index { return; } - let confirm = self.prompt_confirmation( - PromptLevel::Warning, - format!("Drop commit {}?", commit.sha), - Some("This rewrites history on the current branch.".into()), - "Drop Commit", - window, - cx, - ); + let support_receiver = repository.update(cx, |repository, _| { + repository.drop_commit_support(commit.sha.to_string()) + }); cx.spawn_in(window, async move |this, cx| { + let drop_support = support_receiver + .await + .map_err(|_| anyhow::anyhow!("Operation was canceled"))??; + if !drop_support.can_drop { + return Ok(()); + } + + let confirm = this.update_in(cx, |this, window, cx| { + this.prompt_confirmation( + PromptLevel::Warning, + format!("Drop commit {}?", commit.sha), + Some("This rewrites history on the current branch.".into()), + "Drop Commit", + window, + cx, + ) + })?; + if !confirm.await? { return Ok(()); } @@ -2605,37 +2631,12 @@ impl GitGraph { window: &mut Window, cx: &mut Context, ) { - let Some(commit) = self.graph_data.commits.get(entry_idx) else { - return; - }; - let Some(repository) = self.get_repository(cx) else { - return; - }; - - let sha = commit.data.sha.to_string(); - let receiver = repository.update(cx, |repository, _| repository.drop_commit_support(sha)); - - cx.spawn_in(window, async move |this, cx| { - let drop_support = receiver - .await - .map_err(|_| anyhow::anyhow!("Operation was canceled")) - .and_then(|result| result) - .unwrap_or_else(|error| DropCommitSupport { - can_drop: false, - reason: Some(SharedString::from(error.to_string())), - }); - - let _ = this.update_in(cx, |this, window, cx| { - this.commit_context_menu_state = Some(CommitContextMenuState { - row_index: entry_idx, - drop_support, - }); - if let Some(context_menu) = this.build_commit_context_menu(entry_idx, window, cx) { - this.set_context_menu(context_menu, position, entry_idx, window, cx); - } - }); - }) - .detach(); + self.commit_context_menu_state = Some(CommitContextMenuState { + row_index: entry_idx, + }); + if let Some(context_menu) = self.build_commit_context_menu(entry_idx, window, cx) { + self.set_context_menu(context_menu, position, entry_idx, window, cx); + } } fn build_commit_context_menu( @@ -2759,8 +2760,6 @@ impl GitGraph { window: &mut Window, cx: &mut Context, ) { - self.select_entry(row_index, ScrollStrategy::Nearest, cx); - match &ref_kind { RefNameKind::Branch(_) => { self.deploy_branch_context_menu(position, row_index, ref_kind, window, cx); @@ -2786,35 +2785,25 @@ impl GitGraph { window: &mut Window, cx: &mut Context, ) { - let Some(repository) = self.get_repository(cx) else { - return; - }; - let branch_task = self.resolve_branch(ref_kind, repository, cx); - - cx.spawn_in(window, async move |this, cx| { - let branch = branch_task.await?; - this.update_in(cx, |this, window, cx| { - if let Some(context_menu) = this.build_branch_context_menu(branch, window, cx) { - this.set_context_menu(context_menu, position, row_index, window, cx); - } - })?; - Ok(()) - }) - .detach_and_prompt_err("Failed to open branch menu", window, cx, |error, _, _| { - Some(error.to_string()) - }); + if let Some(context_menu) = self.build_branch_context_menu(ref_kind, window, cx) { + self.set_context_menu(context_menu, position, row_index, window, cx); + } } fn build_branch_context_menu( &self, - branch: Branch, + ref_kind: RefNameKind, window: &mut Window, cx: &mut Context, ) -> Option> { - let branch_name: SharedString = branch.name().to_string().into(); + let branch_name = ref_kind + .branch_lookup_name() + .unwrap_or_else(|| ref_kind.display_name()); + let branch = self.resolve_branch_from_snapshot(&ref_kind, cx); let focus_handle = self.focus_handle.clone(); let weak = cx.weak_entity(); - let is_remote = branch.is_remote(); + let is_remote = branch.as_ref().is_some_and(Branch::is_remote); + let is_cached_branch = branch.is_some(); Some(ContextMenu::build(window, cx, { let branch_name_for_checkout = branch_name.clone(); @@ -2838,7 +2827,7 @@ impl GitGraph { } }); - let context_menu = if is_remote { + let context_menu = if is_remote || !is_cached_branch { context_menu } else { context_menu.entry("Rename Branch...", None, { @@ -2854,32 +2843,36 @@ impl GitGraph { }) }; - let context_menu = context_menu.entry( - if is_remote { - "Delete Remote-Tracking Branch..." - } else { - "Delete Branch..." - }, - None, - { - let branch_name = branch_name_for_delete.clone(); - let weak = weak.clone(); - move |window, cx| { - if let Some(entity) = weak.upgrade() { - entity.update(cx, |this, cx| { - this.delete_branch( - branch_name.to_string(), - is_remote, - window, - cx, - ); - }); + let context_menu = if is_cached_branch { + context_menu.entry( + if is_remote { + "Delete Remote-Tracking Branch..." + } else { + "Delete Branch..." + }, + None, + { + let branch_name = branch_name_for_delete.clone(); + let weak = weak.clone(); + move |window, cx| { + if let Some(entity) = weak.upgrade() { + entity.update(cx, |this, cx| { + this.delete_branch( + branch_name.to_string(), + is_remote, + window, + cx, + ); + }); + } } - } - }, - ); + }, + ) + } else { + context_menu + }; - let context_menu = if is_remote { + let context_menu = if is_remote || !is_cached_branch { context_menu } else { context_menu.entry("Push Branch...", None, { @@ -3095,25 +3088,18 @@ impl GitGraph { }) } - fn resolve_branch( - &self, - ref_kind: RefNameKind, - repository: Entity, - cx: &mut Context, - ) -> Task> { + fn resolve_branch_from_snapshot(&self, ref_kind: &RefNameKind, cx: &App) -> Option { let branch_name = ref_kind .branch_lookup_name() .unwrap_or_else(|| ref_kind.display_name()); - let receiver = repository.update(cx, |repository, _| repository.branches()); - - cx.spawn(async move |_, _| { - let branches = receiver - .await - .map_err(|_| anyhow::anyhow!("Operation was canceled"))??; - branches - .into_iter() + self.get_repository(cx).and_then(|repository| { + repository + .read(cx) + .snapshot() + .branch_list + .iter() .find(|branch| branch.name() == branch_name.as_ref()) - .ok_or_else(|| anyhow::anyhow!("Branch '{}' not found", branch_name)) + .cloned() }) } @@ -5610,11 +5596,17 @@ impl Render for GitGraph { self.search(query, cx); } let (commit_count, is_loading) = match self.graph_data.max_commit_count { - AllCommitCount::Loaded(count) => (count, true), + AllCommitCount::Loaded(count) => (count, false), AllCommitCount::NotLoaded => { let (commit_count, is_loading) = if let Some(repository) = self.get_repository(cx) { repository.update(cx, |repository, cx| { // Start loading the graph data if we haven't started already + let loaded_count = self.graph_data.commits.len(); + let range = if loaded_count == 0 { + 0..usize::MAX + } else { + loaded_count..loaded_count + }; let GraphDataResponse { commits, is_loading, @@ -5622,11 +5614,11 @@ impl Render for GitGraph { } = repository.graph_data( self.log_source.clone(), self.log_order, - 0..usize::MAX, + range, cx, ); - self.graph_data.add_commits(&commits); - (commits.len(), is_loading) + self.graph_data.add_commits(commits); + (self.graph_data.commits.len(), is_loading) }) } else { (0, false) @@ -6441,6 +6433,136 @@ mod tests { }); } + #[test] + fn graph_data_marks_fully_loaded_only_after_final_event() { + let mut rng = StdRng::seed_from_u64(7); + let commits = generate_random_commit_dag(&mut rng, 1, false); + let mut graph = GraphData::new(1); + + graph.add_commits(&commits); + assert_eq!(graph.max_commit_count, AllCommitCount::NotLoaded); + + graph.mark_fully_loaded(); + assert_eq!(graph.max_commit_count, AllCommitCount::Loaded(1)); + } + + #[gpui::test] + async fn test_commit_context_menu_deploys_synchronously(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; + fs.insert_branches(Path::new("/project/.git"), &["main"]); + + let mut rng = StdRng::seed_from_u64(42); + let commits = generate_random_commit_dag(&mut rng, 3, false); + 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_weak = + multi_workspace.read_with(&*cx, |multi, _| multi.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| { + graph.deploy_entry_context_menu(point(px(10.), px(10.)), 0, window, cx); + assert!(graph.context_menu.is_some()); + assert!(graph._commit_diff_task.is_none()); + }); + } + + #[gpui::test] + async fn test_ref_context_menu_uses_cached_branch_without_selecting_row( + 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; + fs.insert_branches(Path::new("/project/.git"), &["main", "origin/main"]); + + let mut rng = StdRng::seed_from_u64(42); + let commits = generate_random_commit_dag(&mut rng, 3, false); + 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_weak = + multi_workspace.read_with(&*cx, |multi, _| multi.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| { + let main = RefNameKind::Branch("main".into()); + let main_branch = graph + .resolve_branch_from_snapshot(&main, cx) + .expect("main branch should resolve from cached snapshot"); + assert!(!main_branch.is_remote()); + + let remote = RefNameKind::Branch("origin/main".into()); + let remote_branch = graph + .resolve_branch_from_snapshot(&remote, cx) + .expect("remote branch should resolve from cached snapshot"); + assert!(remote_branch.is_remote()); + + graph.deploy_ref_context_menu(point(px(10.), px(10.)), 0, main, window, cx); + assert!(graph.context_menu.is_some()); + assert_eq!(graph.selected_entry_idx, None); + assert!(graph._commit_diff_task.is_none()); + }); + } + fn local_branch(name: &str, upstream: Option<&str>) -> Branch { Branch { is_head: false, From 4cc8c51577eadee28cea445ba2b6890e3433ffd1 Mon Sep 17 00:00:00 2001 From: Trong Nguyen Date: Wed, 6 May 2026 10:07:00 +0700 Subject: [PATCH 04/11] fix(git_graph): block scrolling with context menu open --- crates/git_graph/src/git_graph.rs | 76 +++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index 9e1fb28bfae082..36b60d8821449f 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -4487,6 +4487,11 @@ impl GitGraph { window: &mut Window, cx: &mut Context, ) { + if self.context_menu.is_some() { + cx.stop_propagation(); + return; + } + let line_height = window.line_height(); let delta = event.delta.pixel_delta(line_height); @@ -6563,6 +6568,77 @@ mod tests { }); } + #[gpui::test] + async fn test_graph_scroll_is_blocked_while_context_menu_is_open(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; + fs.insert_branches(Path::new("/project/.git"), &["main"]); + + let mut rng = StdRng::seed_from_u64(42); + let commits = generate_random_commit_dag(&mut rng, 20, false); + 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_weak = + multi_workspace.read_with(&*cx, |multi, _| multi.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| { + let scroll_event = ScrollWheelEvent { + position: point(px(10.), px(10.)), + delta: gpui::ScrollDelta::Pixels(point(px(0.), px(-100.))), + ..Default::default() + }; + let initial_offset = graph.table_interaction_state.read(cx).scroll_offset(); + + graph.handle_graph_scroll(&scroll_event, window, cx); + let scrolled_offset = graph.table_interaction_state.read(cx).scroll_offset(); + assert!( + scrolled_offset.y < initial_offset.y, + "scroll should move the graph when no context menu is open" + ); + + graph.deploy_entry_context_menu(point(px(10.), px(10.)), 0, window, cx); + assert!(graph.context_menu.is_some()); + + graph.handle_graph_scroll(&scroll_event, window, cx); + assert_eq!( + graph.table_interaction_state.read(cx).scroll_offset(), + scrolled_offset + ); + assert!(graph.context_menu.is_some()); + }); + } + fn local_branch(name: &str, upstream: Option<&str>) -> Branch { Branch { is_head: false, From 654d9d934b031b4b10e7e7a978fabd2a96d6bf16 Mon Sep 17 00:00:00 2001 From: Trong Nguyen Date: Wed, 6 May 2026 10:24:01 +0700 Subject: [PATCH 05/11] fix(git_graph): block table scroll with context menu open --- crates/git_graph/src/git_graph.rs | 68 +++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 16 deletions(-) diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index 36b60d8821449f..089c548acc1e0b 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -20,8 +20,8 @@ use gpui::{ DismissEvent, DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, Hsla, MouseButton, MouseDownEvent, PathBuilder, Pixels, Point, PromptLevel, ScrollStrategy, ScrollWheelEvent, SharedString, Subscription, Task, TextStyleRefinement, - UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, point, prelude::*, - px, uniform_list, + UniformListScrollHandle, WeakEntity, Window, actions, anchored, point, prelude::*, px, + uniform_list, }; use language::line_diff; use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; @@ -1674,6 +1674,12 @@ impl GitGraph { self.context_menu.is_some() } + fn clear_context_menu(&mut self, cx: &mut Context) { + self.context_menu = None; + self.commit_context_menu_state = None; + cx.notify(); + } + /// Checks whether a ref name from git's `%D` decoration /// format refers to the currently checked-out branch. fn is_head_ref(ref_name: &str, head_branch_name: &Option) -> bool { @@ -5899,6 +5905,7 @@ impl Render for GitGraph { .key_context("GitGraph") .track_focus(&self.focus_handle) .size_full() + .relative() .bg(cx.theme().colors().editor_background) .on_action(cx.listener(|this, _: &OpenCommitView, window, cx| { this.open_selected_commit_view(window, cx); @@ -5971,14 +5978,30 @@ impl Render for GitGraph { .child(self.render_search_bar(cx)) .child(div().flex_1().child(content)), ) + .children(self.context_menu.as_ref().map(|_| { + div() + .absolute() + .inset_0() + .occlude() + .on_scroll_wheel(|_, _, cx| cx.stop_propagation()) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.clear_context_menu(cx); + }), + ) + .on_mouse_down( + MouseButton::Right, + cx.listener(|this, _, _, cx| { + this.clear_context_menu(cx); + }), + ) + })) .children(self.context_menu.as_ref().map(|context_menu| { - deferred( - anchored() - .position(context_menu.position) - .anchor(Anchor::TopLeft) - .child(context_menu.menu.clone()), - ) - .with_priority(1) + anchored() + .position(context_menu.position) + .anchor(Anchor::TopLeft) + .child(context_menu.menu.clone()) })) .on_action(cx.listener(|_, _: &buffer_search::Deploy, window, cx| { window.dispatch_action(Box::new(FocusSearch), cx); @@ -6612,12 +6635,13 @@ mod tests { }); cx.run_until_parked(); + let scroll_event = ScrollWheelEvent { + position: point(px(900.), px(120.)), + delta: gpui::ScrollDelta::Pixels(point(px(0.), px(-100.))), + ..Default::default() + }; + git_graph.update_in(cx, |graph, window, cx| { - let scroll_event = ScrollWheelEvent { - position: point(px(10.), px(10.)), - delta: gpui::ScrollDelta::Pixels(point(px(0.), px(-100.))), - ..Default::default() - }; let initial_offset = graph.table_interaction_state.read(cx).scroll_offset(); graph.handle_graph_scroll(&scroll_event, window, cx); @@ -6629,11 +6653,23 @@ mod tests { graph.deploy_entry_context_menu(point(px(10.), px(10.)), 0, window, cx); assert!(graph.context_menu.is_some()); + }); + cx.draw( + point(px(0.), px(0.)), + gpui::size(px(1200.), px(800.)), + |_, _| git_graph.clone().into_any_element(), + ); + cx.run_until_parked(); - graph.handle_graph_scroll(&scroll_event, window, cx); + let offset_before_blocked_scroll = git_graph.read_with(&*cx, |graph, cx| { + graph.table_interaction_state.read(cx).scroll_offset() + }); + + cx.simulate_event(scroll_event); + git_graph.read_with(&*cx, |graph, cx| { assert_eq!( graph.table_interaction_state.read(cx).scroll_offset(), - scrolled_offset + offset_before_blocked_scroll ); assert!(graph.context_menu.is_some()); }); From a29f30daf7aa2f0d26fc245c55c4e351f29e9b5f Mon Sep 17 00:00:00 2001 From: Trong Nguyen Date: Wed, 6 May 2026 14:23:26 +0700 Subject: [PATCH 06/11] fix(git_graph): address push and revert review findings --- crates/git/src/repository.rs | 49 +++++++-- crates/project/src/git_store.rs | 181 ++++++++++++++++++++++++++------ crates/proto/proto/git.proto | 8 +- 3 files changed, 198 insertions(+), 40 deletions(-) diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 2a72670dca2c91..e5799fb6ce53ff 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1085,6 +1085,18 @@ impl PushOptions { } } +fn revert_commit_command_args(sha: &str, parent_count: usize, no_commit: bool) -> Vec { + let mut args = vec!["revert".into(), "--no-edit".into()]; + if parent_count > 1 { + args.extend(["-m".into(), "1".into()]); + } + if no_commit { + args.push("--no-commit".into()); + } + args.push(sha.into()); + args +} + impl std::fmt::Debug for dyn GitRepository { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("dyn GitRepository<...>").finish() @@ -2220,12 +2232,17 @@ impl GitRepository for RealGitRepository { } fn revert_commit(&self, sha: String, no_commit: bool) -> BoxFuture<'_, Result<()>> { - let mut args = vec!["revert".into(), "--no-edit".into()]; - if no_commit { - args.push("--no-commit".into()); - } - args.push(sha); - self.simple_git_command(args) + let git = self.git_binary(); + + self.executor + .spawn(async move { + let commit_line = git.run(&["rev-list", "--parents", "-n", "1", &sha]).await?; + let parent_count = commit_line.split_whitespace().count().saturating_sub(1); + git.run(&revert_commit_command_args(&sha, parent_count, no_commit)) + .await?; + Ok(()) + }) + .boxed() } fn drop_commit_support(&self, sha: String) -> BoxFuture<'_, Result> { @@ -4060,6 +4077,26 @@ mod tests { ); } + #[test] + fn test_revert_commit_command_args() { + assert_eq!( + revert_commit_command_args("abc123", 1, false), + vec!["revert", "--no-edit", "abc123"] + ); + assert_eq!( + revert_commit_command_args("abc123", 1, true), + vec!["revert", "--no-edit", "--no-commit", "abc123"] + ); + assert_eq!( + revert_commit_command_args("abc123", 2, false), + vec!["revert", "--no-edit", "-m", "1", "abc123"] + ); + assert_eq!( + revert_commit_command_args("abc123", 2, true), + vec!["revert", "--no-edit", "-m", "1", "--no-commit", "abc123"] + ); + } + #[gpui::test] async fn test_build_command_untrusted_includes_both_safety_args(cx: &mut TestAppContext) { cx.executor().allow_parking(); diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index ef33c58f041a0d..a77000be132ae1 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -505,6 +505,66 @@ impl EventEmitter for Repository {} impl EventEmitter for Repository {} impl EventEmitter for GitStore {} +fn push_mode_from_proto(mode: proto::push::push_options_v2::PushMode) -> git::repository::PushMode { + match mode { + proto::push::push_options_v2::PushMode::Normal => git::repository::PushMode::Normal, + proto::push::push_options_v2::PushMode::ForceWithLease => { + git::repository::PushMode::ForceWithLease + } + proto::push::push_options_v2::PushMode::Force => git::repository::PushMode::Force, + } +} + +fn push_mode_to_proto(mode: git::repository::PushMode) -> proto::push::push_options_v2::PushMode { + match mode { + git::repository::PushMode::Normal => proto::push::push_options_v2::PushMode::Normal, + git::repository::PushMode::ForceWithLease => { + proto::push::push_options_v2::PushMode::ForceWithLease + } + git::repository::PushMode::Force => proto::push::push_options_v2::PushMode::Force, + } +} + +fn push_options_from_proto(payload: &proto::Push) -> Option { + if let Some(options) = payload.options_v2.as_ref() { + return Some(git::repository::PushOptions { + set_upstream: options.set_upstream, + push_mode: push_mode_from_proto(options.push_mode()), + }); + } + + payload.options.as_ref().map(|_| match payload.options() { + proto::push::PushOptions::SetUpstream => git::repository::PushOptions { + set_upstream: true, + push_mode: git::repository::PushMode::Normal, + }, + proto::push::PushOptions::Force => git::repository::PushOptions { + set_upstream: false, + push_mode: git::repository::PushMode::ForceWithLease, + }, + }) +} + +fn push_options_to_proto( + options: git::repository::PushOptions, +) -> ( + Option, + Option, +) { + let options_v2 = proto::push::PushOptionsV2 { + set_upstream: options.set_upstream, + push_mode: push_mode_to_proto(options.push_mode) as i32, + }; + + let legacy_options = match (options.set_upstream, options.push_mode) { + (true, git::repository::PushMode::Normal) => Some(proto::push::PushOptions::SetUpstream), + (false, git::repository::PushMode::ForceWithLease) => Some(proto::push::PushOptions::Force), + _ => None, + }; + + (legacy_options, Some(options_v2)) +} + pub struct GitJob { job: Box Task<()>>, key: Option, @@ -2222,25 +2282,7 @@ impl GitStore { &mut cx, ); - let options = - envelope - .payload - .options - .as_ref() - .map(|options| git::repository::PushOptions { - set_upstream: options.set_upstream, - push_mode: match options.push_mode() { - proto::push::push_options::PushMode::Normal => { - git::repository::PushMode::Normal - } - proto::push::push_options::PushMode::ForceWithLease => { - git::repository::PushMode::ForceWithLease - } - proto::push::push_options::PushMode::Force => { - git::repository::PushMode::Force - } - }, - }); + let options = push_options_from_proto(&envelope.payload); let branch_name = envelope.payload.branch_name.into(); let remote_branch_name = envelope.payload.remote_branch_name.into(); @@ -6482,6 +6524,8 @@ impl Repository { let askpass_delegate = askpass_delegates.lock().remove(&askpass_id); debug_assert!(askpass_delegate.is_some()); }); + let (legacy_options, options_v2) = + options.map(push_options_to_proto).unwrap_or((None, None)); let response = client .request(proto::Push { project_id: project_id.0, @@ -6490,20 +6534,8 @@ impl Repository { branch_name: branch.to_string(), remote_branch_name: remote_branch.to_string(), remote_name: remote.to_string(), - options: options.map(|options| proto::push::PushOptions { - set_upstream: options.set_upstream, - push_mode: match options.push_mode { - git::repository::PushMode::Normal => { - proto::push::push_options::PushMode::Normal - } - git::repository::PushMode::ForceWithLease => { - proto::push::push_options::PushMode::ForceWithLease - } - git::repository::PushMode::Force => { - proto::push::push_options::PushMode::Force - } - } as i32, - }), + options: legacy_options.map(Into::into), + options_v2, }) .await?; @@ -7064,6 +7096,7 @@ impl Repository { branch_name: format!("refs/tags/{name}"), remote_branch_name: format!("refs/tags/{name}"), options: None, + options_v2: None, }) .await .map(|response| RemoteCommandOutput { @@ -8689,6 +8722,88 @@ mod tests { }); } + #[test] + fn test_push_options_from_legacy_proto() { + let mut push = proto::Push::default(); + push.options = Some(proto::push::PushOptions::SetUpstream as i32); + assert_eq!( + push_options_from_proto(&push), + Some(PushOptions { + set_upstream: true, + push_mode: git::repository::PushMode::Normal, + }) + ); + + push.options = Some(proto::push::PushOptions::Force as i32); + assert_eq!( + push_options_from_proto(&push), + Some(PushOptions { + set_upstream: false, + push_mode: git::repository::PushMode::ForceWithLease, + }) + ); + } + + #[test] + fn test_push_options_v2_precedes_legacy_proto() { + let mut push = proto::Push::default(); + push.options = Some(proto::push::PushOptions::SetUpstream as i32); + push.options_v2 = Some(proto::push::PushOptionsV2 { + set_upstream: false, + push_mode: proto::push::push_options_v2::PushMode::Force as i32, + }); + + assert_eq!( + push_options_from_proto(&push), + Some(PushOptions { + set_upstream: false, + push_mode: git::repository::PushMode::Force, + }) + ); + } + + #[test] + fn test_push_options_to_proto_sets_v2_and_representable_legacy() { + let (legacy, options_v2) = push_options_to_proto(PushOptions { + set_upstream: true, + push_mode: git::repository::PushMode::Normal, + }); + assert_eq!(legacy, Some(proto::push::PushOptions::SetUpstream)); + assert_eq!( + options_v2, + Some(proto::push::PushOptionsV2 { + set_upstream: true, + push_mode: proto::push::push_options_v2::PushMode::Normal as i32, + }) + ); + + let (legacy, options_v2) = push_options_to_proto(PushOptions { + set_upstream: false, + push_mode: git::repository::PushMode::ForceWithLease, + }); + assert_eq!(legacy, Some(proto::push::PushOptions::Force)); + assert_eq!( + options_v2, + Some(proto::push::PushOptionsV2 { + set_upstream: false, + push_mode: proto::push::push_options_v2::PushMode::ForceWithLease as i32, + }) + ); + + let (legacy, options_v2) = push_options_to_proto(PushOptions { + set_upstream: true, + push_mode: git::repository::PushMode::Force, + }); + assert_eq!(legacy, None); + assert_eq!( + options_v2, + Some(proto::push::PushOptionsV2 { + set_upstream: true, + push_mode: proto::push::push_options_v2::PushMode::Force as i32, + }) + ); + } + fn verify_invariants(repository: &Repository) -> anyhow::Result<()> { match &repository.commit_data_handler { CommitDataHandlerState::Open(handler) => { diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 448cbc49a9944f..2b7b465ede8e43 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -404,8 +404,14 @@ message Push { optional PushOptions options = 6; uint64 askpass_id = 7; string remote_branch_name = 8; + optional PushOptionsV2 options_v2 = 9; - message PushOptions { + enum PushOptions { + SET_UPSTREAM = 0; + FORCE = 1; + } + + message PushOptionsV2 { bool set_upstream = 1; PushMode push_mode = 2; From 478e0029a48a9b229fd4d0be3b1e429bd0551ff3 Mon Sep 17 00:00:00 2001 From: Trong Nguyen Date: Wed, 6 May 2026 15:37:16 +0700 Subject: [PATCH 07/11] fix(git_graph): expose commit ops and tag push remote picker --- crates/git_graph/src/git_graph.rs | 291 +++++++++++++++++++++++++++--- 1 file changed, 264 insertions(+), 27 deletions(-) diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index 434efd19dbbc2d..e26affa0989eb5 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -459,6 +459,47 @@ impl PushBranchDialogState { } } +#[derive(Clone, Debug, PartialEq, Eq)] +struct TagPushTarget { + tag_name: SharedString, + remote: Remote, +} + +#[derive(Clone, Debug)] +struct PushTagDialogState { + tag_name: SharedString, + available_remotes: Vec, + selected_remote: SharedString, +} + +impl PushTagDialogState { + fn new(tag_name: SharedString, available_remotes: Vec) -> anyhow::Result { + let selected_remote = available_remotes + .first() + .cloned() + .ok_or_else(|| anyhow::anyhow!("No remote configured for repository"))?; + + Ok(Self { + tag_name, + available_remotes, + selected_remote, + }) + } + + fn select_remote(&mut self, remote_name: SharedString) { + self.selected_remote = remote_name; + } + + fn push_target(&self) -> TagPushTarget { + TagPushTarget { + tag_name: self.tag_name.clone(), + remote: Remote { + name: self.selected_remote.clone(), + }, + } + } +} + pub struct SplitState { left_ratio: f32, visible_left_ratio: f32, @@ -2682,6 +2723,15 @@ impl GitGraph { .action("Checkout Commit...", CheckoutCommit.boxed_clone()) .action("Cherry-Pick Commit...", CherryPickCommit.boxed_clone()) .action("Revert Commit...", RevertCommit.boxed_clone()) + .action("Drop Commit...", DropCommit.boxed_clone()) + .action( + "Merge Commit into Current Branch...", + MergeCommit.boxed_clone(), + ) + .action( + "Rebase Current Branch onto Commit...", + RebaseOntoCommit.boxed_clone(), + ) .action( "Reset Current Branch to This Commit...", ResetCommit.boxed_clone(), @@ -3218,40 +3268,76 @@ impl GitGraph { let Some(repository) = self.get_repository(cx) else { return; }; - let Some(remote_name) = repository - .read(cx) - .remote_upstream_url - .as_ref() - .map(|_| SharedString::from("upstream")) - .or_else(|| { - repository - .read(cx) - .remote_origin_url - .as_ref() - .map(|_| SharedString::from("origin")) - }) - else { - let prompt = window.prompt( - PromptLevel::Warning, - "No remote configured for repository", - None, - &["Ok"], - cx, - ); - cx.spawn(async move |_, _| { - prompt.await.ok(); - anyhow::Ok(()) - }) - .detach(); + let remotes_receiver = + repository.update(cx, |repository, _| repository.get_remotes(None, true)); + + self.context_menu = None; + self.commit_context_menu_state = None; + + cx.spawn_in(window, async move |this, cx| { + let remotes = remotes_receiver + .await + .map_err(|_| anyhow::anyhow!("Operation was canceled"))??; + + if remotes.is_empty() { + this.update_in(cx, |_, window, cx| { + let prompt = window.prompt( + PromptLevel::Warning, + "No remote configured for repository", + None, + &["Ok"], + cx, + ); + cx.spawn(async move |_, _| { + prompt.await.ok(); + anyhow::Ok(()) + }) + .detach(); + })?; + return Ok(()); + } + + let dialog_state = PushTagDialogState::new( + tag_name.into(), + remotes.into_iter().map(|remote| remote.name).collect(), + )?; + + this.update_in(cx, |this, window, cx| { + let Some(workspace) = this.workspace.upgrade() else { + return; + }; + let graph = cx.weak_entity(); + workspace.update(cx, |workspace, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + PushTagModal::new(graph, dialog_state.clone(), window, cx) + }); + }); + })?; + + Ok(()) + }) + .detach_and_prompt_err("Failed to push tag", window, cx, |error, _, _| { + Some(error.to_string()) + }); + } + + fn perform_push_tag( + &mut self, + target: TagPushTarget, + window: &mut Window, + cx: &mut Context, + ) { + let Some(repository) = self.get_repository(cx) else { return; }; - + let tag_name = target.tag_name.clone(); + let remote_name = target.remote.name.clone(); let askpass = self.askpass_delegate(format!("git push {}", remote_name), window, cx); let task = cx.spawn(async move |_, cx| { repository .update(cx, |repository, cx| { - repository.push_tag(tag_name.into(), remote_name, askpass, cx) + repository.push_tag(tag_name, remote_name, askpass, cx) }) .await .map_err(|_| anyhow::anyhow!("Operation was canceled"))??; @@ -5249,6 +5335,134 @@ impl Render for PushBranchModal { } } +struct PushTagModal { + graph: WeakEntity, + state: PushTagDialogState, + focus_handle: FocusHandle, +} + +impl PushTagModal { + fn new( + graph: WeakEntity, + state: PushTagDialogState, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + Self { + graph, + state, + focus_handle: cx.focus_handle(), + } + } + + fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } + + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let target = self.state.push_target(); + if let Some(graph) = self.graph.upgrade() { + graph.update(cx, |graph, cx| { + graph.perform_push_tag(target, window, cx); + }); + } + + cx.emit(DismissEvent); + } + + fn render_remote_dropdown(&self, window: &mut Window, cx: &mut Context) -> DropdownMenu { + let weak = cx.weak_entity(); + let remotes = self.state.available_remotes.clone(); + let menu = ContextMenu::build(window, cx, move |mut menu, _, _| { + for remote_name in remotes.clone() { + let weak = weak.clone(); + menu = menu.entry(remote_name.clone(), None, move |_window, cx| { + if let Some(entity) = weak.upgrade() { + entity.update(cx, |this, cx| { + this.state.select_remote(remote_name.clone()); + cx.notify(); + }); + } + }); + } + menu + }); + + DropdownMenu::new( + "push-tag-remote-dropdown", + self.state.selected_remote.clone(), + menu, + ) + .style(DropdownStyle::Outlined) + .full_width(true) + } +} + +impl EventEmitter for PushTagModal {} +impl ModalView for PushTagModal {} +impl Focusable for PushTagModal { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for PushTagModal { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("PushTagModal") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .elevation_2(cx) + .w(ui::rems(34.)) + .child( + h_flex() + .px_3() + .pt_2() + .pb_1() + .gap_1p5() + .child(Icon::new(IconName::GitCommit).size(IconSize::XSmall)) + .child(Label::new(format!("Push Tag ({})", self.state.tag_name))), + ) + .child( + v_flex() + .px_3() + .pb_3() + .w_full() + .gap_3() + .child( + v_flex() + .gap_1() + .child(Label::new("Push to Remote:").size(LabelSize::Small)) + .child(self.render_remote_dropdown(window, cx)), + ) + .child( + h_flex() + .justify_end() + .gap_2() + .child( + Button::new("push-tag-cancel", "Cancel") + .style(ButtonStyle::Subtle) + .on_click(cx.listener( + |this: &mut PushTagModal, _, window, cx| { + this.cancel(&Cancel, window, cx); + }, + )), + ) + .child( + Button::new("push-tag-confirm", "Push") + .style(ButtonStyle::Filled) + .on_click(cx.listener( + |this: &mut PushTagModal, _, window, cx| { + this.confirm(&Confirm, window, cx); + }, + )), + ), + ), + ) + } +} + struct DeleteBranchModal { graph: WeakEntity, branch_name: SharedString, @@ -6730,6 +6944,29 @@ mod tests { ); } + #[test] + fn push_tag_dialog_uses_selected_remote() { + let mut state = + PushTagDialogState::new("v1.0.0".into(), vec!["origin".into(), "upstream".into()]) + .expect("remote should be available"); + + assert_eq!(state.selected_remote.as_ref(), "origin"); + + state.select_remote("upstream".into()); + + let target = state.push_target(); + assert_eq!(target.tag_name.as_ref(), "v1.0.0"); + assert_eq!(target.remote.name.as_ref(), "upstream"); + } + + #[test] + fn push_tag_dialog_requires_a_remote() { + let error = PushTagDialogState::new("v1.0.0".into(), Vec::new()) + .expect_err("remote should be required"); + + assert_eq!(error.to_string(), "No remote configured for repository"); + } + /// Generates a random commit DAG suitable for testing git graph rendering. /// /// The commits are ordered newest-first (like git log output), so: From 60afa118732b7455617451a19bea615cbf30ad88 Mon Sep 17 00:00:00 2001 From: Trong Nguyen Date: Wed, 6 May 2026 15:50:16 +0700 Subject: [PATCH 08/11] fix(git_graph): reopen commit context menu on right click --- crates/git_graph/src/git_graph.rs | 113 +++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 3 deletions(-) diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index e26affa0989eb5..bf2c721a81e2de 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -4573,6 +4573,20 @@ impl GitGraph { self.handle_entry_secondary_mouse_down(row, event, window, cx); } + fn handle_context_menu_overlay_secondary_mouse_down( + &mut self, + event: &MouseDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(row) = self.row_at_position(event.position.y, window, cx) { + self.deploy_entry_context_menu(event.position, row, window, cx); + } else { + self.clear_context_menu(cx); + } + cx.stop_propagation(); + } + fn handle_graph_scroll( &mut self, event: &ScrollWheelEvent, @@ -6206,8 +6220,10 @@ impl Render for GitGraph { ) .on_mouse_down( MouseButton::Right, - cx.listener(|this, _, _, cx| { - this.clear_context_menu(cx); + cx.listener(|this, event: &MouseDownEvent, window, cx| { + this.handle_context_menu_overlay_secondary_mouse_down( + event, window, cx, + ); }), ) })) @@ -6653,7 +6669,7 @@ mod tests { use fs::FakeFs; use git::Oid; use git::repository::InitialGraphCommitData; - use gpui::{TestAppContext, UpdateGlobal}; + use gpui::{Modifiers, TestAppContext, UpdateGlobal}; use project::Project; use project::git_store::{GitStoreEvent, RepositoryEvent}; use rand::prelude::*; @@ -6739,6 +6755,97 @@ mod tests { }); } + #[gpui::test] + async fn test_right_clicking_another_commit_replaces_open_context_menu( + 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; + fs.insert_branches(Path::new("/project/.git"), &["main"]); + + let mut rng = StdRng::seed_from_u64(42); + let commits = generate_random_commit_dag(&mut rng, 3, false); + 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_weak = + multi_workspace.read_with(&*cx, |multi, _| multi.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| { + let row_height = GitGraph::row_height(window, cx); + graph.graph_canvas_bounds.set(Some(Bounds { + origin: point(px(0.), px(0.)), + size: gpui::size(px(1000.), row_height * 3.), + })); + + graph.deploy_entry_context_menu(point(px(10.), row_height * 0.5), 0, window, cx); + assert_eq!( + graph + .context_menu + .as_ref() + .map(|context_menu| context_menu.entry_idx), + Some(0) + ); + + graph.handle_context_menu_overlay_secondary_mouse_down( + &MouseDownEvent { + button: MouseButton::Right, + position: point(px(10.), row_height * 1.5), + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + }, + window, + cx, + ); + + assert_eq!( + graph + .context_menu + .as_ref() + .map(|context_menu| context_menu.entry_idx), + Some(1) + ); + assert_eq!( + graph + .commit_context_menu_state + .as_ref() + .map(|state| state.row_index), + Some(1) + ); + }); + } + #[gpui::test] async fn test_ref_context_menu_uses_cached_branch_without_selecting_row( cx: &mut TestAppContext, From 10f8db5da01efde1cdb4396d5268adc014d2238e Mon Sep 17 00:00:00 2001 From: Trong Nguyen Date: Wed, 6 May 2026 16:07:46 +0700 Subject: [PATCH 09/11] fix(git_graph): switch context menus between refs and commits --- crates/git_graph/src/git_graph.rs | 105 +++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 2 deletions(-) diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index bf2c721a81e2de..4dc01ad352c935 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -39,7 +39,7 @@ use search::{ }; use smallvec::{SmallVec, smallvec}; use std::{ - cell::Cell, + cell::{Cell, RefCell}, ops::Range, rc::Rc, sync::{Arc, OnceLock}, @@ -1282,6 +1282,13 @@ struct GitGraphContextMenu { _subscription: Subscription, } +#[derive(Clone)] +struct RefContextMenuTarget { + bounds: Bounds, + row_index: usize, + ref_kind: RefNameKind, +} + pub struct GitGraph { focus_handle: FocusHandle, search_state: SearchState, @@ -1290,6 +1297,7 @@ pub struct GitGraph { workspace: WeakEntity, context_menu: Option, commit_context_menu_state: Option, + ref_context_menu_targets: Rc>>, table_interaction_state: Entity, column_widths: Entity, selected_entry_idx: Option, @@ -1525,6 +1533,7 @@ impl GitGraph { _commit_diff_task: None, context_menu: None, commit_context_menu_state: None, + ref_context_menu_targets: Rc::new(RefCell::new(Vec::new())), table_interaction_state, column_widths, selected_entry_idx: None, @@ -1759,12 +1768,33 @@ impl GitGraph { cx: &Context, ) -> impl IntoElement { let ref_kind = RefNameKind::classify(name); + let ref_context_menu_targets = self.ref_context_menu_targets.clone(); let weak = cx.weak_entity(); let chip_id = ElementId::Name(format!("ref-chip-{}-{}", row_index, name.as_ref()).into()); div() .id(chip_id) + .relative() .child(self.render_chip(name, accent_color, is_head)) + .child( + gpui::canvas( + { + let ref_kind = ref_kind.clone(); + move |bounds, _window, _cx| { + ref_context_menu_targets + .borrow_mut() + .push(RefContextMenuTarget { + bounds, + row_index, + ref_kind: ref_kind.clone(), + }); + } + }, + |_bounds, _state, _window, _cx| {}, + ) + .absolute() + .inset_0(), + ) .on_mouse_down( MouseButton::Right, move |event: &MouseDownEvent, window, cx| { @@ -2816,6 +2846,7 @@ impl GitGraph { window: &mut Window, cx: &mut Context, ) { + self.commit_context_menu_state = None; match &ref_kind { RefNameKind::Branch(_) => { self.deploy_branch_context_menu(position, row_index, ref_kind, window, cx); @@ -4579,7 +4610,23 @@ impl GitGraph { window: &mut Window, cx: &mut Context, ) { - if let Some(row) = self.row_at_position(event.position.y, window, cx) { + let ref_target = self + .ref_context_menu_targets + .borrow() + .iter() + .rev() + .find(|target| target.bounds.contains(&event.position)) + .cloned(); + + if let Some(target) = ref_target { + self.deploy_ref_context_menu( + event.position, + target.row_index, + target.ref_kind, + window, + cx, + ); + } else if let Some(row) = self.row_at_position(event.position.y, window, cx) { self.deploy_entry_context_menu(event.position, row, window, cx); } else { self.clear_context_menu(cx); @@ -5828,6 +5875,8 @@ impl Render for GitGraphAskPassModal { impl Render for GitGraph { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.ref_context_menu_targets.borrow_mut().clear(); + // This happens when we changed branches, we should refresh our search as well if let QueryState::Pending(query) = &mut self.search_state.state { let query = std::mem::take(query); @@ -6843,6 +6892,58 @@ mod tests { .map(|state| state.row_index), Some(1) ); + + for ref_kind in [ + RefNameKind::Branch("main".into()), + RefNameKind::Tag("v1.0.0".into()), + RefNameKind::Stash("stash@{0}".into()), + ] { + graph.deploy_entry_context_menu(point(px(10.), row_height * 0.5), 0, window, cx); + assert_eq!( + graph + .commit_context_menu_state + .as_ref() + .map(|state| state.row_index), + Some(0) + ); + + graph.ref_context_menu_targets.borrow_mut().clear(); + graph + .ref_context_menu_targets + .borrow_mut() + .push(RefContextMenuTarget { + bounds: Bounds { + origin: point(px(0.), row_height), + size: gpui::size(px(100.), row_height), + }, + row_index: 1, + ref_kind, + }); + + graph.handle_context_menu_overlay_secondary_mouse_down( + &MouseDownEvent { + button: MouseButton::Right, + position: point(px(10.), row_height * 1.5), + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + }, + window, + cx, + ); + + assert_eq!( + graph + .context_menu + .as_ref() + .map(|context_menu| context_menu.entry_idx), + Some(1) + ); + assert!( + graph.commit_context_menu_state.is_none(), + "ref context menus should not retain commit-only state" + ); + } }); } From 0499db4a1c9fb5df3062a22cf260605f091d91f5 Mon Sep 17 00:00:00 2001 From: Trong Nguyen Date: Mon, 11 May 2026 09:57:47 +0700 Subject: [PATCH 10/11] fix: add missing git graph job descriptions --- crates/project/src/git_store.rs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index e91603803fcd0e..ceeae8544228bc 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -5143,7 +5143,7 @@ impl Repository { message: Option, ) -> oneshot::Receiver> { let this = self.this.clone(); - self.send_job(None, move |repo, mut cx| async move { + self.send_job("create_tag", None, move |repo, mut cx| async move { let result = match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.create_tag(sha, name, message).await @@ -5165,7 +5165,7 @@ impl Repository { pub fn create_branch_at(&mut self, sha: String, name: String) -> oneshot::Receiver> { let this = self.this.clone(); - self.send_job(None, move |repo, mut cx| async move { + self.send_job("create_branch_at", None, move |repo, mut cx| async move { let result = match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.create_branch_at(sha, name).await @@ -5186,7 +5186,7 @@ impl Repository { } pub fn checkout_commit(&mut self, sha: String) -> oneshot::Receiver> { - self.send_job(None, move |repo, _cx| async move { + self.send_job("checkout_commit", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.checkout_commit(sha).await @@ -5204,7 +5204,7 @@ impl Repository { record_origin: bool, no_commit: bool, ) -> oneshot::Receiver> { - self.send_job(None, move |repo, _cx| async move { + self.send_job("cherry_pick", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.cherry_pick(sha, record_origin, no_commit).await @@ -5217,7 +5217,7 @@ impl Repository { } pub fn revert_commit(&mut self, sha: String, no_commit: bool) -> oneshot::Receiver> { - self.send_job(None, move |repo, _cx| async move { + self.send_job("revert_commit", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.revert_commit(sha, no_commit).await @@ -5233,7 +5233,7 @@ impl Repository { &mut self, sha: String, ) -> oneshot::Receiver> { - self.send_job(None, move |repo, _cx| async move { + self.send_job("drop_commit_support", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.drop_commit_support(sha).await @@ -5246,7 +5246,7 @@ impl Repository { } pub fn drop_commit(&mut self, sha: String) -> oneshot::Receiver> { - self.send_job(None, move |repo, _cx| async move { + self.send_job("drop_commit", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.drop_commit(sha).await @@ -5259,7 +5259,7 @@ impl Repository { } pub fn merge_commit(&mut self, sha: String) -> oneshot::Receiver> { - self.send_job(None, move |repo, _cx| async move { + self.send_job("merge_commit", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.merge_commit(sha).await @@ -5272,7 +5272,7 @@ impl Repository { } pub fn rebase_onto(&mut self, sha: String) -> oneshot::Receiver> { - self.send_job(None, move |repo, _cx| async move { + self.send_job("rebase_onto", None, move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.rebase_onto(sha).await @@ -7274,7 +7274,7 @@ impl Repository { pub fn delete_tag(&mut self, name: String) -> oneshot::Receiver> { let this = self.this.clone(); - self.send_job(None, move |repo, mut cx| async move { + self.send_job("delete_tag", None, move |repo, mut cx| async move { let result = match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { backend.delete_tag(name).await @@ -7308,6 +7308,7 @@ impl Repository { let id = self.id; self.send_job( + "push_tag", Some(format!("git push {} refs/tags/{}", remote, name).into()), move |repo, _cx| async move { match repo { From 8548ca5f8a33f94b76691bc2480b825c68eabb2a Mon Sep 17 00:00:00 2001 From: Trong Nguyen Date: Wed, 13 May 2026 10:14:55 +0700 Subject: [PATCH 11/11] refactor(git_graph): split context menu code --- crates/git_graph/src/context_menu.rs | 796 ++++++++++ crates/git_graph/src/git_graph.rs | 2053 +------------------------- crates/git_graph/src/modals.rs | 1177 +++++++++++++++ 3 files changed, 2024 insertions(+), 2002 deletions(-) create mode 100644 crates/git_graph/src/context_menu.rs create mode 100644 crates/git_graph/src/modals.rs diff --git a/crates/git_graph/src/context_menu.rs b/crates/git_graph/src/context_menu.rs new file mode 100644 index 00000000000000..ecb6ee0d5fa06b --- /dev/null +++ b/crates/git_graph/src/context_menu.rs @@ -0,0 +1,796 @@ +use super::*; + +#[derive(Clone)] +pub(super) struct SelectedCommitInfo { + pub(super) index: usize, + pub(super) sha: SharedString, + pub(super) subject: Option, +} + +#[derive(Clone, Debug)] +pub(super) enum RefNameKind { + Branch(SharedString), + Tag(SharedString), + Stash(SharedString), +} + +impl RefNameKind { + pub(super) fn classify(ref_name: &SharedString) -> Self { + let name = ref_name.as_ref(); + if name == "refs/stash" + || name == "stash" + || name.starts_with("stash@{") + || name.contains("refs/stash") + { + Self::Stash(ref_name.clone()) + } else if name.starts_with("tag: ") || name.starts_with("refs/tags/") { + Self::Tag(ref_name.clone()) + } else { + Self::Branch(ref_name.clone()) + } + } + + pub(super) fn display_name(&self) -> SharedString { + match self { + Self::Branch(name) => { + let name = name.as_ref(); + name.strip_prefix("HEAD -> ") + .unwrap_or(name) + .to_string() + .into() + } + Self::Tag(name) => { + let name = name.as_ref(); + name.strip_prefix("tag: ") + .or_else(|| name.strip_prefix("refs/tags/")) + .unwrap_or(name) + .to_string() + .into() + } + Self::Stash(name) => name.clone(), + } + } + + pub(super) fn branch_lookup_name(&self) -> Option { + match self { + Self::Branch(name) => { + let name = name.as_ref(); + Some( + name.strip_prefix("HEAD -> ") + .unwrap_or(name) + .to_string() + .into(), + ) + } + _ => None, + } + } + + pub(super) fn stash_index(&self) -> Option { + match self { + Self::Stash(name) => { + let name = name.as_ref(); + if let Some(start) = name.find("stash@{") { + let rest = &name[start + 7..]; + rest.strip_suffix('}')?.parse::().ok() + } else { + Some(0) + } + } + _ => None, + } + } +} + +#[derive(Clone)] +pub(super) struct CommitContextMenuState { + pub(super) row_index: usize, +} + +#[derive(Clone, Copy)] +pub(super) enum ResetPromptMode { + Soft, + Mixed, + Hard, +} + +impl ResetPromptMode { + pub(super) const ALL: [Self; 3] = [Self::Soft, Self::Mixed, Self::Hard]; + + pub(super) fn to_reset_mode(self) -> ResetMode { + match self { + Self::Soft => ResetMode::Soft, + Self::Mixed => ResetMode::Mixed, + Self::Hard => ResetMode::Hard, + } + } + + pub(super) fn label(self) -> &'static str { + match self { + Self::Soft => "Soft", + Self::Mixed => "Mixed", + Self::Hard => "Hard", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct BranchPushTarget { + pub(super) branch: Branch, + pub(super) remote: Remote, + pub(super) remote_branch_name: SharedString, + pub(super) options: Option, +} + +#[derive(Clone, Debug)] +pub(super) struct PushBranchDialogState { + pub(super) branch: Branch, + pub(super) available_remotes: Vec, + pub(super) selected_remote: SharedString, + pub(super) set_upstream: bool, + pub(super) push_mode: PushMode, +} + +impl PushBranchDialogState { + pub(super) fn new( + branch: Branch, + available_remotes: Vec, + ) -> anyhow::Result { + let selected_remote = Self::default_remote_name(&branch, &available_remotes)?; + let set_upstream = Self::default_set_upstream(&branch, selected_remote.as_ref()); + + Ok(Self { + branch, + available_remotes, + selected_remote, + set_upstream, + push_mode: PushMode::Normal, + }) + } + + fn default_remote_name( + branch: &Branch, + available_remotes: &[SharedString], + ) -> anyhow::Result { + if let Some(remote_name) = Self::tracked_upstream_remote_name(branch) + && let Some(remote) = available_remotes + .iter() + .find(|remote| remote.as_ref() == remote_name) + { + return Ok(remote.clone()); + } + + available_remotes + .first() + .cloned() + .ok_or_else(|| anyhow::anyhow!("No remote configured for repository")) + } + + fn tracked_upstream_remote_name(branch: &Branch) -> Option<&str> { + branch + .upstream + .as_ref() + .filter(|upstream| matches!(upstream.tracking, UpstreamTracking::Tracked(_))) + .and_then(|upstream| upstream.remote_name()) + } + + fn tracked_upstream_branch_name(branch: &Branch) -> Option<&str> { + branch + .upstream + .as_ref() + .filter(|upstream| matches!(upstream.tracking, UpstreamTracking::Tracked(_))) + .and_then(|upstream| upstream.branch_name()) + } + + fn default_set_upstream(branch: &Branch, selected_remote: &str) -> bool { + Self::tracked_upstream_remote_name(branch) != Some(selected_remote) + } + + pub(super) fn select_remote(&mut self, remote_name: SharedString) { + self.selected_remote = remote_name; + self.set_upstream = Self::default_set_upstream(&self.branch, self.selected_remote.as_ref()); + } + + pub(super) fn push_target(&self) -> BranchPushTarget { + let remote_branch_name = if Self::tracked_upstream_remote_name(&self.branch) + == Some(self.selected_remote.as_ref()) + { + Self::tracked_upstream_branch_name(&self.branch) + .unwrap_or_else(|| self.branch.name()) + .to_string() + .into() + } else { + self.branch.name().to_string().into() + }; + + let options = match (self.set_upstream, self.push_mode) { + (false, PushMode::Normal) => None, + (set_upstream, push_mode) => Some(PushOptions { + set_upstream, + push_mode, + }), + }; + + BranchPushTarget { + branch: self.branch.clone(), + remote: Remote { + name: self.selected_remote.clone(), + }, + remote_branch_name, + options, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct TagPushTarget { + pub(super) tag_name: SharedString, + pub(super) remote: Remote, +} + +#[derive(Clone, Debug)] +pub(super) struct PushTagDialogState { + pub(super) tag_name: SharedString, + pub(super) available_remotes: Vec, + pub(super) selected_remote: SharedString, +} + +impl PushTagDialogState { + pub(super) fn new( + tag_name: SharedString, + available_remotes: Vec, + ) -> anyhow::Result { + let selected_remote = available_remotes + .first() + .cloned() + .ok_or_else(|| anyhow::anyhow!("No remote configured for repository"))?; + + Ok(Self { + tag_name, + available_remotes, + selected_remote, + }) + } + + pub(super) fn select_remote(&mut self, remote_name: SharedString) { + self.selected_remote = remote_name; + } + + pub(super) fn push_target(&self) -> TagPushTarget { + TagPushTarget { + tag_name: self.tag_name.clone(), + remote: Remote { + name: self.selected_remote.clone(), + }, + } + } +} + +fn update_git_graph( + weak: &WeakEntity, + window: &mut Window, + cx: &mut App, + update: impl FnOnce(&mut GitGraph, &mut Window, &mut Context), +) { + if let Some(entity) = weak.upgrade() { + entity.update(cx, |this, cx| update(this, window, cx)); + } +} + +fn write_text_to_clipboard(text: SharedString, cx: &mut App) { + cx.write_to_clipboard(ClipboardItem::new_string(text.to_string())); +} + +impl GitGraph { + fn git_task_context(&self, commit_sha: Oid, 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); + } + + 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(); + } + + pub(super) fn deploy_entry_context_menu( + &mut self, + position: Point, + entry_idx: usize, + window: &mut Window, + cx: &mut Context, + ) { + self.commit_context_menu_state = Some(CommitContextMenuState { + row_index: entry_idx, + }); + if let Some(context_menu) = self.build_commit_context_menu(entry_idx, window, cx) { + self.set_context_menu(context_menu, position, entry_idx, window, cx); + } + } + + fn build_commit_context_menu( + &self, + entry_idx: usize, + window: &mut Window, + cx: &mut Context, + ) -> Option> { + let selected_commit = self.commit_info_for_entry(entry_idx, cx)?; + let context_state = self.commit_context_menu_state.as_ref()?; + if context_state.row_index != selected_commit.index { + return None; + } + + let copy_subject_disabled = selected_commit.subject.is_none(); + let commit = self.graph_data.commits.get(entry_idx)?; + let sha = commit.data.sha; + 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 git_tasks = self + .git_task_context(sha, cx) + .map(|task_context| self.git_context_menu_tasks(&task_context, cx)) + .unwrap_or_default(); + + let focus_handle = self.focus_handle.clone(); + let git_graph = cx.entity(); + + Some(ContextMenu::build( + window, + cx, + move |context_menu, window, _| { + context_menu + .context(focus_handle) + .header(format!("Commit {}", selected_commit.sha)) + .entry( + "View Commit", + Some(OpenCommitView.boxed_clone()), + window.handler_for(&git_graph, move |this, window, cx| { + this.open_commit_view(entry_idx, window, cx); + }), + ) + .separator() + .action("Create Tag...", AddTag.boxed_clone()) + .action("Create Branch...", CreateBranchAtCommit.boxed_clone()) + .separator() + .action("Checkout Commit...", CheckoutCommit.boxed_clone()) + .action("Cherry-Pick Commit...", CherryPickCommit.boxed_clone()) + .action("Revert Commit...", RevertCommit.boxed_clone()) + .action("Drop Commit...", DropCommit.boxed_clone()) + .action( + "Merge Commit into Current Branch...", + MergeCommit.boxed_clone(), + ) + .action( + "Rebase Current Branch onto Commit...", + RebaseOntoCommit.boxed_clone(), + ) + .action( + "Reset Current Branch to This Commit...", + ResetCommit.boxed_clone(), + ) + .separator() + .action("Copy Commit Hash", CopyCommitHash.boxed_clone()) + .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(entry_idx, window, cx); + })), + ) + .action_disabled_when( + copy_subject_disabled, + "Copy Commit Subject", + CopyCommitSubject.boxed_clone(), + ) + .when(!git_tasks.is_empty(), |mut menu| { + menu = menu.separator().header("Custom Git Commands"); + + 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 + }) + }, + )) + } + + pub(super) fn set_context_menu( + &mut self, + context_menu: Entity, + position: Point, + entry_idx: usize, + window: &mut Window, + cx: &mut Context, + ) { + window.focus(&context_menu.focus_handle(cx), cx); + + let subscription = cx.subscribe_in( + &context_menu, + window, + |this, _, _: &DismissEvent, window, cx| { + if this.context_menu.as_ref().is_some_and(|context_menu| { + context_menu + .menu + .focus_handle(cx) + .contains_focused(window, cx) + }) { + cx.focus_self(window); + } + this.context_menu.take(); + this.commit_context_menu_state = None; + cx.notify(); + }, + ); + self.context_menu = Some(GitGraphContextMenu { + menu: context_menu, + position, + entry_idx, + _subscription: subscription, + }); + cx.notify(); + } + + pub(super) fn deploy_ref_context_menu( + &mut self, + position: Point, + row_index: usize, + ref_kind: RefNameKind, + window: &mut Window, + cx: &mut Context, + ) { + self.commit_context_menu_state = None; + match &ref_kind { + RefNameKind::Branch(_) => { + self.deploy_branch_context_menu(position, row_index, ref_kind, window, cx); + } + RefNameKind::Tag(_) => { + if let Some(context_menu) = self.build_tag_context_menu(&ref_kind, window, cx) { + self.set_context_menu(context_menu, position, row_index, window, cx); + } + } + RefNameKind::Stash(_) => { + if let Some(context_menu) = self.build_stash_context_menu(&ref_kind, window, cx) { + self.set_context_menu(context_menu, position, row_index, window, cx); + } + } + } + } + + fn deploy_branch_context_menu( + &mut self, + position: Point, + row_index: usize, + ref_kind: RefNameKind, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(context_menu) = self.build_branch_context_menu(ref_kind, window, cx) { + self.set_context_menu(context_menu, position, row_index, window, cx); + } + } + + fn build_branch_context_menu( + &self, + ref_kind: RefNameKind, + window: &mut Window, + cx: &mut Context, + ) -> Option> { + let branch_name = ref_kind + .branch_lookup_name() + .unwrap_or_else(|| ref_kind.display_name()); + let branch = self.resolve_branch_from_snapshot(&ref_kind, cx); + let focus_handle = self.focus_handle.clone(); + let weak = cx.weak_entity(); + let is_remote = branch.as_ref().is_some_and(Branch::is_remote); + let is_cached_branch = branch.is_some(); + + Some(ContextMenu::build(window, cx, { + let branch_name_for_checkout = branch_name.clone(); + let branch_name_for_copy = branch_name.clone(); + let branch_name_for_rename = branch_name.clone(); + let branch_name_for_delete = branch_name.clone(); + let branch_name_for_push = branch_name; + move |context_menu, _, _| { + let context_menu = + context_menu + .context(focus_handle) + .entry("Checkout Branch", None, { + let branch_name = branch_name_for_checkout.clone(); + let weak = weak.clone(); + move |window, cx| { + update_git_graph(&weak, window, cx, |this, window, cx| { + this.checkout_branch(branch_name.to_string(), window, cx); + }); + } + }); + + let context_menu = if is_remote || !is_cached_branch { + context_menu + } else { + context_menu.entry("Rename Branch...", None, { + let branch_name = branch_name_for_rename.clone(); + let weak = weak.clone(); + move |window, cx| { + update_git_graph(&weak, window, cx, |this, window, cx| { + this.rename_branch(branch_name.to_string(), window, cx); + }); + } + }) + }; + + let context_menu = if is_cached_branch { + context_menu.entry( + if is_remote { + "Delete Remote-Tracking Branch..." + } else { + "Delete Branch..." + }, + None, + { + let branch_name = branch_name_for_delete.clone(); + let weak = weak.clone(); + move |window, cx| { + update_git_graph(&weak, window, cx, |this, window, cx| { + this.delete_branch( + branch_name.to_string(), + is_remote, + window, + cx, + ); + }); + } + }, + ) + } else { + context_menu + }; + + let context_menu = if is_remote || !is_cached_branch { + context_menu + } else { + context_menu.entry("Push Branch...", None, { + let branch_name = branch_name_for_push; + let weak = weak.clone(); + move |window, cx| { + update_git_graph(&weak, window, cx, |this, window, cx| { + this.push_branch(branch_name.to_string(), window, cx); + }); + } + }) + }; + + context_menu + .separator() + .entry("Merge Branch into Current Branch...", None, { + let weak = weak.clone(); + move |window, cx| { + update_git_graph(&weak, window, cx, |this, window, cx| { + this.merge_context_menu_commit(window, cx); + }); + } + }) + .entry("Rebase Current Branch onto Branch...", None, { + let weak = weak.clone(); + move |window, cx| { + update_git_graph(&weak, window, cx, |this, window, cx| { + this.rebase_context_menu_commit(window, cx); + }); + } + }) + .separator() + .action("Copy Branch HEAD Hash", CopyCommitHash.boxed_clone()) + .entry("Copy Branch Name", None, { + let name = branch_name_for_copy; + move |_window, cx| { + write_text_to_clipboard(name.clone(), cx); + } + }) + } + })) + } + + fn build_tag_context_menu( + &self, + ref_kind: &RefNameKind, + window: &mut Window, + cx: &mut Context, + ) -> Option> { + let tag_name = ref_kind.display_name(); + let focus_handle = self.focus_handle.clone(); + let weak = cx.weak_entity(); + + Some(ContextMenu::build(window, cx, { + let tag_name_for_delete = tag_name.clone(); + let tag_name_for_copy = tag_name.clone(); + let tag_name_for_push = tag_name; + move |context_menu, _, _| { + context_menu + .context(focus_handle) + .action("Checkout Tag...", CheckoutCommit.boxed_clone()) + .separator() + .entry("Delete Tag...", None, { + let tag_name = tag_name_for_delete.clone(); + let weak = weak.clone(); + move |window, cx| { + update_git_graph(&weak, window, cx, |this, window, cx| { + this.delete_tag(tag_name.to_string(), window, cx); + }); + } + }) + .entry("Push Tag", None, { + let tag_name = tag_name_for_push; + let weak = weak.clone(); + move |window, cx| { + update_git_graph(&weak, window, cx, |this, window, cx| { + this.push_tag(tag_name.to_string(), window, cx); + }); + } + }) + .entry("Create Branch from Tag...", None, { + let weak = weak.clone(); + move |window, cx| { + update_git_graph(&weak, window, cx, |this, window, cx| { + this.show_create_branch_from_tag_modal(window, cx); + }); + } + }) + .separator() + .action("Copy Tagged Commit Hash", CopyCommitHash.boxed_clone()) + .entry("Copy Tag Name", None, { + let name = tag_name_for_copy; + move |_window, cx| { + write_text_to_clipboard(name.clone(), cx); + } + }) + } + })) + } + + fn build_stash_context_menu( + &self, + ref_kind: &RefNameKind, + window: &mut Window, + cx: &mut Context, + ) -> Option> { + let stash_name = ref_kind.display_name(); + let stash_index = ref_kind.stash_index(); + let focus_handle = self.focus_handle.clone(); + let weak = cx.weak_entity(); + + Some(ContextMenu::build(window, cx, { + let stash_name_for_copy = stash_name.clone(); + let stash_name_for_branch = stash_name; + move |context_menu, _, _| { + context_menu + .context(focus_handle) + .entry("Apply Stash", None, { + let weak = weak.clone(); + move |window, cx| { + update_git_graph(&weak, window, cx, |this, window, cx| { + this.apply_stash(stash_index, window, cx); + }); + } + }) + .entry("Pop Stash...", None, { + let weak = weak.clone(); + move |window, cx| { + update_git_graph(&weak, window, cx, |this, window, cx| { + this.pop_stash(stash_index, window, cx); + }); + } + }) + .entry("Drop Stash...", None, { + let weak = weak.clone(); + move |window, cx| { + update_git_graph(&weak, window, cx, |this, window, cx| { + this.drop_stash(stash_index, window, cx); + }); + } + }) + .separator() + .entry("Create Branch from Stash...", None, { + let weak = weak.clone(); + move |window, cx| { + let stash_name = stash_name_for_branch.to_string(); + update_git_graph(&weak, window, cx, |this, window, cx| { + this.show_create_branch_from_stash_modal(stash_name, window, cx); + }); + } + }) + .separator() + .action("Copy Stash Commit Hash", CopyCommitHash.boxed_clone()) + .entry("Copy Stash Name", None, { + let name = stash_name_for_copy; + move |_window, cx| { + write_text_to_clipboard(name.clone(), cx); + } + }) + } + })) + } +} diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index e1ec1eb30f8136..f8ee7908c650ba 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -64,6 +64,18 @@ use workspace::{ }; use zeroize::Zeroize; +mod context_menu; +mod modals; + +use context_menu::{ + BranchPushTarget, CommitContextMenuState, PushBranchDialogState, PushTagDialogState, + RefNameKind, ResetPromptMode, SelectedCommitInfo, TagPushTarget, +}; +use modals::{ + AddTagModal, CherryPickModal, CreateBranchAtCommitModal, DeleteBranchModal, + GitGraphAskPassModal, PushBranchModal, PushTagModal, RenameBranchModal, RevertCommitModal, +}; + const COMMIT_CIRCLE_RADIUS: Pixels = px(3.5); const COMMIT_CIRCLE_STROKE_WIDTH: Pixels = px(1.5); const LANE_WIDTH: Pixels = px(16.0); @@ -342,266 +354,6 @@ struct SearchState { pub selected_index: Option, } -#[derive(Clone)] -struct SelectedCommitInfo { - index: usize, - sha: SharedString, - subject: Option, -} - -#[derive(Clone, Debug)] -enum RefNameKind { - Branch(SharedString), - Tag(SharedString), - Stash(SharedString), -} - -impl RefNameKind { - fn classify(ref_name: &SharedString) -> Self { - let name = ref_name.as_ref(); - if name == "refs/stash" - || name == "stash" - || name.starts_with("stash@{") - || name.contains("refs/stash") - { - Self::Stash(ref_name.clone()) - } else if name.starts_with("tag: ") || name.starts_with("refs/tags/") { - Self::Tag(ref_name.clone()) - } else { - Self::Branch(ref_name.clone()) - } - } - - fn display_name(&self) -> SharedString { - match self { - Self::Branch(name) => { - let name = name.as_ref(); - name.strip_prefix("HEAD -> ") - .unwrap_or(name) - .to_string() - .into() - } - Self::Tag(name) => { - let name = name.as_ref(); - name.strip_prefix("tag: ") - .or_else(|| name.strip_prefix("refs/tags/")) - .unwrap_or(name) - .to_string() - .into() - } - Self::Stash(name) => name.clone(), - } - } - - fn branch_lookup_name(&self) -> Option { - match self { - Self::Branch(name) => { - let name = name.as_ref(); - Some( - name.strip_prefix("HEAD -> ") - .unwrap_or(name) - .to_string() - .into(), - ) - } - _ => None, - } - } - - fn stash_index(&self) -> Option { - match self { - Self::Stash(name) => { - let name = name.as_ref(); - if let Some(start) = name.find("stash@{") { - let rest = &name[start + 7..]; - rest.strip_suffix('}')?.parse::().ok() - } else { - Some(0) - } - } - _ => None, - } - } -} - -#[derive(Clone)] -struct CommitContextMenuState { - row_index: usize, -} - -#[derive(Clone, Copy)] -enum ResetPromptMode { - Soft, - Mixed, - Hard, -} - -impl ResetPromptMode { - const ALL: [Self; 3] = [Self::Soft, Self::Mixed, Self::Hard]; - - fn to_reset_mode(self) -> ResetMode { - match self { - Self::Soft => ResetMode::Soft, - Self::Mixed => ResetMode::Mixed, - Self::Hard => ResetMode::Hard, - } - } - - fn label(self) -> &'static str { - match self { - Self::Soft => "Soft", - Self::Mixed => "Mixed", - Self::Hard => "Hard", - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct BranchPushTarget { - branch: Branch, - remote: Remote, - remote_branch_name: SharedString, - options: Option, -} - -#[derive(Clone, Debug)] -struct PushBranchDialogState { - branch: Branch, - available_remotes: Vec, - selected_remote: SharedString, - set_upstream: bool, - push_mode: PushMode, -} - -impl PushBranchDialogState { - fn new(branch: Branch, available_remotes: Vec) -> anyhow::Result { - let selected_remote = Self::default_remote_name(&branch, &available_remotes)?; - let set_upstream = Self::default_set_upstream(&branch, selected_remote.as_ref()); - - Ok(Self { - branch, - available_remotes, - selected_remote, - set_upstream, - push_mode: PushMode::Normal, - }) - } - - fn default_remote_name( - branch: &Branch, - available_remotes: &[SharedString], - ) -> anyhow::Result { - if let Some(remote_name) = Self::tracked_upstream_remote_name(branch) - && let Some(remote) = available_remotes - .iter() - .find(|remote| remote.as_ref() == remote_name) - { - return Ok(remote.clone()); - } - - available_remotes - .first() - .cloned() - .ok_or_else(|| anyhow::anyhow!("No remote configured for repository")) - } - - fn tracked_upstream_remote_name(branch: &Branch) -> Option<&str> { - branch - .upstream - .as_ref() - .filter(|upstream| matches!(upstream.tracking, UpstreamTracking::Tracked(_))) - .and_then(|upstream| upstream.remote_name()) - } - - fn tracked_upstream_branch_name(branch: &Branch) -> Option<&str> { - branch - .upstream - .as_ref() - .filter(|upstream| matches!(upstream.tracking, UpstreamTracking::Tracked(_))) - .and_then(|upstream| upstream.branch_name()) - } - - fn default_set_upstream(branch: &Branch, selected_remote: &str) -> bool { - Self::tracked_upstream_remote_name(branch) != Some(selected_remote) - } - - fn select_remote(&mut self, remote_name: SharedString) { - self.selected_remote = remote_name; - self.set_upstream = Self::default_set_upstream(&self.branch, self.selected_remote.as_ref()); - } - - fn push_target(&self) -> BranchPushTarget { - let remote_branch_name = if Self::tracked_upstream_remote_name(&self.branch) - == Some(self.selected_remote.as_ref()) - { - Self::tracked_upstream_branch_name(&self.branch) - .unwrap_or_else(|| self.branch.name()) - .to_string() - .into() - } else { - self.branch.name().to_string().into() - }; - - let options = match (self.set_upstream, self.push_mode) { - (false, PushMode::Normal) => None, - (set_upstream, push_mode) => Some(PushOptions { - set_upstream, - push_mode, - }), - }; - - BranchPushTarget { - branch: self.branch.clone(), - remote: Remote { - name: self.selected_remote.clone(), - }, - remote_branch_name, - options, - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct TagPushTarget { - tag_name: SharedString, - remote: Remote, -} - -#[derive(Clone, Debug)] -struct PushTagDialogState { - tag_name: SharedString, - available_remotes: Vec, - selected_remote: SharedString, -} - -impl PushTagDialogState { - fn new(tag_name: SharedString, available_remotes: Vec) -> anyhow::Result { - let selected_remote = available_remotes - .first() - .cloned() - .ok_or_else(|| anyhow::anyhow!("No remote configured for repository"))?; - - Ok(Self { - tag_name, - available_remotes, - selected_remote, - }) - } - - fn select_remote(&mut self, remote_name: SharedString) { - self.selected_remote = remote_name; - } - - fn push_target(&self) -> TagPushTarget { - TagPushTarget { - tag_name: self.tag_name.clone(), - remote: Remote { - name: self.selected_remote.clone(), - }, - } - } -} - pub struct SplitState { left_ratio: f32, visible_left_ratio: f32, @@ -2523,12 +2275,12 @@ impl GitGraph { cx, ); - cx.spawn_in(window, async move |this, cx| { - if !confirm.await? { - return Ok(()); - } - - this.update_in(cx, |this, window, cx| { + self.run_confirmed_git_operation( + confirm, + "Failed to checkout commit", + window, + cx, + move |this, window, cx| { let sha = commit.sha.to_string(); let repository = repository.clone(); let task = cx.spawn(async move |_, cx| { @@ -2539,13 +2291,8 @@ impl GitGraph { Ok(()) }); this.run_git_operation(task, "Failed to checkout commit", window, cx); - })?; - - Ok(()) - }) - .detach_and_prompt_err("Failed to checkout commit", window, cx, |error, _, _| { - Some(error.to_string()) - }); + }, + ); } fn cherry_pick_context_menu_commit(&mut self, window: &mut Window, cx: &mut Context) { @@ -2665,12 +2412,12 @@ impl GitGraph { cx, ); - cx.spawn_in(window, async move |this, cx| { - if !confirm.await? { - return Ok(()); - } - - this.update_in(cx, |this, window, cx| { + self.run_confirmed_git_operation( + confirm, + "Failed to merge commit", + window, + cx, + move |this, window, cx| { let sha = commit.sha.to_string(); let repository = repository.clone(); let task = cx.spawn(async move |_, cx| { @@ -2681,13 +2428,8 @@ impl GitGraph { Ok(()) }); this.run_git_operation(task, "Failed to merge commit", window, cx); - })?; - - Ok(()) - }) - .detach_and_prompt_err("Failed to merge commit", window, cx, |error, _, _| { - Some(error.to_string()) - }); + }, + ); } fn rebase_context_menu_commit(&mut self, window: &mut Window, cx: &mut Context) { @@ -2707,12 +2449,12 @@ impl GitGraph { cx, ); - cx.spawn_in(window, async move |this, cx| { - if !confirm.await? { - return Ok(()); - } - - this.update_in(cx, |this, window, cx| { + self.run_confirmed_git_operation( + confirm, + "Failed to rebase current branch", + window, + cx, + move |this, window, cx| { let sha = commit.sha.to_string(); let repository = repository.clone(); let task = cx.spawn(async move |_, cx| { @@ -2723,15 +2465,7 @@ impl GitGraph { Ok(()) }); this.run_git_operation(task, "Failed to rebase current branch", window, cx); - })?; - - Ok(()) - }) - .detach_and_prompt_err( - "Failed to rebase current branch", - window, - cx, - |error, _, _| Some(error.to_string()), + }, ); } @@ -2844,203 +2578,6 @@ impl GitGraph { self.copy_commit_tag(selected_entry_index, window, cx); } - fn git_task_context(&self, commit_sha: Oid, 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); - } - - 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, - entry_idx: usize, - window: &mut Window, - cx: &mut Context, - ) { - self.commit_context_menu_state = Some(CommitContextMenuState { - row_index: entry_idx, - }); - if let Some(context_menu) = self.build_commit_context_menu(entry_idx, window, cx) { - self.set_context_menu(context_menu, position, entry_idx, window, cx); - } - } - - fn build_commit_context_menu( - &self, - entry_idx: usize, - window: &mut Window, - cx: &mut Context, - ) -> Option> { - let selected_commit = self.commit_info_for_entry(entry_idx, cx)?; - let context_state = self.commit_context_menu_state.as_ref()?; - if context_state.row_index != selected_commit.index { - return None; - } - - let copy_subject_disabled = selected_commit.subject.is_none(); - let commit = self.graph_data.commits.get(entry_idx)?; - let sha = commit.data.sha; - 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 git_tasks = self - .git_task_context(sha, cx) - .map(|task_context| self.git_context_menu_tasks(&task_context, cx)) - .unwrap_or_default(); - - let focus_handle = self.focus_handle.clone(); - let git_graph = cx.entity(); - - Some(ContextMenu::build( - window, - cx, - move |context_menu, window, _| { - context_menu - .context(focus_handle) - .header(format!("Commit {}", selected_commit.sha)) - .entry( - "View Commit", - Some(OpenCommitView.boxed_clone()), - window.handler_for(&git_graph, move |this, window, cx| { - this.open_commit_view(entry_idx, window, cx); - }), - ) - .separator() - .action("Create Tag...", AddTag.boxed_clone()) - .action("Create Branch...", CreateBranchAtCommit.boxed_clone()) - .separator() - .action("Checkout Commit...", CheckoutCommit.boxed_clone()) - .action("Cherry-Pick Commit...", CherryPickCommit.boxed_clone()) - .action("Revert Commit...", RevertCommit.boxed_clone()) - .action("Drop Commit...", DropCommit.boxed_clone()) - .action( - "Merge Commit into Current Branch...", - MergeCommit.boxed_clone(), - ) - .action( - "Rebase Current Branch onto Commit...", - RebaseOntoCommit.boxed_clone(), - ) - .action( - "Reset Current Branch to This Commit...", - ResetCommit.boxed_clone(), - ) - .separator() - .action("Copy Commit Hash", CopyCommitHash.boxed_clone()) - .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(entry_idx, window, cx); - })), - ) - .action_disabled_when( - copy_subject_disabled, - "Copy Commit Subject", - CopyCommitSubject.boxed_clone(), - ) - .when(!git_tasks.is_empty(), |mut menu| { - menu = menu.separator().header("Custom Git Commands"); - - 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 - }) - }, - )) - } - fn run_git_operation( &mut self, operation: Task>, @@ -3066,347 +2603,28 @@ impl GitGraph { }); } - fn set_context_menu( + fn run_confirmed_git_operation( &mut self, - context_menu: Entity, - position: Point, - entry_idx: usize, + confirm: Task>, + error_message: &'static str, window: &mut Window, cx: &mut Context, + operation: impl FnOnce(&mut Self, &mut Window, &mut Context) + 'static, ) { - window.focus(&context_menu.focus_handle(cx), cx); + cx.spawn_in(window, async move |this, cx| { + if !confirm.await? { + return Ok(()); + } - let subscription = cx.subscribe_in( - &context_menu, - window, - |this, _, _: &DismissEvent, window, cx| { - if this.context_menu.as_ref().is_some_and(|context_menu| { - context_menu - .menu - .focus_handle(cx) - .contains_focused(window, cx) - }) { - cx.focus_self(window); - } - this.context_menu.take(); - this.commit_context_menu_state = None; - cx.notify(); - }, - ); - self.context_menu = Some(GitGraphContextMenu { - menu: context_menu, - position, - entry_idx, - _subscription: subscription, + this.update_in(cx, |this, window, cx| { + operation(this, window, cx); + })?; + + Ok(()) + }) + .detach_and_prompt_err(error_message, window, cx, |error, _, _| { + Some(error.to_string()) }); - cx.notify(); - } - - fn deploy_ref_context_menu( - &mut self, - position: Point, - row_index: usize, - ref_kind: RefNameKind, - window: &mut Window, - cx: &mut Context, - ) { - self.commit_context_menu_state = None; - match &ref_kind { - RefNameKind::Branch(_) => { - self.deploy_branch_context_menu(position, row_index, ref_kind, window, cx); - } - RefNameKind::Tag(_) => { - if let Some(context_menu) = self.build_tag_context_menu(&ref_kind, window, cx) { - self.set_context_menu(context_menu, position, row_index, window, cx); - } - } - RefNameKind::Stash(_) => { - if let Some(context_menu) = self.build_stash_context_menu(&ref_kind, window, cx) { - self.set_context_menu(context_menu, position, row_index, window, cx); - } - } - } - } - - fn deploy_branch_context_menu( - &mut self, - position: Point, - row_index: usize, - ref_kind: RefNameKind, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(context_menu) = self.build_branch_context_menu(ref_kind, window, cx) { - self.set_context_menu(context_menu, position, row_index, window, cx); - } - } - - fn build_branch_context_menu( - &self, - ref_kind: RefNameKind, - window: &mut Window, - cx: &mut Context, - ) -> Option> { - let branch_name = ref_kind - .branch_lookup_name() - .unwrap_or_else(|| ref_kind.display_name()); - let branch = self.resolve_branch_from_snapshot(&ref_kind, cx); - let focus_handle = self.focus_handle.clone(); - let weak = cx.weak_entity(); - let is_remote = branch.as_ref().is_some_and(Branch::is_remote); - let is_cached_branch = branch.is_some(); - - Some(ContextMenu::build(window, cx, { - let branch_name_for_checkout = branch_name.clone(); - let branch_name_for_copy = branch_name.clone(); - let branch_name_for_rename = branch_name.clone(); - let branch_name_for_delete = branch_name.clone(); - let branch_name_for_push = branch_name; - move |context_menu, _, _| { - let context_menu = - context_menu - .context(focus_handle) - .entry("Checkout Branch", None, { - let branch_name = branch_name_for_checkout.clone(); - let weak = weak.clone(); - move |window, cx| { - if let Some(entity) = weak.upgrade() { - entity.update(cx, |this, cx| { - this.checkout_branch(branch_name.to_string(), window, cx); - }); - } - } - }); - - let context_menu = if is_remote || !is_cached_branch { - context_menu - } else { - context_menu.entry("Rename Branch...", None, { - let branch_name = branch_name_for_rename.clone(); - let weak = weak.clone(); - move |window, cx| { - if let Some(entity) = weak.upgrade() { - entity.update(cx, |this, cx| { - this.rename_branch(branch_name.to_string(), window, cx); - }); - } - } - }) - }; - - let context_menu = if is_cached_branch { - context_menu.entry( - if is_remote { - "Delete Remote-Tracking Branch..." - } else { - "Delete Branch..." - }, - None, - { - let branch_name = branch_name_for_delete.clone(); - let weak = weak.clone(); - move |window, cx| { - if let Some(entity) = weak.upgrade() { - entity.update(cx, |this, cx| { - this.delete_branch( - branch_name.to_string(), - is_remote, - window, - cx, - ); - }); - } - } - }, - ) - } else { - context_menu - }; - - let context_menu = if is_remote || !is_cached_branch { - context_menu - } else { - context_menu.entry("Push Branch...", None, { - let branch_name = branch_name_for_push; - let weak = weak.clone(); - move |window, cx| { - if let Some(entity) = weak.upgrade() { - entity.update(cx, |this, cx| { - this.push_branch(branch_name.to_string(), window, cx); - }); - } - } - }) - }; - - context_menu - .separator() - .entry("Merge Branch into Current Branch...", None, { - let weak = weak.clone(); - move |window, cx| { - if let Some(entity) = weak.upgrade() { - entity.update(cx, |this, cx| { - this.merge_context_menu_commit(window, cx); - }); - } - } - }) - .entry("Rebase Current Branch onto Branch...", None, { - let weak = weak.clone(); - move |window, cx| { - if let Some(entity) = weak.upgrade() { - entity.update(cx, |this, cx| { - this.rebase_context_menu_commit(window, cx); - }); - } - } - }) - .separator() - .action("Copy Branch HEAD Hash", CopyCommitHash.boxed_clone()) - .entry("Copy Branch Name", None, { - let name = branch_name_for_copy; - move |_window, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(name.to_string())); - } - }) - } - })) - } - - fn build_tag_context_menu( - &self, - ref_kind: &RefNameKind, - window: &mut Window, - cx: &mut Context, - ) -> Option> { - let tag_name = ref_kind.display_name(); - let focus_handle = self.focus_handle.clone(); - let weak = cx.weak_entity(); - - Some(ContextMenu::build(window, cx, { - let tag_name_for_delete = tag_name.clone(); - let tag_name_for_copy = tag_name.clone(); - let tag_name_for_push = tag_name; - move |context_menu, _, _| { - context_menu - .context(focus_handle) - .action("Checkout Tag...", CheckoutCommit.boxed_clone()) - .separator() - .entry("Delete Tag...", None, { - let tag_name = tag_name_for_delete.clone(); - let weak = weak.clone(); - move |window, cx| { - if let Some(entity) = weak.upgrade() { - entity.update(cx, |this, cx| { - this.delete_tag(tag_name.to_string(), window, cx); - }); - } - } - }) - .entry("Push Tag", None, { - let tag_name = tag_name_for_push; - let weak = weak.clone(); - move |window, cx| { - if let Some(entity) = weak.upgrade() { - entity.update(cx, |this, cx| { - this.push_tag(tag_name.to_string(), window, cx); - }); - } - } - }) - .entry("Create Branch from Tag...", None, { - let weak = weak.clone(); - move |window, cx| { - if let Some(entity) = weak.upgrade() { - entity.update(cx, |this, cx| { - this.show_create_branch_from_tag_modal(window, cx); - }); - } - } - }) - .separator() - .action("Copy Tagged Commit Hash", CopyCommitHash.boxed_clone()) - .entry("Copy Tag Name", None, { - let name = tag_name_for_copy; - move |_window, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(name.to_string())); - } - }) - } - })) - } - - fn build_stash_context_menu( - &self, - ref_kind: &RefNameKind, - window: &mut Window, - cx: &mut Context, - ) -> Option> { - let stash_name = ref_kind.display_name(); - let stash_index = ref_kind.stash_index(); - let focus_handle = self.focus_handle.clone(); - let weak = cx.weak_entity(); - - Some(ContextMenu::build(window, cx, { - let stash_name_for_copy = stash_name.clone(); - let stash_name_for_branch = stash_name; - move |context_menu, _, _| { - context_menu - .context(focus_handle) - .entry("Apply Stash", None, { - let weak = weak.clone(); - move |window, cx| { - if let Some(entity) = weak.upgrade() { - entity.update(cx, |this, cx| { - this.apply_stash(stash_index, window, cx); - }); - } - } - }) - .entry("Pop Stash...", None, { - let weak = weak.clone(); - move |window, cx| { - if let Some(entity) = weak.upgrade() { - entity.update(cx, |this, cx| { - this.pop_stash(stash_index, window, cx); - }); - } - } - }) - .entry("Drop Stash...", None, { - let weak = weak.clone(); - move |window, cx| { - if let Some(entity) = weak.upgrade() { - entity.update(cx, |this, cx| { - this.drop_stash(stash_index, window, cx); - }); - } - } - }) - .separator() - .entry("Create Branch from Stash...", None, { - let weak = weak.clone(); - move |window, cx| { - if let Some(entity) = weak.upgrade() { - let stash_name = stash_name_for_branch.to_string(); - entity.update(cx, |this, cx| { - this.show_create_branch_from_stash_modal( - stash_name, window, cx, - ); - }); - } - } - }) - .separator() - .action("Copy Stash Commit Hash", CopyCommitHash.boxed_clone()) - .entry("Copy Stash Name", None, { - let name = stash_name_for_copy; - move |_window, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(name.to_string())); - } - }) - } - })) } fn askpass_delegate( @@ -4968,1175 +4186,6 @@ impl GitGraph { } } -struct CreateBranchAtCommitModal { - graph: WeakEntity, - repository: Entity, - commit_sha: SharedString, - title: SharedString, - editor: Entity, - checkout_after_create: bool, -} - -impl CreateBranchAtCommitModal { - fn new( - graph: WeakEntity, - repository: Entity, - commit_sha: SharedString, - initial_name: Option, - title: SharedString, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - if let Some(initial_name) = initial_name.clone() { - editor.set_text(initial_name, window, cx); - } else { - editor.set_placeholder_text("Enter branch name...", window, cx); - } - editor - }); - - Self { - graph, - repository, - commit_sha, - title, - editor, - checkout_after_create: false, - } - } - - fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { - cx.emit(DismissEvent); - } - - fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { - let branch_name = self.editor.read(cx).text(cx).trim().replace(' ', "-"); - if branch_name.is_empty() { - return; - } - - let repository = self.repository.clone(); - let graph = self.graph.clone(); - let commit_sha = self.commit_sha.to_string(); - let checkout_after_create = self.checkout_after_create; - - cx.spawn(async move |_, cx| { - repository - .update(cx, |repository, _| { - repository.create_branch_at(commit_sha, branch_name.clone()) - }) - .await??; - - if checkout_after_create { - repository - .update(cx, |repository, _| repository.change_branch(branch_name)) - .await??; - } - - let _ = graph.update(cx, |graph, cx| { - graph.reload_graph(cx); - }); - - Ok(()) - }) - .detach_and_prompt_err("Failed to create branch", window, cx, |error, _, _| { - Some(error.to_string()) - }); - - cx.emit(DismissEvent); - } -} - -impl EventEmitter for CreateBranchAtCommitModal {} -impl ModalView for CreateBranchAtCommitModal {} -impl Focusable for CreateBranchAtCommitModal { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.editor.focus_handle(cx) - } -} - -impl Render for CreateBranchAtCommitModal { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .key_context("CreateBranchAtCommitModal") - .on_action(cx.listener(Self::cancel)) - .on_action(cx.listener(Self::confirm)) - .elevation_2(cx) - .w(ui::rems(34.)) - .child( - h_flex() - .px_3() - .pt_2() - .pb_1() - .gap_1p5() - .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall)) - .child(Label::new(self.title.clone())), - ) - .child( - v_flex() - .px_3() - .pb_3() - .w_full() - .gap_2() - .child(self.editor.clone()) - .child( - Checkbox::new( - "create-branch-checkout-after-create", - if self.checkout_after_create { - ToggleState::Selected - } else { - ToggleState::Unselected - }, - ) - .label("Checkout after create") - .label_size(LabelSize::Small) - .on_click(cx.listener( - |this: &mut CreateBranchAtCommitModal, _, _window, cx| { - this.checkout_after_create = !this.checkout_after_create; - cx.notify(); - }, - )), - ), - ) - } -} - -struct CherryPickModal { - graph: WeakEntity, - repository: Entity, - commit_sha: SharedString, - record_origin: bool, - no_commit: bool, - focus_handle: FocusHandle, -} - -impl CherryPickModal { - fn new( - graph: WeakEntity, - repository: Entity, - commit_sha: SharedString, - _window: &mut Window, - cx: &mut Context, - ) -> Self { - Self { - graph, - repository, - commit_sha, - record_origin: false, - no_commit: false, - focus_handle: cx.focus_handle(), - } - } - - fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { - cx.emit(DismissEvent); - } - - fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { - let repository = self.repository.clone(); - let graph = self.graph.clone(); - let sha = self.commit_sha.to_string(); - let record_origin = self.record_origin; - let no_commit = self.no_commit; - - cx.spawn(async move |_, cx| { - repository - .update(cx, |repository, _| { - repository.cherry_pick(sha, record_origin, no_commit) - }) - .await??; - - let _ = graph.update(cx, |graph, cx| { - graph.reload_graph(cx); - }); - - Ok(()) - }) - .detach_and_prompt_err( - "Failed to cherry-pick commit", - window, - cx, - |error, _, _| Some(error.to_string()), - ); - - cx.emit(DismissEvent); - } -} - -impl EventEmitter for CherryPickModal {} -impl ModalView for CherryPickModal {} -impl Focusable for CherryPickModal { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for CherryPickModal { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .key_context("CherryPickModal") - .track_focus(&self.focus_handle) - .on_action(cx.listener(Self::cancel)) - .on_action(cx.listener(Self::confirm)) - .elevation_2(cx) - .w(ui::rems(34.)) - .child( - h_flex() - .px_3() - .pt_2() - .pb_1() - .gap_1p5() - .child(Icon::new(IconName::GitCommit).size(IconSize::XSmall)) - .child(Label::new(format!("Cherry Pick {}", self.commit_sha))), - ) - .child( - v_flex() - .px_3() - .pb_2() - .gap_1() - .child( - Checkbox::new( - "cherry-pick-record-origin", - if self.record_origin { - ToggleState::Selected - } else { - ToggleState::Unselected - }, - ) - .label("Record origin (-x)") - .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, _window, cx| { - this.record_origin = !this.record_origin; - cx.notify(); - })), - ) - .child( - Checkbox::new( - "cherry-pick-no-commit", - if self.no_commit { - ToggleState::Selected - } else { - ToggleState::Unselected - }, - ) - .label("No commit (--no-commit)") - .label_size(LabelSize::Small) - .on_click(cx.listener(|this, _, _window, cx| { - this.no_commit = !this.no_commit; - cx.notify(); - })), - ), - ) - .child( - h_flex() - .px_3() - .pb_3() - .gap_2() - .justify_end() - .child( - Button::new("cherry-pick-cancel", "Cancel") - .style(ButtonStyle::Subtle) - .on_click(cx.listener(|this, _, window, cx| { - this.cancel(&Cancel, window, cx); - })), - ) - .child( - Button::new("cherry-pick-confirm", "Cherry Pick") - .style(ButtonStyle::Filled) - .on_click(cx.listener(|this, _, window, cx| { - this.confirm(&Confirm, window, cx); - })), - ), - ) - } -} - -struct AddTagModal { - graph: WeakEntity, - repository: Entity, - commit_sha: SharedString, - name_editor: Entity, - message_editor: Entity, -} - -impl AddTagModal { - fn new( - graph: WeakEntity, - repository: Entity, - commit_sha: SharedString, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let name_editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Enter tag name...", window, cx); - editor - }); - let message_editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_placeholder_text("Optional tag message...", window, cx); - editor - }); - - Self { - graph, - repository, - commit_sha, - name_editor, - message_editor, - } - } - - fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { - cx.emit(DismissEvent); - } - - fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { - let tag_name = self.name_editor.read(cx).text(cx).trim().to_string(); - if tag_name.is_empty() { - return; - } - - let tag_message = self.message_editor.read(cx).text(cx).trim().to_string(); - let repository = self.repository.clone(); - let graph = self.graph.clone(); - let commit_sha = self.commit_sha.to_string(); - - cx.spawn(async move |_, cx| { - repository - .update(cx, |repository, _| { - repository.create_tag( - commit_sha, - tag_name, - (!tag_message.is_empty()).then_some(tag_message), - ) - }) - .await??; - - let _ = graph.update(cx, |graph, cx| { - graph.reload_graph(cx); - }); - - Ok(()) - }) - .detach_and_prompt_err("Failed to add tag", window, cx, |error, _, _| { - Some(error.to_string()) - }); - - cx.emit(DismissEvent); - } -} - -impl EventEmitter for AddTagModal {} -impl ModalView for AddTagModal {} -impl Focusable for AddTagModal { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.name_editor.focus_handle(cx) - } -} - -impl Render for AddTagModal { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .key_context("AddTagModal") - .on_action(cx.listener(Self::cancel)) - .on_action(cx.listener(Self::confirm)) - .elevation_2(cx) - .w(ui::rems(34.)) - .child( - h_flex() - .px_3() - .pt_2() - .pb_1() - .gap_1p5() - .child(Icon::new(IconName::GitCommit).size(IconSize::XSmall)) - .child(Label::new(format!("Add Tag at {}", self.commit_sha))), - ) - .child( - v_flex() - .px_3() - .pb_3() - .w_full() - .gap_2() - .child(self.name_editor.clone()) - .child(self.message_editor.clone()), - ) - } -} - -struct RenameBranchModal { - graph: WeakEntity, - repository: Entity, - branch_name: SharedString, - editor: Entity, -} - -impl RenameBranchModal { - fn new( - branch_name: String, - repository: Entity, - graph: WeakEntity, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_text(branch_name.clone(), window, cx); - editor - }); - Self { - graph, - repository, - branch_name: branch_name.into(), - editor, - } - } - - fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { - cx.emit(DismissEvent); - } - - fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { - let new_name = self.editor.read(cx).text(cx); - if new_name.is_empty() || new_name == self.branch_name.as_ref() { - cx.emit(DismissEvent); - return; - } - - let repository = self.repository.clone(); - let graph = self.graph.clone(); - let old_name = self.branch_name.to_string(); - - cx.spawn(async move |_, cx| { - repository - .update(cx, |repository, _| { - repository.rename_branch(old_name.clone(), new_name.clone()) - }) - .await??; - - let _ = graph.update(cx, |graph, cx| { - graph.reload_graph(cx); - }); - - Ok(()) - }) - .detach_and_prompt_err("Failed to rename branch", window, cx, |error, _, _| { - Some(error.to_string()) - }); - - cx.emit(DismissEvent); - } -} - -impl EventEmitter for RenameBranchModal {} -impl ModalView for RenameBranchModal {} -impl Focusable for RenameBranchModal { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.editor.focus_handle(cx) - } -} - -impl Render for RenameBranchModal { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .key_context("RenameBranchModal") - .on_action(cx.listener(Self::cancel)) - .on_action(cx.listener(Self::confirm)) - .elevation_2(cx) - .w(ui::rems(34.)) - .child( - h_flex() - .px_3() - .pt_2() - .pb_1() - .gap_1p5() - .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall)) - .child(Label::new(format!("Rename Branch ({})", self.branch_name))), - ) - .child(div().px_3().pb_3().w_full().child(self.editor.clone())) - } -} - -struct PushBranchModal { - graph: WeakEntity, - state: PushBranchDialogState, - focus_handle: FocusHandle, -} - -impl PushBranchModal { - fn new( - graph: WeakEntity, - state: PushBranchDialogState, - _window: &mut Window, - cx: &mut Context, - ) -> Self { - Self { - graph, - state, - focus_handle: cx.focus_handle(), - } - } - - fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { - cx.emit(DismissEvent); - } - - fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { - let target = self.state.push_target(); - if let Some(graph) = self.graph.upgrade() { - graph.update(cx, |graph, cx| { - graph.perform_push_branch(target, window, cx); - }); - } - - cx.emit(DismissEvent); - } - - fn render_remote_dropdown(&self, window: &mut Window, cx: &mut Context) -> DropdownMenu { - let weak = cx.weak_entity(); - let remotes = self.state.available_remotes.clone(); - let menu = ContextMenu::build(window, cx, move |mut menu, _, _| { - for remote_name in remotes.clone() { - let weak = weak.clone(); - menu = menu.entry(remote_name.clone(), None, move |_window, cx| { - if let Some(entity) = weak.upgrade() { - entity.update(cx, |this, cx| { - this.state.select_remote(remote_name.clone()); - cx.notify(); - }); - } - }); - } - menu - }); - - DropdownMenu::new( - "push-branch-remote-dropdown", - self.state.selected_remote.clone(), - menu, - ) - .style(DropdownStyle::Outlined) - .full_width(true) - } - - fn render_push_mode_option( - &self, - id: &'static str, - label: &'static str, - push_mode: PushMode, - cx: &mut Context, - ) -> impl IntoElement { - Checkbox::new( - id, - if self.state.push_mode == push_mode { - ToggleState::Selected - } else { - ToggleState::Unselected - }, - ) - .label(label) - .label_size(LabelSize::Small) - .on_click( - cx.listener(move |this: &mut PushBranchModal, _, _window, cx| { - this.state.push_mode = push_mode; - cx.notify(); - }), - ) - } -} - -impl EventEmitter for PushBranchModal {} -impl ModalView for PushBranchModal {} -impl Focusable for PushBranchModal { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for PushBranchModal { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .key_context("PushBranchModal") - .track_focus(&self.focus_handle) - .on_action(cx.listener(Self::cancel)) - .on_action(cx.listener(Self::confirm)) - .elevation_2(cx) - .w(ui::rems(36.)) - .child( - h_flex() - .px_3() - .pt_2() - .pb_1() - .gap_1p5() - .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall)) - .child(Label::new(format!( - "Push Branch ({})", - self.state.branch.name() - ))), - ) - .child( - v_flex() - .px_3() - .pb_3() - .w_full() - .gap_3() - .child( - v_flex() - .gap_1() - .child(Label::new("Push to Remote(s):").size(LabelSize::Small)) - .child(self.render_remote_dropdown(window, cx)), - ) - .child( - Checkbox::new( - "push-branch-set-upstream", - if self.state.set_upstream { - ToggleState::Selected - } else { - ToggleState::Unselected - }, - ) - .label("Set Upstream") - .label_size(LabelSize::Small) - .on_click(cx.listener( - |this: &mut PushBranchModal, _, _window, cx| { - this.state.set_upstream = !this.state.set_upstream; - cx.notify(); - }, - )), - ) - .child( - v_flex() - .gap_1() - .child(Label::new("Push Mode:").size(LabelSize::Small)) - .child( - v_flex() - .gap_1() - .child(self.render_push_mode_option( - "push-branch-mode-normal", - "Normal", - PushMode::Normal, - cx, - )) - .child(self.render_push_mode_option( - "push-branch-mode-force-with-lease", - "Force With Lease", - PushMode::ForceWithLease, - cx, - )) - .child(self.render_push_mode_option( - "push-branch-mode-force", - "Force", - PushMode::Force, - cx, - )), - ), - ) - .child( - h_flex() - .justify_end() - .gap_2() - .child( - Button::new("push-branch-cancel", "Cancel") - .style(ButtonStyle::Subtle) - .on_click(cx.listener( - |this: &mut PushBranchModal, _, window, cx| { - this.cancel(&Cancel, window, cx); - }, - )), - ) - .child( - Button::new("push-branch-confirm", "Push") - .style(ButtonStyle::Filled) - .on_click(cx.listener( - |this: &mut PushBranchModal, _, window, cx| { - this.confirm(&Confirm, window, cx); - }, - )), - ), - ), - ) - } -} - -struct PushTagModal { - graph: WeakEntity, - state: PushTagDialogState, - focus_handle: FocusHandle, -} - -impl PushTagModal { - fn new( - graph: WeakEntity, - state: PushTagDialogState, - _window: &mut Window, - cx: &mut Context, - ) -> Self { - Self { - graph, - state, - focus_handle: cx.focus_handle(), - } - } - - fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { - cx.emit(DismissEvent); - } - - fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { - let target = self.state.push_target(); - if let Some(graph) = self.graph.upgrade() { - graph.update(cx, |graph, cx| { - graph.perform_push_tag(target, window, cx); - }); - } - - cx.emit(DismissEvent); - } - - fn render_remote_dropdown(&self, window: &mut Window, cx: &mut Context) -> DropdownMenu { - let weak = cx.weak_entity(); - let remotes = self.state.available_remotes.clone(); - let menu = ContextMenu::build(window, cx, move |mut menu, _, _| { - for remote_name in remotes.clone() { - let weak = weak.clone(); - menu = menu.entry(remote_name.clone(), None, move |_window, cx| { - if let Some(entity) = weak.upgrade() { - entity.update(cx, |this, cx| { - this.state.select_remote(remote_name.clone()); - cx.notify(); - }); - } - }); - } - menu - }); - - DropdownMenu::new( - "push-tag-remote-dropdown", - self.state.selected_remote.clone(), - menu, - ) - .style(DropdownStyle::Outlined) - .full_width(true) - } -} - -impl EventEmitter for PushTagModal {} -impl ModalView for PushTagModal {} -impl Focusable for PushTagModal { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for PushTagModal { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .key_context("PushTagModal") - .track_focus(&self.focus_handle) - .on_action(cx.listener(Self::cancel)) - .on_action(cx.listener(Self::confirm)) - .elevation_2(cx) - .w(ui::rems(34.)) - .child( - h_flex() - .px_3() - .pt_2() - .pb_1() - .gap_1p5() - .child(Icon::new(IconName::GitCommit).size(IconSize::XSmall)) - .child(Label::new(format!("Push Tag ({})", self.state.tag_name))), - ) - .child( - v_flex() - .px_3() - .pb_3() - .w_full() - .gap_3() - .child( - v_flex() - .gap_1() - .child(Label::new("Push to Remote:").size(LabelSize::Small)) - .child(self.render_remote_dropdown(window, cx)), - ) - .child( - h_flex() - .justify_end() - .gap_2() - .child( - Button::new("push-tag-cancel", "Cancel") - .style(ButtonStyle::Subtle) - .on_click(cx.listener( - |this: &mut PushTagModal, _, window, cx| { - this.cancel(&Cancel, window, cx); - }, - )), - ) - .child( - Button::new("push-tag-confirm", "Push") - .style(ButtonStyle::Filled) - .on_click(cx.listener( - |this: &mut PushTagModal, _, window, cx| { - this.confirm(&Confirm, window, cx); - }, - )), - ), - ), - ) - } -} - -struct DeleteBranchModal { - graph: WeakEntity, - branch_name: SharedString, - is_remote: bool, - force_delete: bool, - focus_handle: FocusHandle, -} - -impl DeleteBranchModal { - fn new( - graph: WeakEntity, - branch_name: String, - is_remote: bool, - _window: &mut Window, - cx: &mut Context, - ) -> Self { - Self { - graph, - branch_name: branch_name.into(), - is_remote, - force_delete: false, - focus_handle: cx.focus_handle(), - } - } - - fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { - cx.emit(DismissEvent); - } - - fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { - if let Some(graph) = self.graph.upgrade() { - let branch_name = self.branch_name.to_string(); - let is_remote = self.is_remote; - let force_delete = self.force_delete; - graph.update(cx, |graph, cx| { - graph.perform_delete_branch(branch_name, is_remote, force_delete, window, cx); - }); - } - - cx.emit(DismissEvent); - } -} - -impl EventEmitter for DeleteBranchModal {} -impl ModalView for DeleteBranchModal {} -impl Focusable for DeleteBranchModal { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for DeleteBranchModal { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .key_context("DeleteBranchModal") - .track_focus(&self.focus_handle) - .on_action(cx.listener(Self::cancel)) - .on_action(cx.listener(Self::confirm)) - .elevation_2(cx) - .w(ui::rems(34.)) - .child( - h_flex() - .px_3() - .pt_2() - .pb_1() - .gap_1p5() - .child(Icon::new(IconName::Trash).size(IconSize::XSmall)) - .child(Label::new(if self.is_remote { - format!("Delete Remote-Tracking Branch ({})", self.branch_name) - } else { - format!("Delete Branch ({})", self.branch_name) - })), - ) - .child( - v_flex() - .px_3() - .pb_3() - .w_full() - .gap_2() - .child(Label::new("This cannot be undone.")) - .when(!self.is_remote, |this| { - this.child( - Checkbox::new( - "delete-branch-force-delete", - if self.force_delete { - ToggleState::Selected - } else { - ToggleState::Unselected - }, - ) - .label("Force delete") - .label_size(LabelSize::Small) - .on_click(cx.listener( - |this: &mut DeleteBranchModal, _, _window, cx| { - this.force_delete = !this.force_delete; - cx.notify(); - }, - )), - ) - }) - .child( - h_flex() - .justify_end() - .gap_2() - .child( - Button::new("delete-branch-cancel", "Cancel") - .style(ButtonStyle::Subtle) - .on_click(cx.listener( - |this: &mut DeleteBranchModal, _, window, cx| { - this.cancel(&Cancel, window, cx); - }, - )), - ) - .child( - Button::new("delete-branch-confirm", "Delete") - .style(ButtonStyle::Filled) - .on_click(cx.listener( - |this: &mut DeleteBranchModal, _, window, cx| { - this.confirm(&Confirm, window, cx); - }, - )), - ), - ), - ) - } -} - -struct RevertCommitModal { - graph: WeakEntity, - repository: Entity, - commit_sha: SharedString, - no_commit: bool, - focus_handle: FocusHandle, -} - -impl RevertCommitModal { - fn new( - graph: WeakEntity, - repository: Entity, - commit_sha: SharedString, - _window: &mut Window, - cx: &mut Context, - ) -> Self { - Self { - graph, - repository, - commit_sha, - no_commit: false, - focus_handle: cx.focus_handle(), - } - } - - fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { - cx.emit(DismissEvent); - } - - fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { - let repository = self.repository.clone(); - let graph = self.graph.clone(); - let sha = self.commit_sha.to_string(); - let no_commit = self.no_commit; - - cx.spawn(async move |_, cx| { - repository - .update(cx, |repository, _| repository.revert_commit(sha, no_commit)) - .await??; - - let _ = graph.update(cx, |graph, cx| { - graph.reload_graph(cx); - }); - - Ok(()) - }) - .detach_and_prompt_err("Failed to revert commit", window, cx, |error, _, _| { - Some(error.to_string()) - }); - - cx.emit(DismissEvent); - } -} - -impl EventEmitter for RevertCommitModal {} -impl ModalView for RevertCommitModal {} -impl Focusable for RevertCommitModal { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for RevertCommitModal { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .key_context("RevertCommitModal") - .track_focus(&self.focus_handle) - .on_action(cx.listener(Self::cancel)) - .on_action(cx.listener(Self::confirm)) - .elevation_2(cx) - .w(ui::rems(34.)) - .child( - h_flex() - .px_3() - .pt_2() - .pb_1() - .gap_1p5() - .child(Icon::new(IconName::GitCommit).size(IconSize::XSmall)) - .child(Label::new(format!("Revert Commit {}", self.commit_sha))), - ) - .child( - v_flex() - .px_3() - .pb_3() - .w_full() - .gap_2() - .child( - Checkbox::new( - "revert-commit-no-commit", - if self.no_commit { - ToggleState::Selected - } else { - ToggleState::Unselected - }, - ) - .label("Do not commit (--no-commit)") - .label_size(LabelSize::Small) - .on_click(cx.listener( - |this: &mut RevertCommitModal, _, _window, cx| { - this.no_commit = !this.no_commit; - cx.notify(); - }, - )), - ) - .child( - h_flex() - .justify_end() - .gap_2() - .child( - Button::new("revert-commit-cancel", "Cancel") - .style(ButtonStyle::Subtle) - .on_click(cx.listener( - |this: &mut RevertCommitModal, _, window, cx| { - this.cancel(&Cancel, window, cx); - }, - )), - ) - .child( - Button::new("revert-commit-confirm", "Revert") - .style(ButtonStyle::Filled) - .on_click(cx.listener( - |this: &mut RevertCommitModal, _, window, cx| { - this.confirm(&Confirm, window, cx); - }, - )), - ), - ), - ) - } -} - -struct GitGraphAskPassModal { - operation: SharedString, - prompt: SharedString, - editor: Entity, - tx: Option>, -} - -impl GitGraphAskPassModal { - fn new( - operation: SharedString, - prompt: SharedString, - tx: oneshot::Sender, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - if prompt.contains("yes/no") || prompt.contains("Username") { - editor.set_masked(false, cx); - } else { - editor.set_masked(true, cx); - } - editor - }); - - Self { - operation, - prompt, - editor, - tx: Some(tx), - } - } - - fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { - cx.emit(DismissEvent); - } - - fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { - if let Some(tx) = self.tx.take() { - let mut text = self.editor.update(cx, |editor, cx| { - let text = editor.text(cx); - editor.clear(window, cx); - text - }); - if let Ok(password) = EncryptedPassword::try_from(text.as_ref()) { - tx.send(password).ok(); - } - text.zeroize(); - } - - cx.emit(DismissEvent); - } -} - -impl EventEmitter for GitGraphAskPassModal {} -impl ModalView for GitGraphAskPassModal {} -impl Focusable for GitGraphAskPassModal { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.editor.focus_handle(cx) - } -} - -impl Render for GitGraphAskPassModal { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .key_context("GitGraphAskPassModal") - .on_action(cx.listener(Self::cancel)) - .on_action(cx.listener(Self::confirm)) - .elevation_2(cx) - .w(ui::rems(34.)) - .child( - h_flex() - .px_3() - .pt_2() - .pb_1() - .gap_1p5() - .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall)) - .child(Label::new(self.operation.clone())), - ) - .child( - v_flex() - .px_3() - .pb_3() - .w_full() - .gap_2() - .child(Label::new(self.prompt.clone())) - .child(self.editor.clone()), - ) - } -} - impl Render for GitGraph { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.ref_context_menu_targets.borrow_mut().clear(); diff --git a/crates/git_graph/src/modals.rs b/crates/git_graph/src/modals.rs new file mode 100644 index 00000000000000..77646f5e3e3234 --- /dev/null +++ b/crates/git_graph/src/modals.rs @@ -0,0 +1,1177 @@ +use super::*; + +pub(super) struct CreateBranchAtCommitModal { + graph: WeakEntity, + repository: Entity, + commit_sha: SharedString, + title: SharedString, + editor: Entity, + checkout_after_create: bool, +} + +impl CreateBranchAtCommitModal { + pub(super) fn new( + graph: WeakEntity, + repository: Entity, + commit_sha: SharedString, + initial_name: Option, + title: SharedString, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + if let Some(initial_name) = initial_name.clone() { + editor.set_text(initial_name, window, cx); + } else { + editor.set_placeholder_text("Enter branch name...", window, cx); + } + editor + }); + + Self { + graph, + repository, + commit_sha, + title, + editor, + checkout_after_create: false, + } + } + + fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } + + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let branch_name = self.editor.read(cx).text(cx).trim().replace(' ', "-"); + if branch_name.is_empty() { + return; + } + + let repository = self.repository.clone(); + let graph = self.graph.clone(); + let commit_sha = self.commit_sha.to_string(); + let checkout_after_create = self.checkout_after_create; + + cx.spawn(async move |_, cx| { + repository + .update(cx, |repository, _| { + repository.create_branch_at(commit_sha, branch_name.clone()) + }) + .await??; + + if checkout_after_create { + repository + .update(cx, |repository, _| repository.change_branch(branch_name)) + .await??; + } + + let _ = graph.update(cx, |graph, cx| { + graph.reload_graph(cx); + }); + + Ok(()) + }) + .detach_and_prompt_err("Failed to create branch", window, cx, |error, _, _| { + Some(error.to_string()) + }); + + cx.emit(DismissEvent); + } +} + +impl EventEmitter for CreateBranchAtCommitModal {} +impl ModalView for CreateBranchAtCommitModal {} +impl Focusable for CreateBranchAtCommitModal { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.editor.focus_handle(cx) + } +} + +impl Render for CreateBranchAtCommitModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("CreateBranchAtCommitModal") + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .elevation_2(cx) + .w(ui::rems(34.)) + .child( + h_flex() + .px_3() + .pt_2() + .pb_1() + .gap_1p5() + .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall)) + .child(Label::new(self.title.clone())), + ) + .child( + v_flex() + .px_3() + .pb_3() + .w_full() + .gap_2() + .child(self.editor.clone()) + .child( + Checkbox::new( + "create-branch-checkout-after-create", + if self.checkout_after_create { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .label("Checkout after create") + .label_size(LabelSize::Small) + .on_click(cx.listener( + |this: &mut CreateBranchAtCommitModal, _, _window, cx| { + this.checkout_after_create = !this.checkout_after_create; + cx.notify(); + }, + )), + ), + ) + } +} + +pub(super) struct CherryPickModal { + graph: WeakEntity, + repository: Entity, + commit_sha: SharedString, + record_origin: bool, + no_commit: bool, + focus_handle: FocusHandle, +} + +impl CherryPickModal { + pub(super) fn new( + graph: WeakEntity, + repository: Entity, + commit_sha: SharedString, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + Self { + graph, + repository, + commit_sha, + record_origin: false, + no_commit: false, + focus_handle: cx.focus_handle(), + } + } + + fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } + + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let repository = self.repository.clone(); + let graph = self.graph.clone(); + let sha = self.commit_sha.to_string(); + let record_origin = self.record_origin; + let no_commit = self.no_commit; + + cx.spawn(async move |_, cx| { + repository + .update(cx, |repository, _| { + repository.cherry_pick(sha, record_origin, no_commit) + }) + .await??; + + let _ = graph.update(cx, |graph, cx| { + graph.reload_graph(cx); + }); + + Ok(()) + }) + .detach_and_prompt_err( + "Failed to cherry-pick commit", + window, + cx, + |error, _, _| Some(error.to_string()), + ); + + cx.emit(DismissEvent); + } +} + +impl EventEmitter for CherryPickModal {} +impl ModalView for CherryPickModal {} +impl Focusable for CherryPickModal { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for CherryPickModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("CherryPickModal") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .elevation_2(cx) + .w(ui::rems(34.)) + .child( + h_flex() + .px_3() + .pt_2() + .pb_1() + .gap_1p5() + .child(Icon::new(IconName::GitCommit).size(IconSize::XSmall)) + .child(Label::new(format!("Cherry Pick {}", self.commit_sha))), + ) + .child( + v_flex() + .px_3() + .pb_2() + .gap_1() + .child( + Checkbox::new( + "cherry-pick-record-origin", + if self.record_origin { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .label("Record origin (-x)") + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.record_origin = !this.record_origin; + cx.notify(); + })), + ) + .child( + Checkbox::new( + "cherry-pick-no-commit", + if self.no_commit { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .label("No commit (--no-commit)") + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.no_commit = !this.no_commit; + cx.notify(); + })), + ), + ) + .child( + h_flex() + .px_3() + .pb_3() + .gap_2() + .justify_end() + .child( + Button::new("cherry-pick-cancel", "Cancel") + .style(ButtonStyle::Subtle) + .on_click(cx.listener(|this, _, window, cx| { + this.cancel(&Cancel, window, cx); + })), + ) + .child( + Button::new("cherry-pick-confirm", "Cherry Pick") + .style(ButtonStyle::Filled) + .on_click(cx.listener(|this, _, window, cx| { + this.confirm(&Confirm, window, cx); + })), + ), + ) + } +} + +pub(super) struct AddTagModal { + graph: WeakEntity, + repository: Entity, + commit_sha: SharedString, + name_editor: Entity, + message_editor: Entity, +} + +impl AddTagModal { + pub(super) fn new( + graph: WeakEntity, + repository: Entity, + commit_sha: SharedString, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let name_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Enter tag name...", window, cx); + editor + }); + let message_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Optional tag message...", window, cx); + editor + }); + + Self { + graph, + repository, + commit_sha, + name_editor, + message_editor, + } + } + + fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } + + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let tag_name = self.name_editor.read(cx).text(cx).trim().to_string(); + if tag_name.is_empty() { + return; + } + + let tag_message = self.message_editor.read(cx).text(cx).trim().to_string(); + let repository = self.repository.clone(); + let graph = self.graph.clone(); + let commit_sha = self.commit_sha.to_string(); + + cx.spawn(async move |_, cx| { + repository + .update(cx, |repository, _| { + repository.create_tag( + commit_sha, + tag_name, + (!tag_message.is_empty()).then_some(tag_message), + ) + }) + .await??; + + let _ = graph.update(cx, |graph, cx| { + graph.reload_graph(cx); + }); + + Ok(()) + }) + .detach_and_prompt_err("Failed to add tag", window, cx, |error, _, _| { + Some(error.to_string()) + }); + + cx.emit(DismissEvent); + } +} + +impl EventEmitter for AddTagModal {} +impl ModalView for AddTagModal {} +impl Focusable for AddTagModal { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.name_editor.focus_handle(cx) + } +} + +impl Render for AddTagModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("AddTagModal") + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .elevation_2(cx) + .w(ui::rems(34.)) + .child( + h_flex() + .px_3() + .pt_2() + .pb_1() + .gap_1p5() + .child(Icon::new(IconName::GitCommit).size(IconSize::XSmall)) + .child(Label::new(format!("Add Tag at {}", self.commit_sha))), + ) + .child( + v_flex() + .px_3() + .pb_3() + .w_full() + .gap_2() + .child(self.name_editor.clone()) + .child(self.message_editor.clone()), + ) + } +} + +pub(super) struct RenameBranchModal { + graph: WeakEntity, + repository: Entity, + branch_name: SharedString, + editor: Entity, +} + +impl RenameBranchModal { + pub(super) fn new( + branch_name: String, + repository: Entity, + graph: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_text(branch_name.clone(), window, cx); + editor + }); + Self { + graph, + repository, + branch_name: branch_name.into(), + editor, + } + } + + fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } + + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let new_name = self.editor.read(cx).text(cx); + if new_name.is_empty() || new_name == self.branch_name.as_ref() { + cx.emit(DismissEvent); + return; + } + + let repository = self.repository.clone(); + let graph = self.graph.clone(); + let old_name = self.branch_name.to_string(); + + cx.spawn(async move |_, cx| { + repository + .update(cx, |repository, _| { + repository.rename_branch(old_name.clone(), new_name.clone()) + }) + .await??; + + let _ = graph.update(cx, |graph, cx| { + graph.reload_graph(cx); + }); + + Ok(()) + }) + .detach_and_prompt_err("Failed to rename branch", window, cx, |error, _, _| { + Some(error.to_string()) + }); + + cx.emit(DismissEvent); + } +} + +impl EventEmitter for RenameBranchModal {} +impl ModalView for RenameBranchModal {} +impl Focusable for RenameBranchModal { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.editor.focus_handle(cx) + } +} + +impl Render for RenameBranchModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("RenameBranchModal") + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .elevation_2(cx) + .w(ui::rems(34.)) + .child( + h_flex() + .px_3() + .pt_2() + .pb_1() + .gap_1p5() + .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall)) + .child(Label::new(format!("Rename Branch ({})", self.branch_name))), + ) + .child(div().px_3().pb_3().w_full().child(self.editor.clone())) + } +} + +pub(super) struct PushBranchModal { + graph: WeakEntity, + state: PushBranchDialogState, + focus_handle: FocusHandle, +} + +fn render_remote_dropdown( + id: &'static str, + selected_remote: SharedString, + available_remotes: &[SharedString], + window: &mut Window, + cx: &mut Context, + select_remote: fn(&mut T, SharedString, &mut Context), +) -> DropdownMenu { + let weak = cx.weak_entity(); + let remotes = available_remotes.to_vec(); + let menu = ContextMenu::build(window, cx, move |mut menu, _, _| { + for remote_name in remotes.clone() { + let weak = weak.clone(); + menu = menu.entry(remote_name.clone(), None, move |_window, cx| { + if let Some(entity) = weak.upgrade() { + entity.update(cx, |this, cx| { + select_remote(this, remote_name.clone(), cx); + }); + } + }); + } + menu + }); + + DropdownMenu::new(id, selected_remote, menu) + .style(DropdownStyle::Outlined) + .full_width(true) +} + +impl PushBranchModal { + pub(super) fn new( + graph: WeakEntity, + state: PushBranchDialogState, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + Self { + graph, + state, + focus_handle: cx.focus_handle(), + } + } + + fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } + + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let target = self.state.push_target(); + if let Some(graph) = self.graph.upgrade() { + graph.update(cx, |graph, cx| { + graph.perform_push_branch(target, window, cx); + }); + } + + cx.emit(DismissEvent); + } + + fn select_remote(&mut self, remote_name: SharedString, cx: &mut Context) { + self.state.select_remote(remote_name); + cx.notify(); + } + + fn render_remote_dropdown(&self, window: &mut Window, cx: &mut Context) -> DropdownMenu { + render_remote_dropdown( + "push-branch-remote-dropdown", + self.state.selected_remote.clone(), + &self.state.available_remotes, + window, + cx, + Self::select_remote, + ) + } + + fn render_push_mode_option( + &self, + id: &'static str, + label: &'static str, + push_mode: PushMode, + cx: &mut Context, + ) -> impl IntoElement { + Checkbox::new( + id, + if self.state.push_mode == push_mode { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .label(label) + .label_size(LabelSize::Small) + .on_click( + cx.listener(move |this: &mut PushBranchModal, _, _window, cx| { + this.state.push_mode = push_mode; + cx.notify(); + }), + ) + } +} + +impl EventEmitter for PushBranchModal {} +impl ModalView for PushBranchModal {} +impl Focusable for PushBranchModal { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for PushBranchModal { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("PushBranchModal") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .elevation_2(cx) + .w(ui::rems(36.)) + .child( + h_flex() + .px_3() + .pt_2() + .pb_1() + .gap_1p5() + .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall)) + .child(Label::new(format!( + "Push Branch ({})", + self.state.branch.name() + ))), + ) + .child( + v_flex() + .px_3() + .pb_3() + .w_full() + .gap_3() + .child( + v_flex() + .gap_1() + .child(Label::new("Push to Remote(s):").size(LabelSize::Small)) + .child(self.render_remote_dropdown(window, cx)), + ) + .child( + Checkbox::new( + "push-branch-set-upstream", + if self.state.set_upstream { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .label("Set Upstream") + .label_size(LabelSize::Small) + .on_click(cx.listener( + |this: &mut PushBranchModal, _, _window, cx| { + this.state.set_upstream = !this.state.set_upstream; + cx.notify(); + }, + )), + ) + .child( + v_flex() + .gap_1() + .child(Label::new("Push Mode:").size(LabelSize::Small)) + .child( + v_flex() + .gap_1() + .child(self.render_push_mode_option( + "push-branch-mode-normal", + "Normal", + PushMode::Normal, + cx, + )) + .child(self.render_push_mode_option( + "push-branch-mode-force-with-lease", + "Force With Lease", + PushMode::ForceWithLease, + cx, + )) + .child(self.render_push_mode_option( + "push-branch-mode-force", + "Force", + PushMode::Force, + cx, + )), + ), + ) + .child( + h_flex() + .justify_end() + .gap_2() + .child( + Button::new("push-branch-cancel", "Cancel") + .style(ButtonStyle::Subtle) + .on_click(cx.listener( + |this: &mut PushBranchModal, _, window, cx| { + this.cancel(&Cancel, window, cx); + }, + )), + ) + .child( + Button::new("push-branch-confirm", "Push") + .style(ButtonStyle::Filled) + .on_click(cx.listener( + |this: &mut PushBranchModal, _, window, cx| { + this.confirm(&Confirm, window, cx); + }, + )), + ), + ), + ) + } +} + +pub(super) struct PushTagModal { + graph: WeakEntity, + state: PushTagDialogState, + focus_handle: FocusHandle, +} + +impl PushTagModal { + pub(super) fn new( + graph: WeakEntity, + state: PushTagDialogState, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + Self { + graph, + state, + focus_handle: cx.focus_handle(), + } + } + + fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } + + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let target = self.state.push_target(); + if let Some(graph) = self.graph.upgrade() { + graph.update(cx, |graph, cx| { + graph.perform_push_tag(target, window, cx); + }); + } + + cx.emit(DismissEvent); + } + + fn select_remote(&mut self, remote_name: SharedString, cx: &mut Context) { + self.state.select_remote(remote_name); + cx.notify(); + } + + fn render_remote_dropdown(&self, window: &mut Window, cx: &mut Context) -> DropdownMenu { + render_remote_dropdown( + "push-tag-remote-dropdown", + self.state.selected_remote.clone(), + &self.state.available_remotes, + window, + cx, + Self::select_remote, + ) + } +} + +impl EventEmitter for PushTagModal {} +impl ModalView for PushTagModal {} +impl Focusable for PushTagModal { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for PushTagModal { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("PushTagModal") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .elevation_2(cx) + .w(ui::rems(34.)) + .child( + h_flex() + .px_3() + .pt_2() + .pb_1() + .gap_1p5() + .child(Icon::new(IconName::GitCommit).size(IconSize::XSmall)) + .child(Label::new(format!("Push Tag ({})", self.state.tag_name))), + ) + .child( + v_flex() + .px_3() + .pb_3() + .w_full() + .gap_3() + .child( + v_flex() + .gap_1() + .child(Label::new("Push to Remote:").size(LabelSize::Small)) + .child(self.render_remote_dropdown(window, cx)), + ) + .child( + h_flex() + .justify_end() + .gap_2() + .child( + Button::new("push-tag-cancel", "Cancel") + .style(ButtonStyle::Subtle) + .on_click(cx.listener( + |this: &mut PushTagModal, _, window, cx| { + this.cancel(&Cancel, window, cx); + }, + )), + ) + .child( + Button::new("push-tag-confirm", "Push") + .style(ButtonStyle::Filled) + .on_click(cx.listener( + |this: &mut PushTagModal, _, window, cx| { + this.confirm(&Confirm, window, cx); + }, + )), + ), + ), + ) + } +} + +pub(super) struct DeleteBranchModal { + graph: WeakEntity, + branch_name: SharedString, + is_remote: bool, + force_delete: bool, + focus_handle: FocusHandle, +} + +impl DeleteBranchModal { + pub(super) fn new( + graph: WeakEntity, + branch_name: String, + is_remote: bool, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + Self { + graph, + branch_name: branch_name.into(), + is_remote, + force_delete: false, + focus_handle: cx.focus_handle(), + } + } + + fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } + + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + if let Some(graph) = self.graph.upgrade() { + let branch_name = self.branch_name.to_string(); + let is_remote = self.is_remote; + let force_delete = self.force_delete; + graph.update(cx, |graph, cx| { + graph.perform_delete_branch(branch_name, is_remote, force_delete, window, cx); + }); + } + + cx.emit(DismissEvent); + } +} + +impl EventEmitter for DeleteBranchModal {} +impl ModalView for DeleteBranchModal {} +impl Focusable for DeleteBranchModal { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for DeleteBranchModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("DeleteBranchModal") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .elevation_2(cx) + .w(ui::rems(34.)) + .child( + h_flex() + .px_3() + .pt_2() + .pb_1() + .gap_1p5() + .child(Icon::new(IconName::Trash).size(IconSize::XSmall)) + .child(Label::new(if self.is_remote { + format!("Delete Remote-Tracking Branch ({})", self.branch_name) + } else { + format!("Delete Branch ({})", self.branch_name) + })), + ) + .child( + v_flex() + .px_3() + .pb_3() + .w_full() + .gap_2() + .child(Label::new("This cannot be undone.")) + .when(!self.is_remote, |this| { + this.child( + Checkbox::new( + "delete-branch-force-delete", + if self.force_delete { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .label("Force delete") + .label_size(LabelSize::Small) + .on_click(cx.listener( + |this: &mut DeleteBranchModal, _, _window, cx| { + this.force_delete = !this.force_delete; + cx.notify(); + }, + )), + ) + }) + .child( + h_flex() + .justify_end() + .gap_2() + .child( + Button::new("delete-branch-cancel", "Cancel") + .style(ButtonStyle::Subtle) + .on_click(cx.listener( + |this: &mut DeleteBranchModal, _, window, cx| { + this.cancel(&Cancel, window, cx); + }, + )), + ) + .child( + Button::new("delete-branch-confirm", "Delete") + .style(ButtonStyle::Filled) + .on_click(cx.listener( + |this: &mut DeleteBranchModal, _, window, cx| { + this.confirm(&Confirm, window, cx); + }, + )), + ), + ), + ) + } +} + +pub(super) struct RevertCommitModal { + graph: WeakEntity, + repository: Entity, + commit_sha: SharedString, + no_commit: bool, + focus_handle: FocusHandle, +} + +impl RevertCommitModal { + pub(super) fn new( + graph: WeakEntity, + repository: Entity, + commit_sha: SharedString, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + Self { + graph, + repository, + commit_sha, + no_commit: false, + focus_handle: cx.focus_handle(), + } + } + + fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } + + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let repository = self.repository.clone(); + let graph = self.graph.clone(); + let sha = self.commit_sha.to_string(); + let no_commit = self.no_commit; + + cx.spawn(async move |_, cx| { + repository + .update(cx, |repository, _| repository.revert_commit(sha, no_commit)) + .await??; + + let _ = graph.update(cx, |graph, cx| { + graph.reload_graph(cx); + }); + + Ok(()) + }) + .detach_and_prompt_err("Failed to revert commit", window, cx, |error, _, _| { + Some(error.to_string()) + }); + + cx.emit(DismissEvent); + } +} + +impl EventEmitter for RevertCommitModal {} +impl ModalView for RevertCommitModal {} +impl Focusable for RevertCommitModal { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for RevertCommitModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("RevertCommitModal") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .elevation_2(cx) + .w(ui::rems(34.)) + .child( + h_flex() + .px_3() + .pt_2() + .pb_1() + .gap_1p5() + .child(Icon::new(IconName::GitCommit).size(IconSize::XSmall)) + .child(Label::new(format!("Revert Commit {}", self.commit_sha))), + ) + .child( + v_flex() + .px_3() + .pb_3() + .w_full() + .gap_2() + .child( + Checkbox::new( + "revert-commit-no-commit", + if self.no_commit { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + ) + .label("Do not commit (--no-commit)") + .label_size(LabelSize::Small) + .on_click(cx.listener( + |this: &mut RevertCommitModal, _, _window, cx| { + this.no_commit = !this.no_commit; + cx.notify(); + }, + )), + ) + .child( + h_flex() + .justify_end() + .gap_2() + .child( + Button::new("revert-commit-cancel", "Cancel") + .style(ButtonStyle::Subtle) + .on_click(cx.listener( + |this: &mut RevertCommitModal, _, window, cx| { + this.cancel(&Cancel, window, cx); + }, + )), + ) + .child( + Button::new("revert-commit-confirm", "Revert") + .style(ButtonStyle::Filled) + .on_click(cx.listener( + |this: &mut RevertCommitModal, _, window, cx| { + this.confirm(&Confirm, window, cx); + }, + )), + ), + ), + ) + } +} + +pub(super) struct GitGraphAskPassModal { + operation: SharedString, + prompt: SharedString, + editor: Entity, + tx: Option>, +} + +impl GitGraphAskPassModal { + pub(super) fn new( + operation: SharedString, + prompt: SharedString, + tx: oneshot::Sender, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + if prompt.contains("yes/no") || prompt.contains("Username") { + editor.set_masked(false, cx); + } else { + editor.set_masked(true, cx); + } + editor + }); + + Self { + operation, + prompt, + editor, + tx: Some(tx), + } + } + + fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } + + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + if let Some(tx) = self.tx.take() { + let mut text = self.editor.update(cx, |editor, cx| { + let text = editor.text(cx); + editor.clear(window, cx); + text + }); + if let Ok(password) = EncryptedPassword::try_from(text.as_ref()) { + tx.send(password).ok(); + } + text.zeroize(); + } + + cx.emit(DismissEvent); + } +} + +impl EventEmitter for GitGraphAskPassModal {} +impl ModalView for GitGraphAskPassModal {} +impl Focusable for GitGraphAskPassModal { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.editor.focus_handle(cx) + } +} + +impl Render for GitGraphAskPassModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("GitGraphAskPassModal") + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .elevation_2(cx) + .w(ui::rems(34.)) + .child( + h_flex() + .px_3() + .pt_2() + .pb_1() + .gap_1p5() + .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall)) + .child(Label::new(self.operation.clone())), + ) + .child( + v_flex() + .px_3() + .pb_3() + .w_full() + .gap_2() + .child(Label::new(self.prompt.clone())) + .child(self.editor.clone()), + ) + } +}