git_panel.rs

   1use crate::git_panel_settings::StatusStyle;
   2use crate::project_diff::Diff;
   3use crate::repository_selector::RepositorySelectorPopoverMenu;
   4use crate::{
   5    git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
   6};
   7use crate::{picker_prompt, project_diff, ProjectDiff};
   8use db::kvp::KEY_VALUE_STORE;
   9use editor::commit_tooltip::CommitTooltip;
  10use editor::{
  11    scroll::ScrollbarAutoHide, Editor, EditorElement, EditorMode, EditorSettings, MultiBuffer,
  12    ShowScrollbar,
  13};
  14use git::repository::{Branch, CommitDetails, PushOptions, Remote, ResetMode, UpstreamTracking};
  15use git::{repository::RepoPath, status::FileStatus, Commit, ToggleStaged};
  16use git::{Push, RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll};
  17use gpui::*;
  18use itertools::Itertools;
  19use language::{Buffer, File};
  20use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
  21use multi_buffer::ExcerptInfo;
  22use panel::{panel_editor_container, panel_editor_style, panel_filled_button, PanelHeader};
  23use project::{
  24    git::{GitEvent, Repository},
  25    Fs, Project, ProjectPath,
  26};
  27use serde::{Deserialize, Serialize};
  28use settings::Settings as _;
  29use std::cell::RefCell;
  30use std::future::Future;
  31use std::rc::Rc;
  32use std::{collections::HashSet, sync::Arc, time::Duration, usize};
  33use strum::{IntoEnumIterator, VariantNames};
  34use time::OffsetDateTime;
  35use ui::{
  36    prelude::*, ButtonLike, Checkbox, ContextMenu, Divider, DividerColor, ElevationIndex, ListItem,
  37    ListItemSpacing, PopoverMenu, Scrollbar, ScrollbarState, Tooltip,
  38};
  39use util::{maybe, post_inc, ResultExt, TryFutureExt};
  40use workspace::{
  41    dock::{DockPosition, Panel, PanelEvent},
  42    notifications::{DetachAndPromptErr, NotificationId},
  43    Toast, Workspace,
  44};
  45
  46actions!(
  47    git_panel,
  48    [
  49        Close,
  50        ToggleFocus,
  51        OpenMenu,
  52        FocusEditor,
  53        FocusChanges,
  54        ToggleFillCoAuthors,
  55    ]
  56);
  57
  58fn prompt<T>(msg: &str, detail: Option<&str>, window: &mut Window, cx: &mut App) -> Task<Result<T>>
  59where
  60    T: IntoEnumIterator + VariantNames + 'static,
  61{
  62    let rx = window.prompt(PromptLevel::Info, msg, detail, &T::VARIANTS, cx);
  63    cx.spawn(|_| async move { Ok(T::iter().nth(rx.await?).unwrap()) })
  64}
  65
  66#[derive(strum::EnumIter, strum::VariantNames)]
  67#[strum(serialize_all = "title_case")]
  68enum TrashCancel {
  69    Trash,
  70    Cancel,
  71}
  72
  73const GIT_PANEL_KEY: &str = "GitPanel";
  74
  75const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
  76
  77pub fn init(cx: &mut App) {
  78    cx.observe_new(
  79        |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
  80            workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
  81                workspace.toggle_panel_focus::<GitPanel>(window, cx);
  82            });
  83        },
  84    )
  85    .detach();
  86}
  87
  88#[derive(Debug, Clone)]
  89pub enum Event {
  90    Focus,
  91}
  92
  93#[derive(Serialize, Deserialize)]
  94struct SerializedGitPanel {
  95    width: Option<Pixels>,
  96}
  97
  98#[derive(Debug, PartialEq, Eq, Clone, Copy)]
  99enum Section {
 100    Conflict,
 101    Tracked,
 102    New,
 103}
 104
 105#[derive(Debug, PartialEq, Eq, Clone)]
 106struct GitHeaderEntry {
 107    header: Section,
 108}
 109
 110impl GitHeaderEntry {
 111    pub fn contains(&self, status_entry: &GitStatusEntry, repo: &Repository) -> bool {
 112        let this = &self.header;
 113        let status = status_entry.status;
 114        match this {
 115            Section::Conflict => repo.has_conflict(&status_entry.repo_path),
 116            Section::Tracked => !status.is_created(),
 117            Section::New => status.is_created(),
 118        }
 119    }
 120    pub fn title(&self) -> &'static str {
 121        match self.header {
 122            Section::Conflict => "Conflicts",
 123            Section::Tracked => "Tracked",
 124            Section::New => "Untracked",
 125        }
 126    }
 127}
 128
 129#[derive(Debug, PartialEq, Eq, Clone)]
 130enum GitListEntry {
 131    GitStatusEntry(GitStatusEntry),
 132    Header(GitHeaderEntry),
 133}
 134
 135impl GitListEntry {
 136    fn status_entry(&self) -> Option<&GitStatusEntry> {
 137        match self {
 138            GitListEntry::GitStatusEntry(entry) => Some(entry),
 139            _ => None,
 140        }
 141    }
 142}
 143
 144#[derive(Debug, PartialEq, Eq, Clone)]
 145pub struct GitStatusEntry {
 146    pub(crate) repo_path: RepoPath,
 147    pub(crate) status: FileStatus,
 148    pub(crate) is_staged: Option<bool>,
 149}
 150
 151#[derive(Clone, Copy, Debug, PartialEq, Eq)]
 152enum TargetStatus {
 153    Staged,
 154    Unstaged,
 155    Reverted,
 156    Unchanged,
 157}
 158
 159struct PendingOperation {
 160    finished: bool,
 161    target_status: TargetStatus,
 162    repo_paths: HashSet<RepoPath>,
 163    op_id: usize,
 164}
 165
 166type RemoteOperations = Rc<RefCell<HashSet<u32>>>;
 167
 168pub struct GitPanel {
 169    remote_operation_id: u32,
 170    pending_remote_operations: RemoteOperations,
 171    pub(crate) active_repository: Option<Entity<Repository>>,
 172    commit_editor: Entity<Editor>,
 173    suggested_commit_message: Option<String>,
 174    conflicted_count: usize,
 175    conflicted_staged_count: usize,
 176    current_modifiers: Modifiers,
 177    add_coauthors: bool,
 178    entries: Vec<GitListEntry>,
 179    focus_handle: FocusHandle,
 180    fs: Arc<dyn Fs>,
 181    hide_scrollbar_task: Option<Task<()>>,
 182    new_count: usize,
 183    new_staged_count: usize,
 184    pending: Vec<PendingOperation>,
 185    pending_commit: Option<Task<()>>,
 186    pending_serialization: Task<Option<()>>,
 187    pub(crate) project: Entity<Project>,
 188    repository_selector: Entity<RepositorySelector>,
 189    scroll_handle: UniformListScrollHandle,
 190    scrollbar_state: ScrollbarState,
 191    selected_entry: Option<usize>,
 192    show_scrollbar: bool,
 193    tracked_count: usize,
 194    tracked_staged_count: usize,
 195    update_visible_entries_task: Task<()>,
 196    width: Option<Pixels>,
 197    workspace: WeakEntity<Workspace>,
 198    context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
 199    modal_open: bool,
 200}
 201
 202struct RemoteOperationGuard {
 203    id: u32,
 204    pending_remote_operations: RemoteOperations,
 205}
 206
 207impl Drop for RemoteOperationGuard {
 208    fn drop(&mut self) {
 209        self.pending_remote_operations.borrow_mut().remove(&self.id);
 210    }
 211}
 212
 213pub(crate) fn commit_message_editor(
 214    commit_message_buffer: Entity<Buffer>,
 215    project: Entity<Project>,
 216    in_panel: bool,
 217    window: &mut Window,
 218    cx: &mut Context<'_, Editor>,
 219) -> Editor {
 220    let buffer = cx.new(|cx| MultiBuffer::singleton(commit_message_buffer, cx));
 221    let max_lines = if in_panel { 6 } else { 18 };
 222    let mut commit_editor = Editor::new(
 223        EditorMode::AutoHeight { max_lines },
 224        buffer,
 225        None,
 226        false,
 227        window,
 228        cx,
 229    );
 230    commit_editor.set_collaboration_hub(Box::new(project));
 231    commit_editor.set_use_autoclose(false);
 232    commit_editor.set_show_gutter(false, cx);
 233    commit_editor.set_show_wrap_guides(false, cx);
 234    commit_editor.set_show_indent_guides(false, cx);
 235    commit_editor.set_placeholder_text("Enter commit message", cx);
 236    commit_editor
 237}
 238
 239impl GitPanel {
 240    pub fn new(
 241        workspace: &mut Workspace,
 242        window: &mut Window,
 243        cx: &mut Context<Workspace>,
 244    ) -> Entity<Self> {
 245        let fs = workspace.app_state().fs.clone();
 246        let project = workspace.project().clone();
 247        let git_store = project.read(cx).git_store().clone();
 248        let active_repository = project.read(cx).active_repository(cx);
 249        let workspace = cx.entity().downgrade();
 250
 251        cx.new(|cx| {
 252            let focus_handle = cx.focus_handle();
 253            cx.on_focus(&focus_handle, window, Self::focus_in).detach();
 254            cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
 255                this.hide_scrollbar(window, cx);
 256            })
 257            .detach();
 258
 259            // just to let us render a placeholder editor.
 260            // Once the active git repo is set, this buffer will be replaced.
 261            let temporary_buffer = cx.new(|cx| Buffer::local("", cx));
 262            let commit_editor = cx.new(|cx| {
 263                commit_message_editor(temporary_buffer, project.clone(), true, window, cx)
 264            });
 265            commit_editor.update(cx, |editor, cx| {
 266                editor.clear(window, cx);
 267            });
 268
 269            let scroll_handle = UniformListScrollHandle::new();
 270
 271            cx.subscribe_in(
 272                &git_store,
 273                window,
 274                move |this, git_store, event, window, cx| match event {
 275                    GitEvent::FileSystemUpdated => {
 276                        this.schedule_update(false, window, cx);
 277                    }
 278                    GitEvent::ActiveRepositoryChanged | GitEvent::GitStateUpdated => {
 279                        this.active_repository = git_store.read(cx).active_repository();
 280                        this.schedule_update(true, window, cx);
 281                    }
 282                },
 283            )
 284            .detach();
 285
 286            let scrollbar_state =
 287                ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity());
 288
 289            let repository_selector =
 290                cx.new(|cx| RepositorySelector::new(project.clone(), window, cx));
 291
 292            let mut git_panel = Self {
 293                pending_remote_operations: Default::default(),
 294                remote_operation_id: 0,
 295                active_repository,
 296                commit_editor,
 297                suggested_commit_message: None,
 298                conflicted_count: 0,
 299                conflicted_staged_count: 0,
 300                current_modifiers: window.modifiers(),
 301                add_coauthors: true,
 302                entries: Vec::new(),
 303                focus_handle: cx.focus_handle(),
 304                fs,
 305                hide_scrollbar_task: None,
 306                new_count: 0,
 307                new_staged_count: 0,
 308                pending: Vec::new(),
 309                pending_commit: None,
 310                pending_serialization: Task::ready(None),
 311                project,
 312                repository_selector,
 313                scroll_handle,
 314                scrollbar_state,
 315                selected_entry: None,
 316                show_scrollbar: false,
 317                tracked_count: 0,
 318                tracked_staged_count: 0,
 319                update_visible_entries_task: Task::ready(()),
 320                width: Some(px(360.)),
 321                context_menu: None,
 322                workspace,
 323                modal_open: false,
 324            };
 325            git_panel.schedule_update(false, window, cx);
 326            git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx);
 327            git_panel
 328        })
 329    }
 330
 331    pub fn entry_by_path(&self, path: &RepoPath) -> Option<usize> {
 332        fn binary_search<F>(mut low: usize, mut high: usize, is_target: F) -> Option<usize>
 333        where
 334            F: Fn(usize) -> std::cmp::Ordering,
 335        {
 336            while low < high {
 337                let mid = low + (high - low) / 2;
 338                match is_target(mid) {
 339                    std::cmp::Ordering::Equal => return Some(mid),
 340                    std::cmp::Ordering::Less => low = mid + 1,
 341                    std::cmp::Ordering::Greater => high = mid,
 342                }
 343            }
 344            None
 345        }
 346        if self.conflicted_count > 0 {
 347            let conflicted_start = 1;
 348            if let Some(ix) = binary_search(
 349                conflicted_start,
 350                conflicted_start + self.conflicted_count,
 351                |ix| {
 352                    self.entries[ix]
 353                        .status_entry()
 354                        .unwrap()
 355                        .repo_path
 356                        .cmp(&path)
 357                },
 358            ) {
 359                return Some(ix);
 360            }
 361        }
 362        if self.tracked_count > 0 {
 363            let tracked_start = if self.conflicted_count > 0 {
 364                1 + self.conflicted_count
 365            } else {
 366                0
 367            } + 1;
 368            if let Some(ix) =
 369                binary_search(tracked_start, tracked_start + self.tracked_count, |ix| {
 370                    self.entries[ix]
 371                        .status_entry()
 372                        .unwrap()
 373                        .repo_path
 374                        .cmp(&path)
 375                })
 376            {
 377                return Some(ix);
 378            }
 379        }
 380        if self.new_count > 0 {
 381            let untracked_start = if self.conflicted_count > 0 {
 382                1 + self.conflicted_count
 383            } else {
 384                0
 385            } + if self.tracked_count > 0 {
 386                1 + self.tracked_count
 387            } else {
 388                0
 389            } + 1;
 390            if let Some(ix) =
 391                binary_search(untracked_start, untracked_start + self.new_count, |ix| {
 392                    self.entries[ix]
 393                        .status_entry()
 394                        .unwrap()
 395                        .repo_path
 396                        .cmp(&path)
 397                })
 398            {
 399                return Some(ix);
 400            }
 401        }
 402        None
 403    }
 404
 405    pub fn select_entry_by_path(
 406        &mut self,
 407        path: ProjectPath,
 408        _: &mut Window,
 409        cx: &mut Context<Self>,
 410    ) {
 411        let Some(git_repo) = self.active_repository.as_ref() else {
 412            return;
 413        };
 414        let Some(repo_path) = git_repo.read(cx).project_path_to_repo_path(&path) else {
 415            return;
 416        };
 417        let Some(ix) = self.entry_by_path(&repo_path) else {
 418            return;
 419        };
 420        self.selected_entry = Some(ix);
 421        cx.notify();
 422    }
 423
 424    fn start_remote_operation(&mut self) -> RemoteOperationGuard {
 425        let id = post_inc(&mut self.remote_operation_id);
 426        self.pending_remote_operations.borrow_mut().insert(id);
 427
 428        RemoteOperationGuard {
 429            id,
 430            pending_remote_operations: self.pending_remote_operations.clone(),
 431        }
 432    }
 433
 434    fn serialize(&mut self, cx: &mut Context<Self>) {
 435        let width = self.width;
 436        self.pending_serialization = cx.background_spawn(
 437            async move {
 438                KEY_VALUE_STORE
 439                    .write_kvp(
 440                        GIT_PANEL_KEY.into(),
 441                        serde_json::to_string(&SerializedGitPanel { width })?,
 442                    )
 443                    .await?;
 444                anyhow::Ok(())
 445            }
 446            .log_err(),
 447        );
 448    }
 449
 450    pub(crate) fn set_modal_open(&mut self, open: bool, cx: &mut Context<Self>) {
 451        self.modal_open = open;
 452        cx.notify();
 453    }
 454
 455    fn dispatch_context(&self, window: &mut Window, cx: &Context<Self>) -> KeyContext {
 456        let mut dispatch_context = KeyContext::new_with_defaults();
 457        dispatch_context.add("GitPanel");
 458
 459        if self.is_focused(window, cx) {
 460            dispatch_context.add("menu");
 461            dispatch_context.add("ChangesList");
 462        }
 463
 464        if self.commit_editor.read(cx).is_focused(window) {
 465            dispatch_context.add("CommitEditor");
 466        }
 467
 468        dispatch_context
 469    }
 470
 471    fn is_focused(&self, window: &Window, cx: &Context<Self>) -> bool {
 472        window
 473            .focused(cx)
 474            .map_or(false, |focused| self.focus_handle == focused)
 475    }
 476
 477    fn close_panel(&mut self, _: &Close, _window: &mut Window, cx: &mut Context<Self>) {
 478        cx.emit(PanelEvent::Close);
 479    }
 480
 481    fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 482        if !self.focus_handle.contains_focused(window, cx) {
 483            cx.emit(Event::Focus);
 484        }
 485    }
 486
 487    fn show_scrollbar(&self, cx: &mut Context<Self>) -> ShowScrollbar {
 488        GitPanelSettings::get_global(cx)
 489            .scrollbar
 490            .show
 491            .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show)
 492    }
 493
 494    fn should_show_scrollbar(&self, cx: &mut Context<Self>) -> bool {
 495        let show = self.show_scrollbar(cx);
 496        match show {
 497            ShowScrollbar::Auto => true,
 498            ShowScrollbar::System => true,
 499            ShowScrollbar::Always => true,
 500            ShowScrollbar::Never => false,
 501        }
 502    }
 503
 504    fn should_autohide_scrollbar(&self, cx: &mut Context<Self>) -> bool {
 505        let show = self.show_scrollbar(cx);
 506        match show {
 507            ShowScrollbar::Auto => true,
 508            ShowScrollbar::System => cx
 509                .try_global::<ScrollbarAutoHide>()
 510                .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
 511            ShowScrollbar::Always => false,
 512            ShowScrollbar::Never => true,
 513        }
 514    }
 515
 516    fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 517        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
 518        if !self.should_autohide_scrollbar(cx) {
 519            return;
 520        }
 521        self.hide_scrollbar_task = Some(cx.spawn_in(window, |panel, mut cx| async move {
 522            cx.background_executor()
 523                .timer(SCROLLBAR_SHOW_INTERVAL)
 524                .await;
 525            panel
 526                .update(&mut cx, |panel, cx| {
 527                    panel.show_scrollbar = false;
 528                    cx.notify();
 529                })
 530                .log_err();
 531        }))
 532    }
 533
 534    fn handle_modifiers_changed(
 535        &mut self,
 536        event: &ModifiersChangedEvent,
 537        _: &mut Window,
 538        cx: &mut Context<Self>,
 539    ) {
 540        self.current_modifiers = event.modifiers;
 541        cx.notify();
 542    }
 543
 544    fn scroll_to_selected_entry(&mut self, cx: &mut Context<Self>) {
 545        if let Some(selected_entry) = self.selected_entry {
 546            self.scroll_handle
 547                .scroll_to_item(selected_entry, ScrollStrategy::Center);
 548        }
 549
 550        cx.notify();
 551    }
 552
 553    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
 554        if self.entries.first().is_some() {
 555            self.selected_entry = Some(1);
 556            self.scroll_to_selected_entry(cx);
 557        }
 558    }
 559
 560    fn select_prev(&mut self, _: &SelectPrev, _window: &mut Window, cx: &mut Context<Self>) {
 561        let item_count = self.entries.len();
 562        if item_count == 0 {
 563            return;
 564        }
 565
 566        if let Some(selected_entry) = self.selected_entry {
 567            let new_selected_entry = if selected_entry > 0 {
 568                selected_entry - 1
 569            } else {
 570                selected_entry
 571            };
 572
 573            if matches!(
 574                self.entries.get(new_selected_entry),
 575                Some(GitListEntry::Header(..))
 576            ) {
 577                if new_selected_entry > 0 {
 578                    self.selected_entry = Some(new_selected_entry - 1)
 579                }
 580            } else {
 581                self.selected_entry = Some(new_selected_entry);
 582            }
 583
 584            self.scroll_to_selected_entry(cx);
 585        }
 586
 587        cx.notify();
 588    }
 589
 590    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
 591        let item_count = self.entries.len();
 592        if item_count == 0 {
 593            return;
 594        }
 595
 596        if let Some(selected_entry) = self.selected_entry {
 597            let new_selected_entry = if selected_entry < item_count - 1 {
 598                selected_entry + 1
 599            } else {
 600                selected_entry
 601            };
 602            if matches!(
 603                self.entries.get(new_selected_entry),
 604                Some(GitListEntry::Header(..))
 605            ) {
 606                self.selected_entry = Some(new_selected_entry + 1);
 607            } else {
 608                self.selected_entry = Some(new_selected_entry);
 609            }
 610
 611            self.scroll_to_selected_entry(cx);
 612        }
 613
 614        cx.notify();
 615    }
 616
 617    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
 618        if self.entries.last().is_some() {
 619            self.selected_entry = Some(self.entries.len() - 1);
 620            self.scroll_to_selected_entry(cx);
 621        }
 622    }
 623
 624    pub(crate) fn editor_focus_handle(&self, cx: &mut Context<Self>) -> FocusHandle {
 625        self.commit_editor.focus_handle(cx).clone()
 626    }
 627
 628    fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
 629        self.commit_editor.update(cx, |editor, cx| {
 630            window.focus(&editor.focus_handle(cx));
 631        });
 632        cx.notify();
 633    }
 634
 635    fn select_first_entry_if_none(&mut self, cx: &mut Context<Self>) {
 636        let have_entries = self
 637            .active_repository
 638            .as_ref()
 639            .map_or(false, |active_repository| {
 640                active_repository.read(cx).entry_count() > 0
 641            });
 642        if have_entries && self.selected_entry.is_none() {
 643            self.selected_entry = Some(1);
 644            self.scroll_to_selected_entry(cx);
 645            cx.notify();
 646        }
 647    }
 648
 649    fn focus_changes_list(
 650        &mut self,
 651        _: &FocusChanges,
 652        window: &mut Window,
 653        cx: &mut Context<Self>,
 654    ) {
 655        self.select_first_entry_if_none(cx);
 656
 657        cx.focus_self(window);
 658        cx.notify();
 659    }
 660
 661    fn get_selected_entry(&self) -> Option<&GitListEntry> {
 662        self.selected_entry.and_then(|i| self.entries.get(i))
 663    }
 664
 665    fn open_diff(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
 666        maybe!({
 667            let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
 668
 669            self.workspace
 670                .update(cx, |workspace, cx| {
 671                    ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
 672                })
 673                .ok()
 674        });
 675    }
 676
 677    fn open_file(
 678        &mut self,
 679        _: &menu::SecondaryConfirm,
 680        window: &mut Window,
 681        cx: &mut Context<Self>,
 682    ) {
 683        maybe!({
 684            let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
 685            let active_repo = self.active_repository.as_ref()?;
 686            let path = active_repo
 687                .read(cx)
 688                .repo_path_to_project_path(&entry.repo_path)?;
 689            if entry.status.is_deleted() {
 690                return None;
 691            }
 692
 693            self.workspace
 694                .update(cx, |workspace, cx| {
 695                    workspace
 696                        .open_path_preview(path, None, false, false, true, window, cx)
 697                        .detach_and_prompt_err("Failed to open file", window, cx, |e, _, _| {
 698                            Some(format!("{e}"))
 699                        });
 700                })
 701                .ok()
 702        });
 703    }
 704
 705    fn revert_selected(
 706        &mut self,
 707        _: &git::RestoreFile,
 708        window: &mut Window,
 709        cx: &mut Context<Self>,
 710    ) {
 711        maybe!({
 712            let list_entry = self.entries.get(self.selected_entry?)?.clone();
 713            let entry = list_entry.status_entry()?;
 714            self.revert_entry(&entry, window, cx);
 715            Some(())
 716        });
 717    }
 718
 719    fn revert_entry(
 720        &mut self,
 721        entry: &GitStatusEntry,
 722        window: &mut Window,
 723        cx: &mut Context<Self>,
 724    ) {
 725        maybe!({
 726            let active_repo = self.active_repository.clone()?;
 727            let path = active_repo
 728                .read(cx)
 729                .repo_path_to_project_path(&entry.repo_path)?;
 730            let workspace = self.workspace.clone();
 731
 732            if entry.status.is_staged() != Some(false) {
 733                self.perform_stage(false, vec![entry.repo_path.clone()], cx);
 734            }
 735            let filename = path.path.file_name()?.to_string_lossy();
 736
 737            if !entry.status.is_created() {
 738                self.perform_checkout(vec![entry.repo_path.clone()], cx);
 739            } else {
 740                let prompt = prompt(&format!("Trash {}?", filename), None, window, cx);
 741                cx.spawn_in(window, |_, mut cx| async move {
 742                    match prompt.await? {
 743                        TrashCancel::Trash => {}
 744                        TrashCancel::Cancel => return Ok(()),
 745                    }
 746                    let task = workspace.update(&mut cx, |workspace, cx| {
 747                        workspace
 748                            .project()
 749                            .update(cx, |project, cx| project.delete_file(path, true, cx))
 750                    })?;
 751                    if let Some(task) = task {
 752                        task.await?;
 753                    }
 754                    Ok(())
 755                })
 756                .detach_and_prompt_err(
 757                    "Failed to trash file",
 758                    window,
 759                    cx,
 760                    |e, _, _| Some(format!("{e}")),
 761                );
 762            }
 763            Some(())
 764        });
 765    }
 766
 767    fn perform_checkout(&mut self, repo_paths: Vec<RepoPath>, cx: &mut Context<Self>) {
 768        let workspace = self.workspace.clone();
 769        let Some(active_repository) = self.active_repository.clone() else {
 770            return;
 771        };
 772
 773        let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1;
 774        self.pending.push(PendingOperation {
 775            op_id,
 776            target_status: TargetStatus::Reverted,
 777            repo_paths: repo_paths.iter().cloned().collect(),
 778            finished: false,
 779        });
 780        self.update_visible_entries(cx);
 781        let task = cx.spawn(|_, mut cx| async move {
 782            let tasks: Vec<_> = workspace.update(&mut cx, |workspace, cx| {
 783                workspace.project().update(cx, |project, cx| {
 784                    repo_paths
 785                        .iter()
 786                        .filter_map(|repo_path| {
 787                            let path = active_repository
 788                                .read(cx)
 789                                .repo_path_to_project_path(&repo_path)?;
 790                            Some(project.open_buffer(path, cx))
 791                        })
 792                        .collect()
 793                })
 794            })?;
 795
 796            let buffers = futures::future::join_all(tasks).await;
 797
 798            active_repository
 799                .update(&mut cx, |repo, _| repo.checkout_files("HEAD", repo_paths))?
 800                .await??;
 801
 802            let tasks: Vec<_> = cx.update(|cx| {
 803                buffers
 804                    .iter()
 805                    .filter_map(|buffer| {
 806                        buffer.as_ref().ok()?.update(cx, |buffer, cx| {
 807                            buffer.is_dirty().then(|| buffer.reload(cx))
 808                        })
 809                    })
 810                    .collect()
 811            })?;
 812
 813            futures::future::join_all(tasks).await;
 814
 815            Ok(())
 816        });
 817
 818        cx.spawn(|this, mut cx| async move {
 819            let result = task.await;
 820
 821            this.update(&mut cx, |this, cx| {
 822                for pending in this.pending.iter_mut() {
 823                    if pending.op_id == op_id {
 824                        pending.finished = true;
 825                        if result.is_err() {
 826                            pending.target_status = TargetStatus::Unchanged;
 827                            this.update_visible_entries(cx);
 828                        }
 829                        break;
 830                    }
 831                }
 832                result
 833                    .map_err(|e| {
 834                        this.show_err_toast(e, cx);
 835                    })
 836                    .ok();
 837            })
 838            .ok();
 839        })
 840        .detach();
 841    }
 842
 843    fn discard_tracked_changes(
 844        &mut self,
 845        _: &RestoreTrackedFiles,
 846        window: &mut Window,
 847        cx: &mut Context<Self>,
 848    ) {
 849        let entries = self
 850            .entries
 851            .iter()
 852            .filter_map(|entry| entry.status_entry().cloned())
 853            .filter(|status_entry| !status_entry.status.is_created())
 854            .collect::<Vec<_>>();
 855
 856        match entries.len() {
 857            0 => return,
 858            1 => return self.revert_entry(&entries[0], window, cx),
 859            _ => {}
 860        }
 861        let mut details = entries
 862            .iter()
 863            .filter_map(|entry| entry.repo_path.0.file_name())
 864            .map(|filename| filename.to_string_lossy())
 865            .take(5)
 866            .join("\n");
 867        if entries.len() > 5 {
 868            details.push_str(&format!("\nand {} more…", entries.len() - 5))
 869        }
 870
 871        #[derive(strum::EnumIter, strum::VariantNames)]
 872        #[strum(serialize_all = "title_case")]
 873        enum DiscardCancel {
 874            DiscardTrackedChanges,
 875            Cancel,
 876        }
 877        let prompt = prompt(
 878            "Discard changes to these files?",
 879            Some(&details),
 880            window,
 881            cx,
 882        );
 883        cx.spawn(|this, mut cx| async move {
 884            match prompt.await {
 885                Ok(DiscardCancel::DiscardTrackedChanges) => {
 886                    this.update(&mut cx, |this, cx| {
 887                        let repo_paths = entries.into_iter().map(|entry| entry.repo_path).collect();
 888                        this.perform_checkout(repo_paths, cx);
 889                    })
 890                    .ok();
 891                }
 892                _ => {
 893                    return;
 894                }
 895            }
 896        })
 897        .detach();
 898    }
 899
 900    fn clean_all(&mut self, _: &TrashUntrackedFiles, window: &mut Window, cx: &mut Context<Self>) {
 901        let workspace = self.workspace.clone();
 902        let Some(active_repo) = self.active_repository.clone() else {
 903            return;
 904        };
 905        let to_delete = self
 906            .entries
 907            .iter()
 908            .filter_map(|entry| entry.status_entry())
 909            .filter(|status_entry| status_entry.status.is_created())
 910            .cloned()
 911            .collect::<Vec<_>>();
 912
 913        match to_delete.len() {
 914            0 => return,
 915            1 => return self.revert_entry(&to_delete[0], window, cx),
 916            _ => {}
 917        };
 918
 919        let mut details = to_delete
 920            .iter()
 921            .map(|entry| {
 922                entry
 923                    .repo_path
 924                    .0
 925                    .file_name()
 926                    .map(|f| f.to_string_lossy())
 927                    .unwrap_or_default()
 928            })
 929            .take(5)
 930            .join("\n");
 931
 932        if to_delete.len() > 5 {
 933            details.push_str(&format!("\nand {} more…", to_delete.len() - 5))
 934        }
 935
 936        let prompt = prompt("Trash these files?", Some(&details), window, cx);
 937        cx.spawn_in(window, |this, mut cx| async move {
 938            match prompt.await? {
 939                TrashCancel::Trash => {}
 940                TrashCancel::Cancel => return Ok(()),
 941            }
 942            let tasks = workspace.update(&mut cx, |workspace, cx| {
 943                to_delete
 944                    .iter()
 945                    .filter_map(|entry| {
 946                        workspace.project().update(cx, |project, cx| {
 947                            let project_path = active_repo
 948                                .read(cx)
 949                                .repo_path_to_project_path(&entry.repo_path)?;
 950                            project.delete_file(project_path, true, cx)
 951                        })
 952                    })
 953                    .collect::<Vec<_>>()
 954            })?;
 955            let to_unstage = to_delete
 956                .into_iter()
 957                .filter_map(|entry| {
 958                    if entry.status.is_staged() != Some(false) {
 959                        Some(entry.repo_path.clone())
 960                    } else {
 961                        None
 962                    }
 963                })
 964                .collect();
 965            this.update(&mut cx, |this, cx| {
 966                this.perform_stage(false, to_unstage, cx)
 967            })?;
 968            for task in tasks {
 969                task.await?;
 970            }
 971            Ok(())
 972        })
 973        .detach_and_prompt_err("Failed to trash files", window, cx, |e, _, _| {
 974            Some(format!("{e}"))
 975        });
 976    }
 977
 978    fn stage_all(&mut self, _: &StageAll, _window: &mut Window, cx: &mut Context<Self>) {
 979        let repo_paths = self
 980            .entries
 981            .iter()
 982            .filter_map(|entry| entry.status_entry())
 983            .filter(|status_entry| status_entry.is_staged != Some(true))
 984            .map(|status_entry| status_entry.repo_path.clone())
 985            .collect::<Vec<_>>();
 986        self.perform_stage(true, repo_paths, cx);
 987    }
 988
 989    fn unstage_all(&mut self, _: &UnstageAll, _window: &mut Window, cx: &mut Context<Self>) {
 990        let repo_paths = self
 991            .entries
 992            .iter()
 993            .filter_map(|entry| entry.status_entry())
 994            .filter(|status_entry| status_entry.is_staged != Some(false))
 995            .map(|status_entry| status_entry.repo_path.clone())
 996            .collect::<Vec<_>>();
 997        self.perform_stage(false, repo_paths, cx);
 998    }
 999
1000    fn toggle_staged_for_entry(
1001        &mut self,
1002        entry: &GitListEntry,
1003        _window: &mut Window,
1004        cx: &mut Context<Self>,
1005    ) {
1006        let Some(active_repository) = self.active_repository.as_ref() else {
1007            return;
1008        };
1009        let (stage, repo_paths) = match entry {
1010            GitListEntry::GitStatusEntry(status_entry) => {
1011                if status_entry.status.is_staged().unwrap_or(false) {
1012                    (false, vec![status_entry.repo_path.clone()])
1013                } else {
1014                    (true, vec![status_entry.repo_path.clone()])
1015                }
1016            }
1017            GitListEntry::Header(section) => {
1018                let goal_staged_state = !self.header_state(section.header).selected();
1019                let repository = active_repository.read(cx);
1020                let entries = self
1021                    .entries
1022                    .iter()
1023                    .filter_map(|entry| entry.status_entry())
1024                    .filter(|status_entry| {
1025                        section.contains(&status_entry, repository)
1026                            && status_entry.is_staged != Some(goal_staged_state)
1027                    })
1028                    .map(|status_entry| status_entry.repo_path.clone())
1029                    .collect::<Vec<_>>();
1030
1031                (goal_staged_state, entries)
1032            }
1033        };
1034        self.perform_stage(stage, repo_paths, cx);
1035    }
1036
1037    fn perform_stage(&mut self, stage: bool, repo_paths: Vec<RepoPath>, cx: &mut Context<Self>) {
1038        let Some(active_repository) = self.active_repository.clone() else {
1039            return;
1040        };
1041        let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1;
1042        self.pending.push(PendingOperation {
1043            op_id,
1044            target_status: if stage {
1045                TargetStatus::Staged
1046            } else {
1047                TargetStatus::Unstaged
1048            },
1049            repo_paths: repo_paths.iter().cloned().collect(),
1050            finished: false,
1051        });
1052        let repo_paths = repo_paths.clone();
1053        let repository = active_repository.read(cx);
1054        self.update_counts(repository);
1055        cx.notify();
1056
1057        cx.spawn({
1058            |this, mut cx| async move {
1059                let result = cx
1060                    .update(|cx| {
1061                        if stage {
1062                            active_repository
1063                                .update(cx, |repo, cx| repo.stage_entries(repo_paths.clone(), cx))
1064                        } else {
1065                            active_repository
1066                                .update(cx, |repo, cx| repo.unstage_entries(repo_paths.clone(), cx))
1067                        }
1068                    })?
1069                    .await;
1070
1071                this.update(&mut cx, |this, cx| {
1072                    for pending in this.pending.iter_mut() {
1073                        if pending.op_id == op_id {
1074                            pending.finished = true
1075                        }
1076                    }
1077                    result
1078                        .map_err(|e| {
1079                            this.show_err_toast(e, cx);
1080                        })
1081                        .ok();
1082                    cx.notify();
1083                })
1084            }
1085        })
1086        .detach();
1087    }
1088
1089    pub fn total_staged_count(&self) -> usize {
1090        self.tracked_staged_count + self.new_staged_count + self.conflicted_staged_count
1091    }
1092
1093    pub fn commit_message_buffer(&self, cx: &App) -> Entity<Buffer> {
1094        self.commit_editor
1095            .read(cx)
1096            .buffer()
1097            .read(cx)
1098            .as_singleton()
1099            .unwrap()
1100            .clone()
1101    }
1102
1103    fn toggle_staged_for_selected(
1104        &mut self,
1105        _: &git::ToggleStaged,
1106        window: &mut Window,
1107        cx: &mut Context<Self>,
1108    ) {
1109        if let Some(selected_entry) = self.get_selected_entry().cloned() {
1110            self.toggle_staged_for_entry(&selected_entry, window, cx);
1111        }
1112    }
1113
1114    fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
1115        if self
1116            .commit_editor
1117            .focus_handle(cx)
1118            .contains_focused(window, cx)
1119        {
1120            self.commit_changes(window, cx)
1121        } else {
1122            cx.propagate();
1123        }
1124    }
1125
1126    pub(crate) fn commit_changes(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1127        let Some(active_repository) = self.active_repository.clone() else {
1128            return;
1129        };
1130        let error_spawn = |message, window: &mut Window, cx: &mut App| {
1131            let prompt = window.prompt(PromptLevel::Warning, message, None, &["Ok"], cx);
1132            cx.spawn(|_| async move {
1133                prompt.await.ok();
1134            })
1135            .detach();
1136        };
1137
1138        if self.has_unstaged_conflicts() {
1139            error_spawn(
1140                "There are still conflicts. You must stage these before committing",
1141                window,
1142                cx,
1143            );
1144            return;
1145        }
1146
1147        let mut message = self.commit_editor.read(cx).text(cx);
1148        if message.trim().is_empty() {
1149            self.commit_editor.read(cx).focus_handle(cx).focus(window);
1150            return;
1151        }
1152        if self.add_coauthors {
1153            self.fill_co_authors(&mut message, cx);
1154        }
1155
1156        let task = if self.has_staged_changes() {
1157            // Repository serializes all git operations, so we can just send a commit immediately
1158            let commit_task = active_repository.read(cx).commit(message.into(), None);
1159            cx.background_spawn(async move { commit_task.await? })
1160        } else {
1161            let changed_files = self
1162                .entries
1163                .iter()
1164                .filter_map(|entry| entry.status_entry())
1165                .filter(|status_entry| !status_entry.status.is_created())
1166                .map(|status_entry| status_entry.repo_path.clone())
1167                .collect::<Vec<_>>();
1168
1169            if changed_files.is_empty() {
1170                error_spawn("No changes to commit", window, cx);
1171                return;
1172            }
1173
1174            let stage_task =
1175                active_repository.update(cx, |repo, cx| repo.stage_entries(changed_files, cx));
1176            cx.spawn(|_, mut cx| async move {
1177                stage_task.await?;
1178                let commit_task = active_repository
1179                    .update(&mut cx, |repo, _| repo.commit(message.into(), None))?;
1180                commit_task.await?
1181            })
1182        };
1183        let task = cx.spawn_in(window, |this, mut cx| async move {
1184            let result = task.await;
1185            this.update_in(&mut cx, |this, window, cx| {
1186                this.pending_commit.take();
1187                match result {
1188                    Ok(()) => {
1189                        this.commit_editor
1190                            .update(cx, |editor, cx| editor.clear(window, cx));
1191                    }
1192                    Err(e) => this.show_err_toast(e, cx),
1193                }
1194            })
1195            .ok();
1196        });
1197
1198        self.pending_commit = Some(task);
1199    }
1200
1201    fn uncommit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1202        let Some(repo) = self.active_repository.clone() else {
1203            return;
1204        };
1205
1206        // TODO: Use git merge-base to find the upstream and main branch split
1207        let confirmation = Task::ready(true);
1208        // let confirmation = if self.commit_editor.read(cx).is_empty(cx) {
1209        //     Task::ready(true)
1210        // } else {
1211        //     let prompt = window.prompt(
1212        //         PromptLevel::Warning,
1213        //         "Uncomitting will replace the current commit message with the previous commit's message",
1214        //         None,
1215        //         &["Ok", "Cancel"],
1216        //         cx,
1217        //     );
1218        //     cx.spawn(|_, _| async move { prompt.await.is_ok_and(|i| i == 0) })
1219        // };
1220
1221        let prior_head = self.load_commit_details("HEAD", cx);
1222
1223        let task = cx.spawn_in(window, |this, mut cx| async move {
1224            let result = maybe!(async {
1225                if !confirmation.await {
1226                    Ok(None)
1227                } else {
1228                    let prior_head = prior_head.await?;
1229
1230                    repo.update(&mut cx, |repo, _| repo.reset("HEAD^", ResetMode::Soft))?
1231                        .await??;
1232
1233                    Ok(Some(prior_head))
1234                }
1235            })
1236            .await;
1237
1238            this.update_in(&mut cx, |this, window, cx| {
1239                this.pending_commit.take();
1240                match result {
1241                    Ok(None) => {}
1242                    Ok(Some(prior_commit)) => {
1243                        this.commit_editor.update(cx, |editor, cx| {
1244                            editor.set_text(prior_commit.message, window, cx)
1245                        });
1246                    }
1247                    Err(e) => this.show_err_toast(e, cx),
1248                }
1249            })
1250            .ok();
1251        });
1252
1253        self.pending_commit = Some(task);
1254    }
1255
1256    /// Suggests a commit message based on the changed files and their statuses
1257    pub fn suggest_commit_message(&self) -> Option<String> {
1258        let entries = self
1259            .entries
1260            .iter()
1261            .filter_map(|entry| {
1262                if let GitListEntry::GitStatusEntry(status_entry) = entry {
1263                    Some(status_entry)
1264                } else {
1265                    None
1266                }
1267            })
1268            .collect::<Vec<&GitStatusEntry>>();
1269
1270        if entries.is_empty() {
1271            None
1272        } else if entries.len() == 1 {
1273            let entry = &entries[0];
1274            let file_name = entry
1275                .repo_path
1276                .file_name()
1277                .unwrap_or_default()
1278                .to_string_lossy();
1279
1280            if entry.status.is_deleted() {
1281                Some(format!("Delete {}", file_name))
1282            } else if entry.status.is_created() {
1283                Some(format!("Create {}", file_name))
1284            } else if entry.status.is_modified() {
1285                Some(format!("Update {}", file_name))
1286            } else {
1287                None
1288            }
1289        } else {
1290            None
1291        }
1292    }
1293
1294    fn update_editor_placeholder(&mut self, cx: &mut Context<Self>) {
1295        let suggested_commit_message = self.suggest_commit_message();
1296        self.suggested_commit_message = suggested_commit_message.clone();
1297
1298        if let Some(suggested_commit_message) = suggested_commit_message {
1299            self.commit_editor.update(cx, |editor, cx| {
1300                editor.set_placeholder_text(Arc::from(suggested_commit_message), cx)
1301            });
1302        }
1303
1304        cx.notify();
1305    }
1306
1307    fn fetch(&mut self, _: &git::Fetch, _window: &mut Window, cx: &mut Context<Self>) {
1308        let Some(repo) = self.active_repository.clone() else {
1309            return;
1310        };
1311        let guard = self.start_remote_operation();
1312        let fetch = repo.read(cx).fetch();
1313        cx.spawn(|_, _| async move {
1314            fetch.await??;
1315            drop(guard);
1316            anyhow::Ok(())
1317        })
1318        .detach_and_log_err(cx);
1319    }
1320
1321    fn pull(&mut self, _: &git::Pull, window: &mut Window, cx: &mut Context<Self>) {
1322        let guard = self.start_remote_operation();
1323        let remote = self.get_current_remote(window, cx);
1324        cx.spawn(move |this, mut cx| async move {
1325            let remote = remote.await?;
1326
1327            this.update(&mut cx, |this, cx| {
1328                let Some(repo) = this.active_repository.clone() else {
1329                    return Err(anyhow::anyhow!("No active repository"));
1330                };
1331
1332                let Some(branch) = repo.read(cx).current_branch() else {
1333                    return Err(anyhow::anyhow!("No active branch"));
1334                };
1335
1336                Ok(repo.read(cx).pull(branch.name.clone(), remote.name))
1337            })??
1338            .await??;
1339
1340            drop(guard);
1341            anyhow::Ok(())
1342        })
1343        .detach_and_log_err(cx);
1344    }
1345
1346    fn push(&mut self, action: &git::Push, window: &mut Window, cx: &mut Context<Self>) {
1347        let guard = self.start_remote_operation();
1348        let options = action.options;
1349        let remote = self.get_current_remote(window, cx);
1350        cx.spawn(move |this, mut cx| async move {
1351            let remote = remote.await?;
1352
1353            this.update(&mut cx, |this, cx| {
1354                let Some(repo) = this.active_repository.clone() else {
1355                    return Err(anyhow::anyhow!("No active repository"));
1356                };
1357
1358                let Some(branch) = repo.read(cx).current_branch() else {
1359                    return Err(anyhow::anyhow!("No active branch"));
1360                };
1361
1362                Ok(repo
1363                    .read(cx)
1364                    .push(branch.name.clone(), remote.name, options))
1365            })??
1366            .await??;
1367
1368            drop(guard);
1369            anyhow::Ok(())
1370        })
1371        .detach_and_log_err(cx);
1372    }
1373
1374    fn get_current_remote(
1375        &mut self,
1376        window: &mut Window,
1377        cx: &mut Context<Self>,
1378    ) -> impl Future<Output = Result<Remote>> {
1379        let repo = self.active_repository.clone();
1380        let workspace = self.workspace.clone();
1381        let mut cx = window.to_async(cx);
1382
1383        async move {
1384            let Some(repo) = repo else {
1385                return Err(anyhow::anyhow!("No active repository"));
1386            };
1387
1388            let mut current_remotes: Vec<Remote> = repo
1389                .update(&mut cx, |repo, _| {
1390                    let Some(current_branch) = repo.current_branch() else {
1391                        return Err(anyhow::anyhow!("No active branch"));
1392                    };
1393
1394                    Ok(repo.get_remotes(Some(current_branch.name.to_string())))
1395                })??
1396                .await??;
1397
1398            if current_remotes.len() == 0 {
1399                return Err(anyhow::anyhow!("No active remote"));
1400            } else if current_remotes.len() == 1 {
1401                return Ok(current_remotes.pop().unwrap());
1402            } else {
1403                let current_remotes: Vec<_> = current_remotes
1404                    .into_iter()
1405                    .map(|remotes| remotes.name)
1406                    .collect();
1407                let selection = cx
1408                    .update(|window, cx| {
1409                        picker_prompt::prompt(
1410                            "Pick which remote to push to",
1411                            current_remotes.clone(),
1412                            workspace,
1413                            window,
1414                            cx,
1415                        )
1416                    })?
1417                    .await?;
1418
1419                return Ok(Remote {
1420                    name: current_remotes[selection].clone(),
1421                });
1422            }
1423        }
1424    }
1425
1426    fn potential_co_authors(&self, cx: &App) -> Vec<(String, String)> {
1427        let mut new_co_authors = Vec::new();
1428        let project = self.project.read(cx);
1429
1430        let Some(room) = self
1431            .workspace
1432            .upgrade()
1433            .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned())
1434        else {
1435            return Vec::default();
1436        };
1437
1438        let room = room.read(cx);
1439
1440        for (peer_id, collaborator) in project.collaborators() {
1441            if collaborator.is_host {
1442                continue;
1443            }
1444
1445            let Some(participant) = room.remote_participant_for_peer_id(*peer_id) else {
1446                continue;
1447            };
1448            if participant.can_write() && participant.user.email.is_some() {
1449                let email = participant.user.email.clone().unwrap();
1450
1451                new_co_authors.push((
1452                    participant
1453                        .user
1454                        .name
1455                        .clone()
1456                        .unwrap_or_else(|| participant.user.github_login.clone()),
1457                    email,
1458                ))
1459            }
1460        }
1461        if !project.is_local() && !project.is_read_only(cx) {
1462            if let Some(user) = room.local_participant_user(cx) {
1463                if let Some(email) = user.email.clone() {
1464                    new_co_authors.push((
1465                        user.name
1466                            .clone()
1467                            .unwrap_or_else(|| user.github_login.clone()),
1468                        email.clone(),
1469                    ))
1470                }
1471            }
1472        }
1473        new_co_authors
1474    }
1475
1476    fn toggle_fill_co_authors(
1477        &mut self,
1478        _: &ToggleFillCoAuthors,
1479        _: &mut Window,
1480        cx: &mut Context<Self>,
1481    ) {
1482        self.add_coauthors = !self.add_coauthors;
1483        cx.notify();
1484    }
1485
1486    fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context<Self>) {
1487        const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
1488
1489        let existing_text = message.to_ascii_lowercase();
1490        let lowercase_co_author_prefix = CO_AUTHOR_PREFIX.to_lowercase();
1491        let mut ends_with_co_authors = false;
1492        let existing_co_authors = existing_text
1493            .lines()
1494            .filter_map(|line| {
1495                let line = line.trim();
1496                if line.starts_with(&lowercase_co_author_prefix) {
1497                    ends_with_co_authors = true;
1498                    Some(line)
1499                } else {
1500                    ends_with_co_authors = false;
1501                    None
1502                }
1503            })
1504            .collect::<HashSet<_>>();
1505
1506        let new_co_authors = self
1507            .potential_co_authors(cx)
1508            .into_iter()
1509            .filter(|(_, email)| {
1510                !existing_co_authors
1511                    .iter()
1512                    .any(|existing| existing.contains(email.as_str()))
1513            })
1514            .collect::<Vec<_>>();
1515
1516        if new_co_authors.is_empty() {
1517            return;
1518        }
1519
1520        if !ends_with_co_authors {
1521            message.push('\n');
1522        }
1523        for (name, email) in new_co_authors {
1524            message.push('\n');
1525            message.push_str(CO_AUTHOR_PREFIX);
1526            message.push_str(&name);
1527            message.push_str(" <");
1528            message.push_str(&email);
1529            message.push('>');
1530        }
1531        message.push('\n');
1532    }
1533
1534    fn schedule_update(
1535        &mut self,
1536        clear_pending: bool,
1537        window: &mut Window,
1538        cx: &mut Context<Self>,
1539    ) {
1540        let handle = cx.entity().downgrade();
1541        self.reopen_commit_buffer(window, cx);
1542        self.update_visible_entries_task = cx.spawn_in(window, |_, mut cx| async move {
1543            cx.background_executor().timer(UPDATE_DEBOUNCE).await;
1544            if let Some(git_panel) = handle.upgrade() {
1545                git_panel
1546                    .update_in(&mut cx, |git_panel, _, cx| {
1547                        if clear_pending {
1548                            git_panel.clear_pending();
1549                        }
1550                        git_panel.update_visible_entries(cx);
1551                        git_panel.update_editor_placeholder(cx);
1552                    })
1553                    .ok();
1554            }
1555        });
1556    }
1557
1558    fn reopen_commit_buffer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1559        let Some(active_repo) = self.active_repository.as_ref() else {
1560            return;
1561        };
1562        let load_buffer = active_repo.update(cx, |active_repo, cx| {
1563            let project = self.project.read(cx);
1564            active_repo.open_commit_buffer(
1565                Some(project.languages().clone()),
1566                project.buffer_store().clone(),
1567                cx,
1568            )
1569        });
1570
1571        cx.spawn_in(window, |git_panel, mut cx| async move {
1572            let buffer = load_buffer.await?;
1573            git_panel.update_in(&mut cx, |git_panel, window, cx| {
1574                if git_panel
1575                    .commit_editor
1576                    .read(cx)
1577                    .buffer()
1578                    .read(cx)
1579                    .as_singleton()
1580                    .as_ref()
1581                    != Some(&buffer)
1582                {
1583                    git_panel.commit_editor = cx.new(|cx| {
1584                        commit_message_editor(buffer, git_panel.project.clone(), true, window, cx)
1585                    });
1586                }
1587            })
1588        })
1589        .detach_and_log_err(cx);
1590    }
1591
1592    fn clear_pending(&mut self) {
1593        self.pending.retain(|v| !v.finished)
1594    }
1595
1596    fn update_visible_entries(&mut self, cx: &mut Context<Self>) {
1597        self.entries.clear();
1598        let mut changed_entries = Vec::new();
1599        let mut new_entries = Vec::new();
1600        let mut conflict_entries = Vec::new();
1601
1602        let Some(repo) = self.active_repository.as_ref() else {
1603            // Just clear entries if no repository is active.
1604            cx.notify();
1605            return;
1606        };
1607
1608        // First pass - collect all paths
1609        let repo = repo.read(cx);
1610
1611        // Second pass - create entries with proper depth calculation
1612        for entry in repo.status() {
1613            let is_conflict = repo.has_conflict(&entry.repo_path);
1614            let is_new = entry.status.is_created();
1615            let is_staged = entry.status.is_staged();
1616
1617            if self.pending.iter().any(|pending| {
1618                pending.target_status == TargetStatus::Reverted
1619                    && !pending.finished
1620                    && pending.repo_paths.contains(&entry.repo_path)
1621            }) {
1622                continue;
1623            }
1624
1625            let entry = GitStatusEntry {
1626                repo_path: entry.repo_path.clone(),
1627                status: entry.status,
1628                is_staged,
1629            };
1630
1631            if is_conflict {
1632                conflict_entries.push(entry);
1633            } else if is_new {
1634                new_entries.push(entry);
1635            } else {
1636                changed_entries.push(entry);
1637            }
1638        }
1639
1640        if conflict_entries.len() > 0 {
1641            self.entries.push(GitListEntry::Header(GitHeaderEntry {
1642                header: Section::Conflict,
1643            }));
1644            self.entries.extend(
1645                conflict_entries
1646                    .into_iter()
1647                    .map(GitListEntry::GitStatusEntry),
1648            );
1649        }
1650
1651        if changed_entries.len() > 0 {
1652            self.entries.push(GitListEntry::Header(GitHeaderEntry {
1653                header: Section::Tracked,
1654            }));
1655            self.entries.extend(
1656                changed_entries
1657                    .into_iter()
1658                    .map(GitListEntry::GitStatusEntry),
1659            );
1660        }
1661        if new_entries.len() > 0 {
1662            self.entries.push(GitListEntry::Header(GitHeaderEntry {
1663                header: Section::New,
1664            }));
1665            self.entries
1666                .extend(new_entries.into_iter().map(GitListEntry::GitStatusEntry));
1667        }
1668
1669        self.update_counts(repo);
1670
1671        self.select_first_entry_if_none(cx);
1672
1673        cx.notify();
1674    }
1675
1676    fn header_state(&self, header_type: Section) -> ToggleState {
1677        let (staged_count, count) = match header_type {
1678            Section::New => (self.new_staged_count, self.new_count),
1679            Section::Tracked => (self.tracked_staged_count, self.tracked_count),
1680            Section::Conflict => (self.conflicted_staged_count, self.conflicted_count),
1681        };
1682        if staged_count == 0 {
1683            ToggleState::Unselected
1684        } else if count == staged_count {
1685            ToggleState::Selected
1686        } else {
1687            ToggleState::Indeterminate
1688        }
1689    }
1690
1691    fn update_counts(&mut self, repo: &Repository) {
1692        self.conflicted_count = 0;
1693        self.conflicted_staged_count = 0;
1694        self.new_count = 0;
1695        self.tracked_count = 0;
1696        self.new_staged_count = 0;
1697        self.tracked_staged_count = 0;
1698        for entry in &self.entries {
1699            let Some(status_entry) = entry.status_entry() else {
1700                continue;
1701            };
1702            if repo.has_conflict(&status_entry.repo_path) {
1703                self.conflicted_count += 1;
1704                if self.entry_is_staged(status_entry) != Some(false) {
1705                    self.conflicted_staged_count += 1;
1706                }
1707            } else if status_entry.status.is_created() {
1708                self.new_count += 1;
1709                if self.entry_is_staged(status_entry) != Some(false) {
1710                    self.new_staged_count += 1;
1711                }
1712            } else {
1713                self.tracked_count += 1;
1714                if self.entry_is_staged(status_entry) != Some(false) {
1715                    self.tracked_staged_count += 1;
1716                }
1717            }
1718        }
1719    }
1720
1721    fn entry_is_staged(&self, entry: &GitStatusEntry) -> Option<bool> {
1722        for pending in self.pending.iter().rev() {
1723            if pending.repo_paths.contains(&entry.repo_path) {
1724                match pending.target_status {
1725                    TargetStatus::Staged => return Some(true),
1726                    TargetStatus::Unstaged => return Some(false),
1727                    TargetStatus::Reverted => continue,
1728                    TargetStatus::Unchanged => continue,
1729                }
1730            }
1731        }
1732        entry.is_staged
1733    }
1734
1735    pub(crate) fn has_staged_changes(&self) -> bool {
1736        self.tracked_staged_count > 0
1737            || self.new_staged_count > 0
1738            || self.conflicted_staged_count > 0
1739    }
1740
1741    pub(crate) fn has_unstaged_changes(&self) -> bool {
1742        self.tracked_count > self.tracked_staged_count
1743            || self.new_count > self.new_staged_count
1744            || self.conflicted_count > self.conflicted_staged_count
1745    }
1746
1747    fn has_conflicts(&self) -> bool {
1748        self.conflicted_count > 0
1749    }
1750
1751    fn has_tracked_changes(&self) -> bool {
1752        self.tracked_count > 0
1753    }
1754
1755    pub fn has_unstaged_conflicts(&self) -> bool {
1756        self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count
1757    }
1758
1759    fn show_err_toast(&self, e: anyhow::Error, cx: &mut App) {
1760        let Some(workspace) = self.workspace.upgrade() else {
1761            return;
1762        };
1763        let notif_id = NotificationId::Named("git-operation-error".into());
1764
1765        let message = e.to_string();
1766        workspace.update(cx, |workspace, cx| {
1767            let toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| {
1768                window.dispatch_action(workspace::OpenLog.boxed_clone(), cx);
1769            });
1770            workspace.show_toast(toast, cx);
1771        });
1772    }
1773
1774    pub fn panel_button(
1775        &self,
1776        id: impl Into<SharedString>,
1777        label: impl Into<SharedString>,
1778    ) -> Button {
1779        let id = id.into().clone();
1780        let label = label.into().clone();
1781
1782        Button::new(id, label)
1783            .label_size(LabelSize::Small)
1784            .layer(ElevationIndex::ElevatedSurface)
1785            .size(ButtonSize::Compact)
1786            .style(ButtonStyle::Filled)
1787    }
1788
1789    pub fn indent_size(&self, window: &Window, cx: &mut Context<Self>) -> Pixels {
1790        Checkbox::container_size(cx).to_pixels(window.rem_size())
1791    }
1792
1793    pub fn render_divider(&self, _cx: &mut Context<Self>) -> impl IntoElement {
1794        h_flex()
1795            .items_center()
1796            .h(px(8.))
1797            .child(Divider::horizontal_dashed().color(DividerColor::Border))
1798    }
1799
1800    pub fn render_panel_header(
1801        &self,
1802        window: &mut Window,
1803        cx: &mut Context<Self>,
1804    ) -> Option<impl IntoElement> {
1805        let all_repositories = self
1806            .project
1807            .read(cx)
1808            .git_store()
1809            .read(cx)
1810            .all_repositories();
1811
1812        let has_repo_above = all_repositories.iter().any(|repo| {
1813            repo.read(cx)
1814                .repository_entry
1815                .work_directory
1816                .is_above_project()
1817        });
1818
1819        let has_visible_repo = all_repositories.len() > 0 || has_repo_above;
1820
1821        if has_visible_repo {
1822            Some(
1823                self.panel_header_container(window, cx)
1824                    .child(
1825                        Label::new("Repository")
1826                            .size(LabelSize::Small)
1827                            .color(Color::Muted),
1828                    )
1829                    .child(self.render_repository_selector(cx))
1830                    .child(div().flex_grow()) // spacer
1831                    .child(
1832                        div()
1833                            .h_flex()
1834                            .gap_1()
1835                            .children(self.render_spinner(cx))
1836                            .children(self.render_sync_button(cx))
1837                            .children(self.render_pull_button(cx))
1838                            .child(
1839                                Button::new("diff", "+/-")
1840                                    .tooltip(Tooltip::for_action_title("Open diff", &Diff))
1841                                    .on_click(|_, _, cx| {
1842                                        cx.defer(|cx| {
1843                                            cx.dispatch_action(&Diff);
1844                                        })
1845                                    }),
1846                            )
1847                            .child(self.render_overflow_menu()),
1848                    ),
1849            )
1850        } else {
1851            None
1852        }
1853    }
1854
1855    pub fn render_spinner(&self, _cx: &mut Context<Self>) -> Option<impl IntoElement> {
1856        (!self.pending_remote_operations.borrow().is_empty()).then(|| {
1857            Icon::new(IconName::ArrowCircle)
1858                .size(IconSize::XSmall)
1859                .color(Color::Info)
1860                .with_animation(
1861                    "arrow-circle",
1862                    Animation::new(Duration::from_secs(2)).repeat(),
1863                    |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
1864                )
1865                .into_any_element()
1866        })
1867    }
1868
1869    pub fn render_overflow_menu(&self) -> impl IntoElement {
1870        PopoverMenu::new("overflow-menu")
1871            .trigger(IconButton::new("overflow-menu-trigger", IconName::Ellipsis))
1872            .menu(move |window, cx| Some(Self::panel_context_menu(window, cx)))
1873            .anchor(Corner::TopRight)
1874    }
1875
1876    pub fn render_sync_button(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
1877        let active_repository = self.project.read(cx).active_repository(cx);
1878        active_repository.as_ref().map(|_| {
1879            panel_filled_button("Fetch")
1880                .icon(IconName::ArrowCircle)
1881                .icon_size(IconSize::Small)
1882                .icon_color(Color::Muted)
1883                .icon_position(IconPosition::Start)
1884                .tooltip(Tooltip::for_action_title("git fetch", &git::Fetch))
1885                .on_click(
1886                    cx.listener(move |this, _, window, cx| this.fetch(&git::Fetch, window, cx)),
1887                )
1888                .into_any_element()
1889        })
1890    }
1891
1892    pub fn render_pull_button(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
1893        let active_repository = self.project.read(cx).active_repository(cx);
1894        active_repository
1895            .as_ref()
1896            .and_then(|repo| repo.read(cx).current_branch())
1897            .and_then(|branch| {
1898                branch.upstream.as_ref().map(|upstream| {
1899                    let status = &upstream.tracking;
1900
1901                    let disabled = status.is_gone();
1902
1903                    panel_filled_button(match status {
1904                        git::repository::UpstreamTracking::Tracked(status) if status.behind > 0 => {
1905                            format!("Pull ({})", status.behind)
1906                        }
1907                        _ => "Pull".to_string(),
1908                    })
1909                    .icon(IconName::ArrowDown)
1910                    .icon_size(IconSize::Small)
1911                    .icon_color(Color::Muted)
1912                    .icon_position(IconPosition::Start)
1913                    .disabled(status.is_gone())
1914                    .tooltip(move |window, cx| {
1915                        if disabled {
1916                            Tooltip::simple("Upstream is gone", cx)
1917                        } else {
1918                            // TODO: Add <origin> and <branch> argument substitutions to this
1919                            Tooltip::for_action("git pull", &git::Pull, window, cx)
1920                        }
1921                    })
1922                    .on_click(
1923                        cx.listener(move |this, _, window, cx| this.pull(&git::Pull, window, cx)),
1924                    )
1925                    .into_any_element()
1926                })
1927            })
1928    }
1929
1930    pub fn render_repository_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
1931        let active_repository = self.project.read(cx).active_repository(cx);
1932        let repository_display_name = active_repository
1933            .as_ref()
1934            .map(|repo| repo.read(cx).display_name(self.project.read(cx), cx))
1935            .unwrap_or_default();
1936
1937        RepositorySelectorPopoverMenu::new(
1938            self.repository_selector.clone(),
1939            ButtonLike::new("active-repository")
1940                .style(ButtonStyle::Subtle)
1941                .child(Label::new(repository_display_name).size(LabelSize::Small)),
1942            Tooltip::text("Select a repository"),
1943        )
1944    }
1945
1946    pub fn can_commit(&self) -> bool {
1947        (self.has_staged_changes() || self.has_tracked_changes()) && !self.has_unstaged_conflicts()
1948    }
1949
1950    pub fn can_stage_all(&self) -> bool {
1951        self.has_unstaged_changes()
1952    }
1953
1954    pub fn can_unstage_all(&self) -> bool {
1955        self.has_staged_changes()
1956    }
1957
1958    pub(crate) fn render_co_authors(&self, cx: &Context<Self>) -> Option<AnyElement> {
1959        let potential_co_authors = self.potential_co_authors(cx);
1960        if potential_co_authors.is_empty() {
1961            None
1962        } else {
1963            Some(
1964                IconButton::new("co-authors", IconName::Person)
1965                    .icon_color(Color::Disabled)
1966                    .selected_icon_color(Color::Selected)
1967                    .toggle_state(self.add_coauthors)
1968                    .tooltip(move |_, cx| {
1969                        let title = format!(
1970                            "Add co-authored-by:{}{}",
1971                            if potential_co_authors.len() == 1 {
1972                                ""
1973                            } else {
1974                                "\n"
1975                            },
1976                            potential_co_authors
1977                                .iter()
1978                                .map(|(name, email)| format!(" {} <{}>", name, email))
1979                                .join("\n")
1980                        );
1981                        Tooltip::simple(title, cx)
1982                    })
1983                    .on_click(cx.listener(|this, _, _, cx| {
1984                        this.add_coauthors = !this.add_coauthors;
1985                        cx.notify();
1986                    }))
1987                    .into_any_element(),
1988            )
1989        }
1990    }
1991
1992    pub fn render_commit_editor(
1993        &self,
1994        window: &mut Window,
1995        cx: &mut Context<Self>,
1996    ) -> impl IntoElement {
1997        let editor = self.commit_editor.clone();
1998        let can_commit = self.can_commit()
1999            && self.pending_commit.is_none()
2000            && !editor.read(cx).is_empty(cx)
2001            && self.has_write_access(cx);
2002
2003        let panel_editor_style = panel_editor_style(true, window, cx);
2004        let enable_coauthors = self.render_co_authors(cx);
2005
2006        let tooltip = if self.has_staged_changes() {
2007            "git commit"
2008        } else {
2009            "git commit --all"
2010        };
2011        let title = if self.has_staged_changes() {
2012            "Commit"
2013        } else {
2014            "Commit Tracked"
2015        };
2016        let editor_focus_handle = self.commit_editor.focus_handle(cx);
2017
2018        let commit_button = panel_filled_button(title)
2019            .tooltip(move |window, cx| {
2020                Tooltip::for_action_in(tooltip, &Commit, &editor_focus_handle, window, cx)
2021            })
2022            .disabled(!can_commit)
2023            .on_click({
2024                cx.listener(move |this, _: &ClickEvent, window, cx| this.commit_changes(window, cx))
2025            });
2026
2027        let branch = self
2028            .active_repository
2029            .as_ref()
2030            .and_then(|repo| repo.read(cx).current_branch().map(|b| b.name.clone()))
2031            .unwrap_or_else(|| "<no branch>".into());
2032
2033        let branch_selector = Button::new("branch-selector", branch)
2034            .color(Color::Muted)
2035            .style(ButtonStyle::Subtle)
2036            .icon(IconName::GitBranch)
2037            .icon_size(IconSize::Small)
2038            .icon_color(Color::Muted)
2039            .size(ButtonSize::Compact)
2040            .icon_position(IconPosition::Start)
2041            .tooltip(Tooltip::for_action_title(
2042                "Switch Branch",
2043                &zed_actions::git::Branch,
2044            ))
2045            .on_click(cx.listener(|_, _, window, cx| {
2046                window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
2047            }))
2048            .style(ButtonStyle::Transparent);
2049
2050        let footer_size = px(32.);
2051        let gap = px(16.0);
2052
2053        let max_height = window.line_height() * 6. + gap + footer_size;
2054
2055        panel_editor_container(window, cx)
2056            .id("commit-editor-container")
2057            .relative()
2058            .h(max_height)
2059            .w_full()
2060            .border_t_1()
2061            .border_color(cx.theme().colors().border)
2062            .bg(cx.theme().colors().editor_background)
2063            .cursor_text()
2064            .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
2065                window.focus(&this.commit_editor.focus_handle(cx));
2066            }))
2067            .when(!self.modal_open, |el| {
2068                el.child(EditorElement::new(&self.commit_editor, panel_editor_style))
2069                    .child(
2070                        h_flex()
2071                            .absolute()
2072                            .bottom_0()
2073                            .left_2()
2074                            .h(footer_size)
2075                            .flex_none()
2076                            .child(branch_selector),
2077                    )
2078                    .child(
2079                        h_flex()
2080                            .absolute()
2081                            .bottom_0()
2082                            .right_2()
2083                            .h(footer_size)
2084                            .flex_none()
2085                            .children(enable_coauthors)
2086                            .child(commit_button),
2087                    )
2088            })
2089    }
2090
2091    fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
2092        let active_repository = self.active_repository.as_ref()?;
2093        let branch = active_repository.read(cx).current_branch()?;
2094        let commit = branch.most_recent_commit.as_ref()?.clone();
2095
2096        let this = cx.entity();
2097        Some(
2098            h_flex()
2099                .items_center()
2100                .py_1p5()
2101                .px(px(8.))
2102                .bg(cx.theme().colors().background)
2103                .border_t_1()
2104                .border_color(cx.theme().colors().border)
2105                .gap_1p5()
2106                .child(
2107                    div()
2108                        .flex_grow()
2109                        .overflow_hidden()
2110                        .max_w(relative(0.6))
2111                        .h_full()
2112                        .child(
2113                            Label::new(commit.subject.clone())
2114                                .size(LabelSize::Small)
2115                                .text_ellipsis(),
2116                        )
2117                        .id("commit-msg-hover")
2118                        .hoverable_tooltip(move |window, cx| {
2119                            GitPanelMessageTooltip::new(
2120                                this.clone(),
2121                                commit.sha.clone(),
2122                                window,
2123                                cx,
2124                            )
2125                            .into()
2126                        }),
2127                )
2128                .child(div().flex_1())
2129                .child(
2130                    panel_filled_button("Uncommit")
2131                        .icon(IconName::Undo)
2132                        .icon_size(IconSize::Small)
2133                        .icon_color(Color::Muted)
2134                        .icon_position(IconPosition::Start)
2135                        .tooltip(Tooltip::for_action_title(
2136                            if self.has_staged_changes() {
2137                                "git reset HEAD^ --soft"
2138                            } else {
2139                                "git reset HEAD^"
2140                            },
2141                            &git::Uncommit,
2142                        ))
2143                        .on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))),
2144                )
2145                .child(self.render_push_button(branch, cx)),
2146        )
2147    }
2148
2149    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
2150        h_flex()
2151            .h_full()
2152            .flex_grow()
2153            .justify_center()
2154            .items_center()
2155            .child(
2156                v_flex()
2157                    .gap_3()
2158                    .child(if self.active_repository.is_some() {
2159                        "No changes to commit"
2160                    } else {
2161                        "No Git repositories"
2162                    })
2163                    .text_ui_sm(cx)
2164                    .mx_auto()
2165                    .text_color(Color::Placeholder.color(cx)),
2166            )
2167    }
2168
2169    fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
2170        let scroll_bar_style = self.show_scrollbar(cx);
2171        let show_container = matches!(scroll_bar_style, ShowScrollbar::Always);
2172
2173        if !self.should_show_scrollbar(cx)
2174            || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
2175        {
2176            return None;
2177        }
2178
2179        Some(
2180            div()
2181                .id("git-panel-vertical-scroll")
2182                .occlude()
2183                .flex_none()
2184                .h_full()
2185                .cursor_default()
2186                .when(show_container, |this| this.pl_1().px_1p5())
2187                .when(!show_container, |this| {
2188                    this.absolute().right_1().top_1().bottom_1().w(px(12.))
2189                })
2190                .on_mouse_move(cx.listener(|_, _, _, cx| {
2191                    cx.notify();
2192                    cx.stop_propagation()
2193                }))
2194                .on_hover(|_, _, cx| {
2195                    cx.stop_propagation();
2196                })
2197                .on_any_mouse_down(|_, _, cx| {
2198                    cx.stop_propagation();
2199                })
2200                .on_mouse_up(
2201                    MouseButton::Left,
2202                    cx.listener(|this, _, window, cx| {
2203                        if !this.scrollbar_state.is_dragging()
2204                            && !this.focus_handle.contains_focused(window, cx)
2205                        {
2206                            this.hide_scrollbar(window, cx);
2207                            cx.notify();
2208                        }
2209
2210                        cx.stop_propagation();
2211                    }),
2212                )
2213                .on_scroll_wheel(cx.listener(|_, _, _, cx| {
2214                    cx.notify();
2215                }))
2216                .children(Scrollbar::vertical(
2217                    // percentage as f32..end_offset as f32,
2218                    self.scrollbar_state.clone(),
2219                )),
2220        )
2221    }
2222
2223    pub fn render_buffer_header_controls(
2224        &self,
2225        entity: &Entity<Self>,
2226        file: &Arc<dyn File>,
2227        _: &Window,
2228        cx: &App,
2229    ) -> Option<AnyElement> {
2230        let repo = self.active_repository.as_ref()?.read(cx);
2231        let repo_path = repo.worktree_id_path_to_repo_path(file.worktree_id(cx), file.path())?;
2232        let ix = self.entry_by_path(&repo_path)?;
2233        let entry = self.entries.get(ix)?;
2234
2235        let is_staged = self.entry_is_staged(entry.status_entry()?);
2236
2237        let checkbox = Checkbox::new("stage-file", is_staged.into())
2238            .disabled(!self.has_write_access(cx))
2239            .fill()
2240            .elevation(ElevationIndex::Surface)
2241            .on_click({
2242                let entry = entry.clone();
2243                let git_panel = entity.downgrade();
2244                move |_, window, cx| {
2245                    git_panel
2246                        .update(cx, |this, cx| {
2247                            this.toggle_staged_for_entry(&entry, window, cx);
2248                            cx.stop_propagation();
2249                        })
2250                        .ok();
2251                }
2252            });
2253        Some(
2254            h_flex()
2255                .id("start-slot")
2256                .text_lg()
2257                .child(checkbox)
2258                .on_mouse_down(MouseButton::Left, |_, _, cx| {
2259                    // prevent the list item active state triggering when toggling checkbox
2260                    cx.stop_propagation();
2261                })
2262                .into_any_element(),
2263        )
2264    }
2265
2266    fn render_entries(
2267        &self,
2268        has_write_access: bool,
2269        _: &Window,
2270        cx: &mut Context<Self>,
2271    ) -> impl IntoElement {
2272        let entry_count = self.entries.len();
2273
2274        v_flex()
2275            .size_full()
2276            .flex_grow()
2277            .overflow_hidden()
2278            .child(
2279                uniform_list(cx.entity().clone(), "entries", entry_count, {
2280                    move |this, range, window, cx| {
2281                        let mut items = Vec::with_capacity(range.end - range.start);
2282
2283                        for ix in range {
2284                            match &this.entries.get(ix) {
2285                                Some(GitListEntry::GitStatusEntry(entry)) => {
2286                                    items.push(this.render_entry(
2287                                        ix,
2288                                        entry,
2289                                        has_write_access,
2290                                        window,
2291                                        cx,
2292                                    ));
2293                                }
2294                                Some(GitListEntry::Header(header)) => {
2295                                    items.push(this.render_list_header(
2296                                        ix,
2297                                        header,
2298                                        has_write_access,
2299                                        window,
2300                                        cx,
2301                                    ));
2302                                }
2303                                None => {}
2304                            }
2305                        }
2306
2307                        items
2308                    }
2309                })
2310                .size_full()
2311                .with_sizing_behavior(ListSizingBehavior::Infer)
2312                .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
2313                .track_scroll(self.scroll_handle.clone()),
2314            )
2315            .on_mouse_down(
2316                MouseButton::Right,
2317                cx.listener(move |this, event: &MouseDownEvent, window, cx| {
2318                    this.deploy_panel_context_menu(event.position, window, cx)
2319                }),
2320            )
2321            .children(self.render_scrollbar(cx))
2322    }
2323
2324    fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
2325        Label::new(label.into()).color(color).single_line()
2326    }
2327
2328    fn render_list_header(
2329        &self,
2330        ix: usize,
2331        header: &GitHeaderEntry,
2332        _: bool,
2333        _: &Window,
2334        _: &Context<Self>,
2335    ) -> AnyElement {
2336        div()
2337            .w_full()
2338            .child(
2339                ListItem::new(ix)
2340                    .spacing(ListItemSpacing::Sparse)
2341                    .disabled(true)
2342                    .child(
2343                        Label::new(header.title())
2344                            .color(Color::Muted)
2345                            .size(LabelSize::Small)
2346                            .single_line(),
2347                    ),
2348            )
2349            .into_any_element()
2350    }
2351
2352    fn load_commit_details(
2353        &self,
2354        sha: &str,
2355        cx: &mut Context<Self>,
2356    ) -> Task<Result<CommitDetails>> {
2357        let Some(repo) = self.active_repository.clone() else {
2358            return Task::ready(Err(anyhow::anyhow!("no active repo")));
2359        };
2360
2361        let show = repo.read(cx).show(sha);
2362        cx.spawn(|_, _| async move { show.await? })
2363    }
2364
2365    fn deploy_entry_context_menu(
2366        &mut self,
2367        position: Point<Pixels>,
2368        ix: usize,
2369        window: &mut Window,
2370        cx: &mut Context<Self>,
2371    ) {
2372        let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else {
2373            return;
2374        };
2375        let stage_title = if entry.status.is_staged() == Some(true) {
2376            "Unstage File"
2377        } else {
2378            "Stage File"
2379        };
2380        let revert_title = if entry.status.is_deleted() {
2381            "Restore file"
2382        } else if entry.status.is_created() {
2383            "Trash file"
2384        } else {
2385            "Discard changes"
2386        };
2387        let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
2388            context_menu
2389                .action(stage_title, ToggleStaged.boxed_clone())
2390                .action(revert_title, git::RestoreFile.boxed_clone())
2391                .separator()
2392                .action("Open Diff", Confirm.boxed_clone())
2393                .action("Open File", SecondaryConfirm.boxed_clone())
2394        });
2395        self.selected_entry = Some(ix);
2396        self.set_context_menu(context_menu, position, window, cx);
2397    }
2398
2399    fn panel_context_menu(window: &mut Window, cx: &mut App) -> Entity<ContextMenu> {
2400        ContextMenu::build(window, cx, |context_menu, _, _| {
2401            context_menu
2402                .action("Stage All", StageAll.boxed_clone())
2403                .action("Unstage All", UnstageAll.boxed_clone())
2404                .separator()
2405                .action("Open Diff", project_diff::Diff.boxed_clone())
2406                .separator()
2407                .action("Discard Tracked Changes", RestoreTrackedFiles.boxed_clone())
2408                .action("Trash Untracked Files", TrashUntrackedFiles.boxed_clone())
2409        })
2410    }
2411
2412    fn deploy_panel_context_menu(
2413        &mut self,
2414        position: Point<Pixels>,
2415        window: &mut Window,
2416        cx: &mut Context<Self>,
2417    ) {
2418        let context_menu = Self::panel_context_menu(window, cx);
2419        self.set_context_menu(context_menu, position, window, cx);
2420    }
2421
2422    fn set_context_menu(
2423        &mut self,
2424        context_menu: Entity<ContextMenu>,
2425        position: Point<Pixels>,
2426        window: &Window,
2427        cx: &mut Context<Self>,
2428    ) {
2429        let subscription = cx.subscribe_in(
2430            &context_menu,
2431            window,
2432            |this, _, _: &DismissEvent, window, cx| {
2433                if this.context_menu.as_ref().is_some_and(|context_menu| {
2434                    context_menu.0.focus_handle(cx).contains_focused(window, cx)
2435                }) {
2436                    cx.focus_self(window);
2437                }
2438                this.context_menu.take();
2439                cx.notify();
2440            },
2441        );
2442        self.context_menu = Some((context_menu, position, subscription));
2443        cx.notify();
2444    }
2445
2446    fn render_entry(
2447        &self,
2448        ix: usize,
2449        entry: &GitStatusEntry,
2450        has_write_access: bool,
2451        window: &Window,
2452        cx: &Context<Self>,
2453    ) -> AnyElement {
2454        let display_name = entry
2455            .repo_path
2456            .file_name()
2457            .map(|name| name.to_string_lossy().into_owned())
2458            .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
2459
2460        let repo_path = entry.repo_path.clone();
2461        let selected = self.selected_entry == Some(ix);
2462        let status_style = GitPanelSettings::get_global(cx).status_style;
2463        let status = entry.status;
2464        let has_conflict = status.is_conflicted();
2465        let is_modified = status.is_modified();
2466        let is_deleted = status.is_deleted();
2467
2468        let label_color = if status_style == StatusStyle::LabelColor {
2469            if has_conflict {
2470                Color::Conflict
2471            } else if is_modified {
2472                Color::Modified
2473            } else if is_deleted {
2474                // We don't want a bunch of red labels in the list
2475                Color::Disabled
2476            } else {
2477                Color::Created
2478            }
2479        } else {
2480            Color::Default
2481        };
2482
2483        let path_color = if status.is_deleted() {
2484            Color::Disabled
2485        } else {
2486            Color::Muted
2487        };
2488
2489        let id: ElementId = ElementId::Name(format!("entry_{}", display_name).into());
2490
2491        let is_entry_staged = self.entry_is_staged(entry);
2492        let mut is_staged: ToggleState = self.entry_is_staged(entry).into();
2493
2494        if !self.has_staged_changes() && !self.has_conflicts() && !entry.status.is_created() {
2495            is_staged = ToggleState::Selected;
2496        }
2497
2498        let checkbox = Checkbox::new(id, is_staged)
2499            .disabled(!has_write_access)
2500            .fill()
2501            .placeholder(!self.has_staged_changes() && !self.has_conflicts())
2502            .elevation(ElevationIndex::Surface)
2503            .on_click({
2504                let entry = entry.clone();
2505                cx.listener(move |this, _, window, cx| {
2506                    this.toggle_staged_for_entry(
2507                        &GitListEntry::GitStatusEntry(entry.clone()),
2508                        window,
2509                        cx,
2510                    );
2511                    cx.stop_propagation();
2512                })
2513            });
2514
2515        let start_slot = h_flex()
2516            .id(("start-slot", ix))
2517            .gap(DynamicSpacing::Base04.rems(cx))
2518            .child(checkbox.tooltip(move |window, cx| {
2519                let tooltip_name = if is_entry_staged.unwrap_or(false) {
2520                    "Unstage"
2521                } else {
2522                    "Stage"
2523                };
2524
2525                Tooltip::for_action(tooltip_name, &ToggleStaged, window, cx)
2526            }))
2527            .child(git_status_icon(status, cx))
2528            .on_mouse_down(MouseButton::Left, |_, _, cx| {
2529                // prevent the list item active state triggering when toggling checkbox
2530                cx.stop_propagation();
2531            });
2532
2533        div()
2534            .w_full()
2535            .child(
2536                ListItem::new(ix)
2537                    .spacing(ListItemSpacing::Sparse)
2538                    .start_slot(start_slot)
2539                    .toggle_state(selected)
2540                    .focused(selected && self.focus_handle(cx).is_focused(window))
2541                    .disabled(!has_write_access)
2542                    .on_click({
2543                        cx.listener(move |this, event: &ClickEvent, window, cx| {
2544                            this.selected_entry = Some(ix);
2545                            cx.notify();
2546                            if event.modifiers().secondary() {
2547                                this.open_file(&Default::default(), window, cx)
2548                            } else {
2549                                this.open_diff(&Default::default(), window, cx);
2550                            }
2551                        })
2552                    })
2553                    .on_secondary_mouse_down(cx.listener(
2554                        move |this, event: &MouseDownEvent, window, cx| {
2555                            this.deploy_entry_context_menu(event.position, ix, window, cx);
2556                            cx.stop_propagation();
2557                        },
2558                    ))
2559                    .child(
2560                        h_flex()
2561                            .when_some(repo_path.parent(), |this, parent| {
2562                                let parent_str = parent.to_string_lossy();
2563                                if !parent_str.is_empty() {
2564                                    this.child(
2565                                        self.entry_label(format!("{}/", parent_str), path_color)
2566                                            .when(status.is_deleted(), |this| this.strikethrough()),
2567                                    )
2568                                } else {
2569                                    this
2570                                }
2571                            })
2572                            .child(
2573                                self.entry_label(display_name.clone(), label_color)
2574                                    .when(status.is_deleted(), |this| this.strikethrough()),
2575                            ),
2576                    ),
2577            )
2578            .into_any_element()
2579    }
2580
2581    fn render_push_button(&self, branch: &Branch, cx: &Context<Self>) -> AnyElement {
2582        let mut disabled = false;
2583
2584        // TODO: Add <origin> and <branch> argument substitutions to this
2585        let button: SharedString;
2586        let tooltip: SharedString;
2587        let action: Option<Push>;
2588        if let Some(upstream) = &branch.upstream {
2589            match upstream.tracking {
2590                UpstreamTracking::Gone => {
2591                    button = "Republish".into();
2592                    tooltip = "git push --set-upstream".into();
2593                    action = Some(git::Push {
2594                        options: Some(PushOptions::SetUpstream),
2595                    });
2596                }
2597                UpstreamTracking::Tracked(tracking) => {
2598                    if tracking.behind > 0 {
2599                        disabled = true;
2600                        button = "Push".into();
2601                        tooltip = "Upstream is ahead of local branch".into();
2602                        action = None;
2603                    } else if tracking.ahead > 0 {
2604                        button = format!("Push ({})", tracking.ahead).into();
2605                        tooltip = "git push".into();
2606                        action = Some(git::Push { options: None });
2607                    } else {
2608                        disabled = true;
2609                        button = "Push".into();
2610                        tooltip = "Upstream matches local branch".into();
2611                        action = None;
2612                    }
2613                }
2614            }
2615        } else {
2616            button = "Publish".into();
2617            tooltip = "git push --set-upstream".into();
2618            action = Some(git::Push {
2619                options: Some(PushOptions::SetUpstream),
2620            });
2621        };
2622
2623        panel_filled_button(button)
2624            .icon(IconName::ArrowUp)
2625            .icon_size(IconSize::Small)
2626            .icon_color(Color::Muted)
2627            .icon_position(IconPosition::Start)
2628            .disabled(disabled)
2629            .when_some(action, |this, action| {
2630                this.on_click(
2631                    cx.listener(move |this, _, window, cx| this.push(&action, window, cx)),
2632                )
2633            })
2634            .tooltip(move |window, cx| {
2635                if let Some(action) = action.as_ref() {
2636                    Tooltip::for_action(tooltip.clone(), action, window, cx)
2637                } else {
2638                    Tooltip::simple(tooltip.clone(), cx)
2639                }
2640            })
2641            .into_any_element()
2642    }
2643
2644    fn has_write_access(&self, cx: &App) -> bool {
2645        !self.project.read(cx).is_read_only(cx)
2646    }
2647}
2648
2649impl Render for GitPanel {
2650    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2651        let project = self.project.read(cx);
2652        let has_entries = self.entries.len() > 0;
2653        let room = self
2654            .workspace
2655            .upgrade()
2656            .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
2657
2658        let has_write_access = self.has_write_access(cx);
2659
2660        let has_co_authors = room.map_or(false, |room| {
2661            room.read(cx)
2662                .remote_participants()
2663                .values()
2664                .any(|remote_participant| remote_participant.can_write())
2665        });
2666
2667        v_flex()
2668            .id("git_panel")
2669            .key_context(self.dispatch_context(window, cx))
2670            .track_focus(&self.focus_handle)
2671            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
2672            .when(has_write_access && !project.is_read_only(cx), |this| {
2673                this.on_action(cx.listener(|this, &ToggleStaged, window, cx| {
2674                    this.toggle_staged_for_selected(&ToggleStaged, window, cx)
2675                }))
2676                .on_action(cx.listener(GitPanel::commit))
2677            })
2678            .on_action(cx.listener(Self::select_first))
2679            .on_action(cx.listener(Self::select_next))
2680            .on_action(cx.listener(Self::select_prev))
2681            .on_action(cx.listener(Self::select_last))
2682            .on_action(cx.listener(Self::close_panel))
2683            .on_action(cx.listener(Self::open_diff))
2684            .on_action(cx.listener(Self::open_file))
2685            .on_action(cx.listener(Self::revert_selected))
2686            .on_action(cx.listener(Self::focus_changes_list))
2687            .on_action(cx.listener(Self::focus_editor))
2688            .on_action(cx.listener(Self::toggle_staged_for_selected))
2689            .on_action(cx.listener(Self::stage_all))
2690            .on_action(cx.listener(Self::unstage_all))
2691            .on_action(cx.listener(Self::discard_tracked_changes))
2692            .on_action(cx.listener(Self::clean_all))
2693            .on_action(cx.listener(Self::fetch))
2694            .on_action(cx.listener(Self::pull))
2695            .on_action(cx.listener(Self::push))
2696            .when(has_write_access && has_co_authors, |git_panel| {
2697                git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
2698            })
2699            // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx)))
2700            .on_hover(cx.listener(|this, hovered, window, cx| {
2701                if *hovered {
2702                    this.show_scrollbar = true;
2703                    this.hide_scrollbar_task.take();
2704                    cx.notify();
2705                } else if !this.focus_handle.contains_focused(window, cx) {
2706                    this.hide_scrollbar(window, cx);
2707                }
2708            }))
2709            .size_full()
2710            .overflow_hidden()
2711            .bg(ElevationIndex::Surface.bg(cx))
2712            .child(
2713                v_flex()
2714                    .size_full()
2715                    .children(self.render_panel_header(window, cx))
2716                    .map(|this| {
2717                        if has_entries {
2718                            this.child(self.render_entries(has_write_access, window, cx))
2719                        } else {
2720                            this.child(self.render_empty_state(cx).into_any_element())
2721                        }
2722                    })
2723                    .children(self.render_previous_commit(cx))
2724                    .child(self.render_commit_editor(window, cx))
2725                    .into_any_element(),
2726            )
2727            .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2728                deferred(
2729                    anchored()
2730                        .position(*position)
2731                        .anchor(gpui::Corner::TopLeft)
2732                        .child(menu.clone()),
2733                )
2734                .with_priority(1)
2735            }))
2736    }
2737}
2738
2739impl Focusable for GitPanel {
2740    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
2741        self.focus_handle.clone()
2742    }
2743}
2744
2745impl EventEmitter<Event> for GitPanel {}
2746
2747impl EventEmitter<PanelEvent> for GitPanel {}
2748
2749pub(crate) struct GitPanelAddon {
2750    pub(crate) workspace: WeakEntity<Workspace>,
2751}
2752
2753impl editor::Addon for GitPanelAddon {
2754    fn to_any(&self) -> &dyn std::any::Any {
2755        self
2756    }
2757
2758    fn render_buffer_header_controls(
2759        &self,
2760        excerpt_info: &ExcerptInfo,
2761        window: &Window,
2762        cx: &App,
2763    ) -> Option<AnyElement> {
2764        let file = excerpt_info.buffer.file()?;
2765        let git_panel = self.workspace.upgrade()?.read(cx).panel::<GitPanel>(cx)?;
2766
2767        git_panel
2768            .read(cx)
2769            .render_buffer_header_controls(&git_panel, &file, window, cx)
2770    }
2771}
2772
2773impl Panel for GitPanel {
2774    fn persistent_name() -> &'static str {
2775        "GitPanel"
2776    }
2777
2778    fn position(&self, _: &Window, cx: &App) -> DockPosition {
2779        GitPanelSettings::get_global(cx).dock
2780    }
2781
2782    fn position_is_valid(&self, position: DockPosition) -> bool {
2783        matches!(position, DockPosition::Left | DockPosition::Right)
2784    }
2785
2786    fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
2787        settings::update_settings_file::<GitPanelSettings>(
2788            self.fs.clone(),
2789            cx,
2790            move |settings, _| settings.dock = Some(position),
2791        );
2792    }
2793
2794    fn size(&self, _: &Window, cx: &App) -> Pixels {
2795        self.width
2796            .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
2797    }
2798
2799    fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
2800        self.width = size;
2801        self.serialize(cx);
2802        cx.notify();
2803    }
2804
2805    fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
2806        Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
2807    }
2808
2809    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
2810        Some("Git Panel")
2811    }
2812
2813    fn toggle_action(&self) -> Box<dyn Action> {
2814        Box::new(ToggleFocus)
2815    }
2816
2817    fn activation_priority(&self) -> u32 {
2818        2
2819    }
2820}
2821
2822impl PanelHeader for GitPanel {}
2823
2824struct GitPanelMessageTooltip {
2825    commit_tooltip: Option<Entity<CommitTooltip>>,
2826}
2827
2828impl GitPanelMessageTooltip {
2829    fn new(
2830        git_panel: Entity<GitPanel>,
2831        sha: SharedString,
2832        window: &mut Window,
2833        cx: &mut App,
2834    ) -> Entity<Self> {
2835        cx.new(|cx| {
2836            cx.spawn_in(window, |this, mut cx| async move {
2837                let details = git_panel
2838                    .update(&mut cx, |git_panel, cx| {
2839                        git_panel.load_commit_details(&sha, cx)
2840                    })?
2841                    .await?;
2842
2843                let commit_details = editor::commit_tooltip::CommitDetails {
2844                    sha: details.sha.clone(),
2845                    committer_name: details.committer_name.clone(),
2846                    committer_email: details.committer_email.clone(),
2847                    commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
2848                    message: Some(editor::commit_tooltip::ParsedCommitMessage {
2849                        message: details.message.clone(),
2850                        ..Default::default()
2851                    }),
2852                };
2853
2854                this.update_in(&mut cx, |this: &mut GitPanelMessageTooltip, window, cx| {
2855                    this.commit_tooltip =
2856                        Some(cx.new(move |cx| CommitTooltip::new(commit_details, window, cx)));
2857                    cx.notify();
2858                })
2859            })
2860            .detach();
2861
2862            Self {
2863                commit_tooltip: None,
2864            }
2865        })
2866    }
2867}
2868
2869impl Render for GitPanelMessageTooltip {
2870    fn render(&mut self, _window: &mut Window, _cx: &mut Context<'_, Self>) -> impl IntoElement {
2871        if let Some(commit_tooltip) = &self.commit_tooltip {
2872            commit_tooltip.clone().into_any_element()
2873        } else {
2874            gpui::Empty.into_any_element()
2875        }
2876    }
2877}