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