git_ui.rs

   1use anyhow::anyhow;
   2use commit_modal::CommitModal;
   3use editor::{Editor, actions::DiffClipboardWithSelectionData};
   4
   5use project::ProjectPath;
   6use ui::{
   7    Color, Headline, HeadlineSize, Icon, IconName, IconSize, IntoElement, ParentElement, Render,
   8    Styled, StyledExt, div, h_flex, rems, v_flex,
   9};
  10use workspace::{Toast, notifications::NotificationId};
  11
  12mod blame_ui;
  13pub mod clone;
  14
  15use git::{
  16    repository::{Branch, CommitDetails, Upstream, UpstreamTracking, UpstreamTrackingStatus},
  17    status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode},
  18};
  19use gpui::{
  20    App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, SharedString,
  21    Subscription, Task, Window,
  22};
  23use menu::{Cancel, Confirm};
  24use project::git_store::Repository;
  25use project_diff::ProjectDiff;
  26use time::OffsetDateTime;
  27use ui::prelude::*;
  28use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr};
  29use zed_actions;
  30
  31use crate::{commit_view::CommitView, git_panel::GitPanel, text_diff_view::TextDiffView};
  32
  33mod askpass_modal;
  34pub mod branch_picker;
  35mod commit_modal;
  36pub mod commit_tooltip;
  37pub mod commit_view;
  38mod conflict_view;
  39pub mod file_diff_view;
  40pub mod file_history_view;
  41pub mod git_panel;
  42mod git_panel_settings;
  43pub mod git_picker;
  44pub mod multi_diff_view;
  45pub mod picker_prompt;
  46pub mod project_diff;
  47pub(crate) mod remote_output;
  48pub mod repository_selector;
  49pub mod stash_picker;
  50pub mod text_diff_view;
  51pub mod worktree_picker;
  52
  53pub use conflict_view::MergeConflictIndicator;
  54
  55pub fn init(cx: &mut App) {
  56    editor::set_blame_renderer(blame_ui::GitBlameRenderer, cx);
  57    commit_view::init(cx);
  58
  59    cx.observe_new(|editor: &mut Editor, _, cx| {
  60        conflict_view::register_editor(editor, editor.buffer().clone(), cx);
  61    })
  62    .detach();
  63
  64    cx.observe_new(|workspace: &mut Workspace, _, cx| {
  65        ProjectDiff::register(workspace, cx);
  66        CommitModal::register(workspace);
  67        git_panel::register(workspace);
  68        repository_selector::register(workspace);
  69        git_picker::register(workspace);
  70
  71        let project = workspace.project().read(cx);
  72        if project.is_read_only(cx) {
  73            return;
  74        }
  75        if !project.is_via_collab() {
  76            workspace.register_action(
  77                |workspace, _: &zed_actions::git::CreatePullRequest, window, cx| {
  78                    if let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) {
  79                        panel.update(cx, |panel, cx| {
  80                            panel.create_pull_request(window, cx);
  81                        });
  82                    }
  83                },
  84            );
  85            workspace.register_action(|workspace, _: &git::Fetch, window, cx| {
  86                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
  87                    return;
  88                };
  89                panel.update(cx, |panel, cx| {
  90                    panel.fetch(true, window, cx);
  91                });
  92            });
  93            workspace.register_action(|workspace, _: &git::FetchFrom, window, cx| {
  94                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
  95                    return;
  96                };
  97                panel.update(cx, |panel, cx| {
  98                    panel.fetch(false, window, cx);
  99                });
 100            });
 101            workspace.register_action(|workspace, _: &git::Push, window, cx| {
 102                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 103                    return;
 104                };
 105                panel.update(cx, |panel, cx| {
 106                    panel.push(false, false, window, cx);
 107                });
 108            });
 109            workspace.register_action(|workspace, _: &git::PushTo, window, cx| {
 110                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 111                    return;
 112                };
 113                panel.update(cx, |panel, cx| {
 114                    panel.push(false, true, window, cx);
 115                });
 116            });
 117            workspace.register_action(|workspace, _: &git::ForcePush, window, cx| {
 118                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 119                    return;
 120                };
 121                panel.update(cx, |panel, cx| {
 122                    panel.push(true, false, window, cx);
 123                });
 124            });
 125            workspace.register_action(|workspace, _: &git::Pull, window, cx| {
 126                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 127                    return;
 128                };
 129                panel.update(cx, |panel, cx| {
 130                    panel.pull(false, window, cx);
 131                });
 132            });
 133            workspace.register_action(|workspace, _: &git::PullRebase, window, cx| {
 134                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 135                    return;
 136                };
 137                panel.update(cx, |panel, cx| {
 138                    panel.pull(true, window, cx);
 139                });
 140            });
 141        }
 142        workspace.register_action(|workspace, action: &git::StashAll, window, cx| {
 143            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 144                return;
 145            };
 146            panel.update(cx, |panel, cx| {
 147                panel.stash_all(action, window, cx);
 148            });
 149        });
 150        workspace.register_action(|workspace, action: &git::StashPop, window, cx| {
 151            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 152                return;
 153            };
 154            panel.update(cx, |panel, cx| {
 155                panel.stash_pop(action, window, cx);
 156            });
 157        });
 158        workspace.register_action(|workspace, action: &git::StashApply, window, cx| {
 159            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 160                return;
 161            };
 162            panel.update(cx, |panel, cx| {
 163                panel.stash_apply(action, window, cx);
 164            });
 165        });
 166        workspace.register_action(|workspace, action: &git::StageAll, window, cx| {
 167            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 168                return;
 169            };
 170            panel.update(cx, |panel, cx| {
 171                panel.stage_all(action, window, cx);
 172            });
 173        });
 174        workspace.register_action(|workspace, action: &git::UnstageAll, window, cx| {
 175            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 176                return;
 177            };
 178            panel.update(cx, |panel, cx| {
 179                panel.unstage_all(action, window, cx);
 180            });
 181        });
 182        workspace.register_action(|workspace, _: &git::Uncommit, window, cx| {
 183            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 184                return;
 185            };
 186            panel.update(cx, |panel, cx| {
 187                panel.uncommit(window, cx);
 188            })
 189        });
 190        workspace.register_action(|workspace, _action: &git::Init, window, cx| {
 191            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 192                return;
 193            };
 194            panel.update(cx, |panel, cx| {
 195                panel.git_init(window, cx);
 196            });
 197        });
 198        workspace.register_action(|workspace, _action: &git::Clone, window, cx| {
 199            let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 200                return;
 201            };
 202
 203            workspace.toggle_modal(window, cx, |window, cx| {
 204                GitCloneModal::show(panel, window, cx)
 205            });
 206        });
 207        workspace.register_action(|workspace, _: &git::OpenModifiedFiles, window, cx| {
 208            open_modified_files(workspace, window, cx);
 209        });
 210        workspace.register_action(|workspace, _: &git::RenameBranch, window, cx| {
 211            rename_current_branch(workspace, window, cx);
 212        });
 213        workspace.register_action(show_ref_picker);
 214        workspace.register_action(
 215            |workspace, action: &DiffClipboardWithSelectionData, window, cx| {
 216                if let Some(task) = TextDiffView::open(action, workspace, window, cx) {
 217                    task.detach();
 218                };
 219            },
 220        );
 221        workspace.register_action(|workspace, _: &git::FileHistory, window, cx| {
 222            let Some(active_item) = workspace.active_item(cx) else {
 223                return;
 224            };
 225            let Some(editor) = active_item.downcast::<Editor>() else {
 226                return;
 227            };
 228            let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() else {
 229                return;
 230            };
 231            let Some(file) = buffer.read(cx).file() else {
 232                return;
 233            };
 234            let worktree_id = file.worktree_id(cx);
 235            let project_path = ProjectPath {
 236                worktree_id,
 237                path: file.path().clone(),
 238            };
 239            let project = workspace.project();
 240            let git_store = project.read(cx).git_store();
 241            let Some((repo, repo_path)) = git_store
 242                .read(cx)
 243                .repository_and_path_for_project_path(&project_path, cx)
 244            else {
 245                return;
 246            };
 247            file_history_view::FileHistoryView::open(
 248                repo_path,
 249                git_store.downgrade(),
 250                repo.downgrade(),
 251                workspace.weak_handle(),
 252                window,
 253                cx,
 254            );
 255        });
 256    })
 257    .detach();
 258}
 259
 260fn open_modified_files(
 261    workspace: &mut Workspace,
 262    window: &mut Window,
 263    cx: &mut Context<Workspace>,
 264) {
 265    let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 266        return;
 267    };
 268    let modified_paths: Vec<_> = panel.update(cx, |panel, cx| {
 269        let Some(repo) = panel.active_repository.as_ref() else {
 270            return Vec::new();
 271        };
 272        let repo = repo.read(cx);
 273        repo.cached_status()
 274            .filter_map(|entry| {
 275                if entry.status.is_modified() {
 276                    repo.repo_path_to_project_path(&entry.repo_path, cx)
 277                } else {
 278                    None
 279                }
 280            })
 281            .collect()
 282    });
 283    for path in modified_paths {
 284        workspace.open_path(path, None, true, window, cx).detach();
 285    }
 286}
 287
 288pub fn git_status_icon(status: FileStatus) -> impl IntoElement {
 289    GitStatusIcon::new(status)
 290}
 291
 292struct RenameBranchModal {
 293    current_branch: SharedString,
 294    editor: Entity<Editor>,
 295    repo: Entity<Repository>,
 296}
 297
 298impl RenameBranchModal {
 299    fn new(
 300        current_branch: String,
 301        repo: Entity<Repository>,
 302        window: &mut Window,
 303        cx: &mut Context<Self>,
 304    ) -> Self {
 305        let editor = cx.new(|cx| {
 306            let mut editor = Editor::single_line(window, cx);
 307            editor.set_text(current_branch.clone(), window, cx);
 308            editor
 309        });
 310        Self {
 311            current_branch: current_branch.into(),
 312            editor,
 313            repo,
 314        }
 315    }
 316
 317    fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context<Self>) {
 318        cx.emit(DismissEvent);
 319    }
 320
 321    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
 322        let new_name = self.editor.read(cx).text(cx);
 323        if new_name.is_empty() || new_name == self.current_branch.as_ref() {
 324            cx.emit(DismissEvent);
 325            return;
 326        }
 327
 328        let repo = self.repo.clone();
 329        let current_branch = self.current_branch.to_string();
 330        cx.spawn(async move |_, cx| {
 331            match repo
 332                .update(cx, |repo, _| {
 333                    repo.rename_branch(current_branch, new_name.clone())
 334                })
 335                .await
 336            {
 337                Ok(Ok(_)) => Ok(()),
 338                Ok(Err(error)) => Err(error),
 339                Err(_) => Err(anyhow!("Operation was canceled")),
 340            }
 341        })
 342        .detach_and_prompt_err("Failed to rename branch", window, cx, |_, _, _| None);
 343        cx.emit(DismissEvent);
 344    }
 345}
 346
 347impl EventEmitter<DismissEvent> for RenameBranchModal {}
 348impl ModalView for RenameBranchModal {}
 349impl Focusable for RenameBranchModal {
 350    fn focus_handle(&self, cx: &App) -> FocusHandle {
 351        self.editor.focus_handle(cx)
 352    }
 353}
 354
 355impl Render for RenameBranchModal {
 356    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 357        v_flex()
 358            .key_context("RenameBranchModal")
 359            .on_action(cx.listener(Self::cancel))
 360            .on_action(cx.listener(Self::confirm))
 361            .elevation_2(cx)
 362            .w(rems(34.))
 363            .child(
 364                h_flex()
 365                    .px_3()
 366                    .pt_2()
 367                    .pb_1()
 368                    .w_full()
 369                    .gap_1p5()
 370                    .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall))
 371                    .child(
 372                        Headline::new(format!("Rename Branch ({})", self.current_branch))
 373                            .size(HeadlineSize::XSmall),
 374                    ),
 375            )
 376            .child(div().px_3().pb_3().w_full().child(self.editor.clone()))
 377    }
 378}
 379
 380fn rename_current_branch(
 381    workspace: &mut Workspace,
 382    window: &mut Window,
 383    cx: &mut Context<Workspace>,
 384) {
 385    let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
 386        return;
 387    };
 388    let current_branch: Option<String> = panel.update(cx, |panel, cx| {
 389        let repo = panel.active_repository.as_ref()?;
 390        let repo = repo.read(cx);
 391        repo.branch.as_ref().map(|branch| branch.name().to_string())
 392    });
 393
 394    let Some(current_branch_name) = current_branch else {
 395        return;
 396    };
 397
 398    let repo = panel.read(cx).active_repository.clone();
 399    let Some(repo) = repo else {
 400        return;
 401    };
 402
 403    workspace.toggle_modal(window, cx, |window, cx| {
 404        RenameBranchModal::new(current_branch_name, repo, window, cx)
 405    });
 406}
 407
 408struct RefPickerModal {
 409    editor: Entity<Editor>,
 410    repo: Entity<Repository>,
 411    workspace: Entity<Workspace>,
 412    commit_details: Option<CommitDetails>,
 413    lookup_task: Option<Task<()>>,
 414    _editor_subscription: Subscription,
 415}
 416
 417impl RefPickerModal {
 418    fn new(
 419        repo: Entity<Repository>,
 420        workspace: Entity<Workspace>,
 421        window: &mut Window,
 422        cx: &mut Context<Self>,
 423    ) -> Self {
 424        let editor = cx.new(|cx| {
 425            let mut editor = Editor::single_line(window, cx);
 426            editor.set_placeholder_text("Enter git ref...", window, cx);
 427            editor
 428        });
 429
 430        let _editor_subscription = cx.subscribe_in(
 431            &editor,
 432            window,
 433            |this, _editor, event: &editor::EditorEvent, window, cx| {
 434                if let editor::EditorEvent::BufferEdited = event {
 435                    this.lookup_commit_details(window, cx);
 436                }
 437            },
 438        );
 439
 440        Self {
 441            editor,
 442            repo,
 443            workspace,
 444            commit_details: None,
 445            lookup_task: None,
 446            _editor_subscription,
 447        }
 448    }
 449
 450    fn lookup_commit_details(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 451        let git_ref = self.editor.read(cx).text(cx);
 452        let git_ref = git_ref.trim().to_string();
 453
 454        if git_ref.is_empty() {
 455            self.commit_details = None;
 456            cx.notify();
 457            return;
 458        }
 459
 460        let repo = self.repo.clone();
 461        self.lookup_task = Some(cx.spawn_in(window, async move |this, cx| {
 462            cx.background_executor()
 463                .timer(std::time::Duration::from_millis(300))
 464                .await;
 465
 466            let show_result = repo
 467                .update(cx, |repo, _| repo.show(git_ref.clone()))
 468                .await
 469                .ok();
 470
 471            if let Some(show_future) = show_result {
 472                if let Ok(details) = show_future {
 473                    this.update(cx, |this, cx| {
 474                        this.commit_details = Some(details);
 475                        cx.notify();
 476                    })
 477                    .ok();
 478                } else {
 479                    this.update(cx, |this, cx| {
 480                        this.commit_details = None;
 481                        cx.notify();
 482                    })
 483                    .ok();
 484                }
 485            }
 486        }));
 487    }
 488
 489    fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context<Self>) {
 490        cx.emit(DismissEvent);
 491    }
 492
 493    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
 494        let git_ref = self.editor.read(cx).text(cx);
 495        let git_ref = git_ref.trim();
 496
 497        if git_ref.is_empty() {
 498            cx.emit(DismissEvent);
 499            return;
 500        }
 501
 502        let git_ref_string = git_ref.to_string();
 503
 504        let repo = self.repo.clone();
 505        let workspace = self.workspace.clone();
 506
 507        window
 508            .spawn(cx, async move |cx| -> anyhow::Result<()> {
 509                let show_future = repo.update(cx, |repo, _| repo.show(git_ref_string.clone()));
 510                let show_result = show_future.await;
 511
 512                match show_result {
 513                    Ok(Ok(details)) => {
 514                        workspace.update_in(cx, |workspace, window, cx| {
 515                            CommitView::open(
 516                                details.sha.to_string(),
 517                                repo.downgrade(),
 518                                workspace.weak_handle(),
 519                                None,
 520                                None,
 521                                window,
 522                                cx,
 523                            );
 524                        })?;
 525                    }
 526                    Ok(Err(_)) | Err(_) => {
 527                        workspace.update(cx, |workspace, cx| {
 528                            let error = anyhow::anyhow!("View commit failed");
 529                            Self::show_git_error_toast(&git_ref_string, error, workspace, cx);
 530                        });
 531                    }
 532                }
 533
 534                Ok(())
 535            })
 536            .detach();
 537        cx.emit(DismissEvent);
 538    }
 539
 540    fn show_git_error_toast(
 541        _git_ref: &str,
 542        error: anyhow::Error,
 543        workspace: &mut Workspace,
 544        cx: &mut Context<Workspace>,
 545    ) {
 546        let toast = Toast::new(NotificationId::unique::<()>(), error.to_string());
 547        workspace.show_toast(toast, cx);
 548    }
 549}
 550
 551impl EventEmitter<DismissEvent> for RefPickerModal {}
 552impl ModalView for RefPickerModal {}
 553impl Focusable for RefPickerModal {
 554    fn focus_handle(&self, cx: &App) -> FocusHandle {
 555        self.editor.focus_handle(cx)
 556    }
 557}
 558
 559impl Render for RefPickerModal {
 560    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 561        let has_commit_details = self.commit_details.is_some();
 562        let commit_preview = self.commit_details.as_ref().map(|details| {
 563            let commit_time = OffsetDateTime::from_unix_timestamp(details.commit_timestamp)
 564                .unwrap_or_else(|_| OffsetDateTime::now_utc());
 565            let local_offset =
 566                time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC);
 567            let formatted_time = time_format::format_localized_timestamp(
 568                commit_time,
 569                OffsetDateTime::now_utc(),
 570                local_offset,
 571                time_format::TimestampFormat::Relative,
 572            );
 573
 574            let subject = details.message.lines().next().unwrap_or("").to_string();
 575            let author_and_subject = format!("{}{}", details.author_name, subject);
 576
 577            h_flex()
 578                .w_full()
 579                .gap_6()
 580                .justify_between()
 581                .overflow_x_hidden()
 582                .child(
 583                    div().max_w_96().child(
 584                        Label::new(author_and_subject)
 585                            .size(LabelSize::Small)
 586                            .truncate()
 587                            .color(Color::Muted),
 588                    ),
 589                )
 590                .child(
 591                    Label::new(formatted_time)
 592                        .size(LabelSize::Small)
 593                        .color(Color::Muted),
 594                )
 595        });
 596
 597        v_flex()
 598            .key_context("RefPickerModal")
 599            .on_action(cx.listener(Self::cancel))
 600            .on_action(cx.listener(Self::confirm))
 601            .elevation_2(cx)
 602            .w(rems(34.))
 603            .child(
 604                h_flex()
 605                    .px_3()
 606                    .pt_2()
 607                    .pb_1()
 608                    .w_full()
 609                    .gap_1p5()
 610                    .child(Icon::new(IconName::Hash).size(IconSize::XSmall))
 611                    .child(Headline::new("View Commit").size(HeadlineSize::XSmall)),
 612            )
 613            .child(div().px_3().w_full().child(self.editor.clone()))
 614            .when_some(commit_preview, |el, preview| {
 615                el.child(div().px_3().pb_3().w_full().child(preview))
 616            })
 617            .when(!has_commit_details, |el| el.child(div().pb_3()))
 618    }
 619}
 620
 621fn show_ref_picker(
 622    workspace: &mut Workspace,
 623    _: &git::ViewCommit,
 624    window: &mut Window,
 625    cx: &mut Context<Workspace>,
 626) {
 627    let Some(repo) = workspace.project().read(cx).active_repository(cx) else {
 628        return;
 629    };
 630
 631    let workspace_entity = cx.entity();
 632    workspace.toggle_modal(window, cx, |window, cx| {
 633        RefPickerModal::new(repo, workspace_entity, window, cx)
 634    });
 635}
 636
 637fn render_remote_button(
 638    id: impl Into<SharedString>,
 639    branch: &Branch,
 640    keybinding_target: Option<FocusHandle>,
 641    show_fetch_button: bool,
 642) -> Option<impl IntoElement> {
 643    let id = id.into();
 644    let upstream = branch.upstream.as_ref();
 645    match upstream {
 646        Some(Upstream {
 647            tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead, behind }),
 648            ..
 649        }) => match (*ahead, *behind) {
 650            (0, 0) if show_fetch_button => {
 651                Some(remote_button::render_fetch_button(keybinding_target, id))
 652            }
 653            (0, 0) => None,
 654            (ahead, 0) => Some(remote_button::render_push_button(
 655                keybinding_target,
 656                id,
 657                ahead,
 658            )),
 659            (ahead, behind) => Some(remote_button::render_pull_button(
 660                keybinding_target,
 661                id,
 662                ahead,
 663                behind,
 664            )),
 665        },
 666        Some(Upstream {
 667            tracking: UpstreamTracking::Gone,
 668            ..
 669        }) => Some(remote_button::render_republish_button(
 670            keybinding_target,
 671            id,
 672        )),
 673        None => Some(remote_button::render_publish_button(keybinding_target, id)),
 674    }
 675}
 676
 677mod remote_button {
 678    use gpui::{Action, AnyView, ClickEvent, Corner, FocusHandle};
 679    use ui::{
 680        App, ButtonCommon, Clickable, ContextMenu, ElementId, FluentBuilder, Icon, IconName,
 681        IconSize, IntoElement, Label, LabelCommon, LabelSize, LineHeightStyle, ParentElement,
 682        PopoverMenu, SharedString, SplitButton, Styled, Tooltip, Window, div, h_flex, rems,
 683    };
 684
 685    pub fn render_fetch_button(
 686        keybinding_target: Option<FocusHandle>,
 687        id: SharedString,
 688    ) -> SplitButton {
 689        split_button(
 690            id,
 691            "Fetch",
 692            0,
 693            0,
 694            Some(IconName::ArrowCircle),
 695            keybinding_target.clone(),
 696            move |_, window, cx| {
 697                window.dispatch_action(Box::new(git::Fetch), cx);
 698            },
 699            move |_window, cx| {
 700                git_action_tooltip(
 701                    "Fetch updates from remote",
 702                    &git::Fetch,
 703                    "git fetch",
 704                    keybinding_target.clone(),
 705                    cx,
 706                )
 707            },
 708        )
 709    }
 710
 711    pub fn render_push_button(
 712        keybinding_target: Option<FocusHandle>,
 713        id: SharedString,
 714        ahead: u32,
 715    ) -> SplitButton {
 716        split_button(
 717            id,
 718            "Push",
 719            ahead as usize,
 720            0,
 721            None,
 722            keybinding_target.clone(),
 723            move |_, window, cx| {
 724                window.dispatch_action(Box::new(git::Push), cx);
 725            },
 726            move |_window, cx| {
 727                git_action_tooltip(
 728                    "Push committed changes to remote",
 729                    &git::Push,
 730                    "git push",
 731                    keybinding_target.clone(),
 732                    cx,
 733                )
 734            },
 735        )
 736    }
 737
 738    pub fn render_pull_button(
 739        keybinding_target: Option<FocusHandle>,
 740        id: SharedString,
 741        ahead: u32,
 742        behind: u32,
 743    ) -> SplitButton {
 744        split_button(
 745            id,
 746            "Pull",
 747            ahead as usize,
 748            behind as usize,
 749            None,
 750            keybinding_target.clone(),
 751            move |_, window, cx| {
 752                window.dispatch_action(Box::new(git::Pull), cx);
 753            },
 754            move |_window, cx| {
 755                git_action_tooltip(
 756                    "Pull",
 757                    &git::Pull,
 758                    "git pull",
 759                    keybinding_target.clone(),
 760                    cx,
 761                )
 762            },
 763        )
 764    }
 765
 766    pub fn render_publish_button(
 767        keybinding_target: Option<FocusHandle>,
 768        id: SharedString,
 769    ) -> SplitButton {
 770        split_button(
 771            id,
 772            "Publish",
 773            0,
 774            0,
 775            Some(IconName::ExpandUp),
 776            keybinding_target.clone(),
 777            move |_, window, cx| {
 778                window.dispatch_action(Box::new(git::Push), cx);
 779            },
 780            move |_window, cx| {
 781                git_action_tooltip(
 782                    "Publish branch to remote",
 783                    &git::Push,
 784                    "git push --set-upstream",
 785                    keybinding_target.clone(),
 786                    cx,
 787                )
 788            },
 789        )
 790    }
 791
 792    pub fn render_republish_button(
 793        keybinding_target: Option<FocusHandle>,
 794        id: SharedString,
 795    ) -> SplitButton {
 796        split_button(
 797            id,
 798            "Republish",
 799            0,
 800            0,
 801            Some(IconName::ExpandUp),
 802            keybinding_target.clone(),
 803            move |_, window, cx| {
 804                window.dispatch_action(Box::new(git::Push), cx);
 805            },
 806            move |_window, cx| {
 807                git_action_tooltip(
 808                    "Re-publish branch to remote",
 809                    &git::Push,
 810                    "git push --set-upstream",
 811                    keybinding_target.clone(),
 812                    cx,
 813                )
 814            },
 815        )
 816    }
 817
 818    fn git_action_tooltip(
 819        label: impl Into<SharedString>,
 820        action: &dyn Action,
 821        command: impl Into<SharedString>,
 822        focus_handle: Option<FocusHandle>,
 823        cx: &mut App,
 824    ) -> AnyView {
 825        let label = label.into();
 826        let command = command.into();
 827
 828        if let Some(handle) = focus_handle {
 829            Tooltip::with_meta_in(label, Some(action), command, &handle, cx)
 830        } else {
 831            Tooltip::with_meta(label, Some(action), command, cx)
 832        }
 833    }
 834
 835    fn render_git_action_menu(
 836        id: impl Into<ElementId>,
 837        keybinding_target: Option<FocusHandle>,
 838    ) -> impl IntoElement {
 839        PopoverMenu::new(id.into())
 840            .trigger(
 841                ui::ButtonLike::new_rounded_right("split-button-right")
 842                    .layer(ui::ElevationIndex::ModalSurface)
 843                    .size(ui::ButtonSize::None)
 844                    .child(
 845                        div()
 846                            .px_1()
 847                            .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
 848                    ),
 849            )
 850            .menu(move |window, cx| {
 851                Some(ContextMenu::build(window, cx, |context_menu, _, _| {
 852                    context_menu
 853                        .when_some(keybinding_target.clone(), |el, keybinding_target| {
 854                            el.context(keybinding_target)
 855                        })
 856                        .action("Fetch", git::Fetch.boxed_clone())
 857                        .action("Fetch From", git::FetchFrom.boxed_clone())
 858                        .action("Pull", git::Pull.boxed_clone())
 859                        .action("Pull (Rebase)", git::PullRebase.boxed_clone())
 860                        .separator()
 861                        .action("Push", git::Push.boxed_clone())
 862                        .action("Push To", git::PushTo.boxed_clone())
 863                        .action("Force Push", git::ForcePush.boxed_clone())
 864                }))
 865            })
 866            .anchor(Corner::TopRight)
 867    }
 868
 869    #[allow(clippy::too_many_arguments)]
 870    fn split_button(
 871        id: SharedString,
 872        left_label: impl Into<SharedString>,
 873        ahead_count: usize,
 874        behind_count: usize,
 875        left_icon: Option<IconName>,
 876        keybinding_target: Option<FocusHandle>,
 877        left_on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
 878        tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
 879    ) -> SplitButton {
 880        fn count(count: usize) -> impl IntoElement {
 881            h_flex()
 882                .ml_neg_px()
 883                .h(rems(0.875))
 884                .items_center()
 885                .overflow_hidden()
 886                .px_0p5()
 887                .child(
 888                    Label::new(count.to_string())
 889                        .size(LabelSize::XSmall)
 890                        .line_height_style(LineHeightStyle::UiLabel),
 891                )
 892        }
 893
 894        let should_render_counts = left_icon.is_none() && (ahead_count > 0 || behind_count > 0);
 895
 896        let left = ui::ButtonLike::new_rounded_left(ElementId::Name(
 897            format!("split-button-left-{}", id).into(),
 898        ))
 899        .layer(ui::ElevationIndex::ModalSurface)
 900        .size(ui::ButtonSize::Compact)
 901        .when(should_render_counts, |this| {
 902            this.child(
 903                h_flex()
 904                    .ml_neg_0p5()
 905                    .when(behind_count > 0, |this| {
 906                        this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall))
 907                            .child(count(behind_count))
 908                    })
 909                    .when(ahead_count > 0, |this| {
 910                        this.child(Icon::new(IconName::ArrowUp).size(IconSize::XSmall))
 911                            .child(count(ahead_count))
 912                    }),
 913            )
 914        })
 915        .when_some(left_icon, |this, left_icon| {
 916            this.child(
 917                h_flex()
 918                    .ml_neg_0p5()
 919                    .child(Icon::new(left_icon).size(IconSize::XSmall)),
 920            )
 921        })
 922        .child(
 923            div()
 924                .child(Label::new(left_label).size(LabelSize::Small))
 925                .mr_0p5(),
 926        )
 927        .on_click(left_on_click)
 928        .tooltip(tooltip);
 929
 930        let right = render_git_action_menu(
 931            ElementId::Name(format!("split-button-right-{}", id).into()),
 932            keybinding_target,
 933        )
 934        .into_any_element();
 935
 936        SplitButton::new(left, right)
 937    }
 938}
 939
 940/// A visual representation of a file's Git status.
 941#[derive(IntoElement, RegisterComponent)]
 942pub struct GitStatusIcon {
 943    status: FileStatus,
 944}
 945
 946impl GitStatusIcon {
 947    pub fn new(status: FileStatus) -> Self {
 948        Self { status }
 949    }
 950}
 951
 952impl RenderOnce for GitStatusIcon {
 953    fn render(self, _window: &mut ui::Window, cx: &mut App) -> impl IntoElement {
 954        let status = self.status;
 955
 956        let (icon_name, color) = if status.is_conflicted() {
 957            (
 958                IconName::Warning,
 959                cx.theme().colors().version_control_conflict,
 960            )
 961        } else if status.is_deleted() {
 962            (
 963                IconName::SquareMinus,
 964                cx.theme().colors().version_control_deleted,
 965            )
 966        } else if status.is_modified() {
 967            (
 968                IconName::SquareDot,
 969                cx.theme().colors().version_control_modified,
 970            )
 971        } else {
 972            (
 973                IconName::SquarePlus,
 974                cx.theme().colors().version_control_added,
 975            )
 976        };
 977
 978        Icon::new(icon_name).color(Color::Custom(color))
 979    }
 980}
 981
 982// View this component preview using `workspace: open component-preview`
 983impl Component for GitStatusIcon {
 984    fn scope() -> ComponentScope {
 985        ComponentScope::VersionControl
 986    }
 987
 988    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
 989        fn tracked_file_status(code: StatusCode) -> FileStatus {
 990            FileStatus::Tracked(git::status::TrackedStatus {
 991                index_status: code,
 992                worktree_status: code,
 993            })
 994        }
 995
 996        let modified = tracked_file_status(StatusCode::Modified);
 997        let added = tracked_file_status(StatusCode::Added);
 998        let deleted = tracked_file_status(StatusCode::Deleted);
 999        let conflict = UnmergedStatus {
1000            first_head: UnmergedStatusCode::Updated,
1001            second_head: UnmergedStatusCode::Updated,
1002        }
1003        .into();
1004
1005        Some(
1006            v_flex()
1007                .gap_6()
1008                .children(vec![example_group(vec![
1009                    single_example("Modified", GitStatusIcon::new(modified).into_any_element()),
1010                    single_example("Added", GitStatusIcon::new(added).into_any_element()),
1011                    single_example("Deleted", GitStatusIcon::new(deleted).into_any_element()),
1012                    single_example(
1013                        "Conflicted",
1014                        GitStatusIcon::new(conflict).into_any_element(),
1015                    ),
1016                ])])
1017                .into_any_element(),
1018        )
1019    }
1020}
1021
1022struct GitCloneModal {
1023    panel: Entity<GitPanel>,
1024    repo_input: Entity<Editor>,
1025    focus_handle: FocusHandle,
1026}
1027
1028impl GitCloneModal {
1029    pub fn show(panel: Entity<GitPanel>, window: &mut Window, cx: &mut Context<Self>) -> Self {
1030        let repo_input = cx.new(|cx| {
1031            let mut editor = Editor::single_line(window, cx);
1032            editor.set_placeholder_text("Enter repository URL…", window, cx);
1033            editor
1034        });
1035        let focus_handle = repo_input.focus_handle(cx);
1036
1037        window.focus(&focus_handle, cx);
1038
1039        Self {
1040            panel,
1041            repo_input,
1042            focus_handle,
1043        }
1044    }
1045}
1046
1047impl Focusable for GitCloneModal {
1048    fn focus_handle(&self, _: &App) -> FocusHandle {
1049        self.focus_handle.clone()
1050    }
1051}
1052
1053impl Render for GitCloneModal {
1054    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1055        div()
1056            .elevation_3(cx)
1057            .w(rems(34.))
1058            .flex_1()
1059            .overflow_hidden()
1060            .child(
1061                div()
1062                    .w_full()
1063                    .p_2()
1064                    .border_b_1()
1065                    .border_color(cx.theme().colors().border_variant)
1066                    .child(self.repo_input.clone()),
1067            )
1068            .child(
1069                h_flex()
1070                    .w_full()
1071                    .p_2()
1072                    .gap_0p5()
1073                    .rounded_b_sm()
1074                    .bg(cx.theme().colors().editor_background)
1075                    .child(
1076                        Label::new("Clone a repository from GitHub or other sources.")
1077                            .color(Color::Muted)
1078                            .size(LabelSize::Small),
1079                    )
1080                    .child(
1081                        Button::new("learn-more", "Learn More")
1082                            .label_size(LabelSize::Small)
1083                            .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::XSmall))
1084                            .on_click(|_, _, cx| {
1085                                cx.open_url("https://github.com/git-guides/git-clone");
1086                            }),
1087                    ),
1088            )
1089            .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
1090                cx.emit(DismissEvent);
1091            }))
1092            .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1093                let repo = this.repo_input.read(cx).text(cx);
1094                this.panel.update(cx, |panel, cx| {
1095                    panel.git_clone(repo, window, cx);
1096                });
1097                cx.emit(DismissEvent);
1098            }))
1099    }
1100}
1101
1102impl EventEmitter<DismissEvent> for GitCloneModal {}
1103
1104impl ModalView for GitCloneModal {}
1105
1106#[cfg(test)]
1107mod view_commit_tests {
1108    use super::*;
1109    use gpui::{TestAppContext, VisualTestContext, WindowHandle};
1110    use language::language_settings::AllLanguageSettings;
1111    use project::project_settings::ProjectSettings;
1112    use project::{FakeFs, Project, WorktreeSettings};
1113    use serde_json::json;
1114    use settings::{Settings as _, SettingsStore};
1115    use std::path::Path;
1116    use std::sync::Arc;
1117    use theme::LoadThemes;
1118    use util::path;
1119    use workspace::WorkspaceSettings;
1120
1121    fn init_test(cx: &mut TestAppContext) {
1122        zlog::init_test();
1123        cx.update(|cx| {
1124            let settings_store = SettingsStore::test(cx);
1125            cx.set_global(settings_store);
1126            theme_settings::init(LoadThemes::JustBase, cx);
1127            AllLanguageSettings::register(cx);
1128            editor::init(cx);
1129            ProjectSettings::register(cx);
1130            WorktreeSettings::register(cx);
1131            WorkspaceSettings::register(cx);
1132        });
1133    }
1134
1135    async fn setup_git_repo(cx: &mut TestAppContext) -> Arc<FakeFs> {
1136        let fs = FakeFs::new(cx.background_executor.clone());
1137        fs.insert_tree(
1138            "/root",
1139            json!({
1140                "project": {
1141                    ".git": {},
1142                    "src": {
1143                        "main.rs": "fn main() {}"
1144                    }
1145                }
1146            }),
1147        )
1148        .await;
1149        fs
1150    }
1151
1152    async fn create_test_workspace(
1153        fs: Arc<FakeFs>,
1154        cx: &mut TestAppContext,
1155    ) -> (Entity<Project>, WindowHandle<Workspace>) {
1156        let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
1157        let workspace =
1158            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1159        cx.read(|cx| {
1160            project
1161                .read(cx)
1162                .worktrees(cx)
1163                .next()
1164                .unwrap()
1165                .read(cx)
1166                .as_local()
1167                .unwrap()
1168                .scan_complete()
1169        })
1170        .await;
1171        (project, workspace)
1172    }
1173
1174    #[gpui::test]
1175    async fn test_show_ref_picker_with_repository(cx: &mut TestAppContext) {
1176        init_test(cx);
1177        let fs = setup_git_repo(cx).await;
1178
1179        fs.set_status_for_repo(
1180            Path::new("/root/project/.git"),
1181            &[("src/main.rs", git::status::StatusCode::Modified.worktree())],
1182        );
1183
1184        let (_project, workspace) = create_test_workspace(fs, cx).await;
1185        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1186
1187        let initial_modal_state = workspace
1188            .read_with(cx, |workspace, cx| {
1189                workspace.active_modal::<RefPickerModal>(cx).is_some()
1190            })
1191            .unwrap_or(false);
1192
1193        let _ = workspace.update(cx, |workspace, window, cx| {
1194            show_ref_picker(workspace, &git::ViewCommit, window, cx);
1195        });
1196
1197        let final_modal_state = workspace
1198            .read_with(cx, |workspace, cx| {
1199                workspace.active_modal::<RefPickerModal>(cx).is_some()
1200            })
1201            .unwrap_or(false);
1202
1203        assert!(!initial_modal_state);
1204        assert!(final_modal_state);
1205    }
1206}