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