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        if self.total_staged_count() != 1 {
1261            return None;
1262        }
1263
1264        let entry = self
1265            .entries
1266            .iter()
1267            .find(|entry| match entry.status_entry() {
1268                Some(entry) => entry.is_staged.unwrap_or(false),
1269                _ => false,
1270            })?;
1271
1272        let GitListEntry::GitStatusEntry(git_status_entry) = entry.clone() else {
1273            return None;
1274        };
1275
1276        let action_text = if git_status_entry.status.is_deleted() {
1277            Some("Delete")
1278        } else if git_status_entry.status.is_created() {
1279            Some("Create")
1280        } else if git_status_entry.status.is_modified() {
1281            Some("Update")
1282        } else {
1283            None
1284        };
1285
1286        let file_name = git_status_entry
1287            .repo_path
1288            .file_name()
1289            .unwrap_or_default()
1290            .to_string_lossy();
1291
1292        Some(format!("{} {}", action_text?, file_name))
1293    }
1294
1295    fn update_editor_placeholder(&mut self, cx: &mut Context<Self>) {
1296        let suggested_commit_message = self.suggest_commit_message();
1297        let suggested_commit_message = suggested_commit_message
1298            .as_deref()
1299            .unwrap_or("Enter commit message");
1300
1301        self.commit_editor.update(cx, |editor, cx| {
1302            editor.set_placeholder_text(Arc::from(suggested_commit_message), cx)
1303        });
1304
1305        cx.notify();
1306    }
1307
1308    fn fetch(&mut self, _: &git::Fetch, _window: &mut Window, cx: &mut Context<Self>) {
1309        let Some(repo) = self.active_repository.clone() else {
1310            return;
1311        };
1312        let guard = self.start_remote_operation();
1313        let fetch = repo.read(cx).fetch();
1314        cx.spawn(|_, _| async move {
1315            fetch.await??;
1316            drop(guard);
1317            anyhow::Ok(())
1318        })
1319        .detach_and_log_err(cx);
1320    }
1321
1322    fn pull(&mut self, _: &git::Pull, window: &mut Window, cx: &mut Context<Self>) {
1323        let guard = self.start_remote_operation();
1324        let remote = self.get_current_remote(window, cx);
1325        cx.spawn(move |this, mut cx| async move {
1326            let remote = remote.await?;
1327
1328            this.update(&mut cx, |this, cx| {
1329                let Some(repo) = this.active_repository.clone() else {
1330                    return Err(anyhow::anyhow!("No active repository"));
1331                };
1332
1333                let Some(branch) = repo.read(cx).current_branch() else {
1334                    return Err(anyhow::anyhow!("No active branch"));
1335                };
1336
1337                Ok(repo.read(cx).pull(branch.name.clone(), remote.name))
1338            })??
1339            .await??;
1340
1341            drop(guard);
1342            anyhow::Ok(())
1343        })
1344        .detach_and_log_err(cx);
1345    }
1346
1347    fn push(&mut self, action: &git::Push, window: &mut Window, cx: &mut Context<Self>) {
1348        let guard = self.start_remote_operation();
1349        let options = action.options;
1350        let remote = self.get_current_remote(window, cx);
1351        cx.spawn(move |this, mut cx| async move {
1352            let remote = remote.await?;
1353
1354            this.update(&mut cx, |this, cx| {
1355                let Some(repo) = this.active_repository.clone() else {
1356                    return Err(anyhow::anyhow!("No active repository"));
1357                };
1358
1359                let Some(branch) = repo.read(cx).current_branch() else {
1360                    return Err(anyhow::anyhow!("No active branch"));
1361                };
1362
1363                Ok(repo
1364                    .read(cx)
1365                    .push(branch.name.clone(), remote.name, options))
1366            })??
1367            .await??;
1368
1369            drop(guard);
1370            anyhow::Ok(())
1371        })
1372        .detach_and_log_err(cx);
1373    }
1374
1375    fn get_current_remote(
1376        &mut self,
1377        window: &mut Window,
1378        cx: &mut Context<Self>,
1379    ) -> impl Future<Output = Result<Remote>> {
1380        let repo = self.active_repository.clone();
1381        let workspace = self.workspace.clone();
1382        let mut cx = window.to_async(cx);
1383
1384        async move {
1385            let Some(repo) = repo else {
1386                return Err(anyhow::anyhow!("No active repository"));
1387            };
1388
1389            let mut current_remotes: Vec<Remote> = repo
1390                .update(&mut cx, |repo, _| {
1391                    let Some(current_branch) = repo.current_branch() else {
1392                        return Err(anyhow::anyhow!("No active branch"));
1393                    };
1394
1395                    Ok(repo.get_remotes(Some(current_branch.name.to_string())))
1396                })??
1397                .await??;
1398
1399            if current_remotes.len() == 0 {
1400                return Err(anyhow::anyhow!("No active remote"));
1401            } else if current_remotes.len() == 1 {
1402                return Ok(current_remotes.pop().unwrap());
1403            } else {
1404                let current_remotes: Vec<_> = current_remotes
1405                    .into_iter()
1406                    .map(|remotes| remotes.name)
1407                    .collect();
1408                let selection = cx
1409                    .update(|window, cx| {
1410                        picker_prompt::prompt(
1411                            "Pick which remote to push to",
1412                            current_remotes.clone(),
1413                            workspace,
1414                            window,
1415                            cx,
1416                        )
1417                    })?
1418                    .await?;
1419
1420                return Ok(Remote {
1421                    name: current_remotes[selection].clone(),
1422                });
1423            }
1424        }
1425    }
1426
1427    fn potential_co_authors(&self, cx: &App) -> Vec<(String, String)> {
1428        let mut new_co_authors = Vec::new();
1429        let project = self.project.read(cx);
1430
1431        let Some(room) = self
1432            .workspace
1433            .upgrade()
1434            .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned())
1435        else {
1436            return Vec::default();
1437        };
1438
1439        let room = room.read(cx);
1440
1441        for (peer_id, collaborator) in project.collaborators() {
1442            if collaborator.is_host {
1443                continue;
1444            }
1445
1446            let Some(participant) = room.remote_participant_for_peer_id(*peer_id) else {
1447                continue;
1448            };
1449            if participant.can_write() && participant.user.email.is_some() {
1450                let email = participant.user.email.clone().unwrap();
1451
1452                new_co_authors.push((
1453                    participant
1454                        .user
1455                        .name
1456                        .clone()
1457                        .unwrap_or_else(|| participant.user.github_login.clone()),
1458                    email,
1459                ))
1460            }
1461        }
1462        if !project.is_local() && !project.is_read_only(cx) {
1463            if let Some(user) = room.local_participant_user(cx) {
1464                if let Some(email) = user.email.clone() {
1465                    new_co_authors.push((
1466                        user.name
1467                            .clone()
1468                            .unwrap_or_else(|| user.github_login.clone()),
1469                        email.clone(),
1470                    ))
1471                }
1472            }
1473        }
1474        new_co_authors
1475    }
1476
1477    fn toggle_fill_co_authors(
1478        &mut self,
1479        _: &ToggleFillCoAuthors,
1480        _: &mut Window,
1481        cx: &mut Context<Self>,
1482    ) {
1483        self.add_coauthors = !self.add_coauthors;
1484        cx.notify();
1485    }
1486
1487    fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context<Self>) {
1488        const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
1489
1490        let existing_text = message.to_ascii_lowercase();
1491        let lowercase_co_author_prefix = CO_AUTHOR_PREFIX.to_lowercase();
1492        let mut ends_with_co_authors = false;
1493        let existing_co_authors = existing_text
1494            .lines()
1495            .filter_map(|line| {
1496                let line = line.trim();
1497                if line.starts_with(&lowercase_co_author_prefix) {
1498                    ends_with_co_authors = true;
1499                    Some(line)
1500                } else {
1501                    ends_with_co_authors = false;
1502                    None
1503                }
1504            })
1505            .collect::<HashSet<_>>();
1506
1507        let new_co_authors = self
1508            .potential_co_authors(cx)
1509            .into_iter()
1510            .filter(|(_, email)| {
1511                !existing_co_authors
1512                    .iter()
1513                    .any(|existing| existing.contains(email.as_str()))
1514            })
1515            .collect::<Vec<_>>();
1516
1517        if new_co_authors.is_empty() {
1518            return;
1519        }
1520
1521        if !ends_with_co_authors {
1522            message.push('\n');
1523        }
1524        for (name, email) in new_co_authors {
1525            message.push('\n');
1526            message.push_str(CO_AUTHOR_PREFIX);
1527            message.push_str(&name);
1528            message.push_str(" <");
1529            message.push_str(&email);
1530            message.push('>');
1531        }
1532        message.push('\n');
1533    }
1534
1535    fn schedule_update(
1536        &mut self,
1537        clear_pending: bool,
1538        window: &mut Window,
1539        cx: &mut Context<Self>,
1540    ) {
1541        let handle = cx.entity().downgrade();
1542        self.reopen_commit_buffer(window, cx);
1543        self.update_visible_entries_task = cx.spawn_in(window, |_, mut cx| async move {
1544            cx.background_executor().timer(UPDATE_DEBOUNCE).await;
1545            if let Some(git_panel) = handle.upgrade() {
1546                git_panel
1547                    .update_in(&mut cx, |git_panel, _, cx| {
1548                        if clear_pending {
1549                            git_panel.clear_pending();
1550                        }
1551                        git_panel.update_visible_entries(cx);
1552                        git_panel.update_editor_placeholder(cx);
1553                    })
1554                    .ok();
1555            }
1556        });
1557    }
1558
1559    fn reopen_commit_buffer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1560        let Some(active_repo) = self.active_repository.as_ref() else {
1561            return;
1562        };
1563        let load_buffer = active_repo.update(cx, |active_repo, cx| {
1564            let project = self.project.read(cx);
1565            active_repo.open_commit_buffer(
1566                Some(project.languages().clone()),
1567                project.buffer_store().clone(),
1568                cx,
1569            )
1570        });
1571
1572        cx.spawn_in(window, |git_panel, mut cx| async move {
1573            let buffer = load_buffer.await?;
1574            git_panel.update_in(&mut cx, |git_panel, window, cx| {
1575                if git_panel
1576                    .commit_editor
1577                    .read(cx)
1578                    .buffer()
1579                    .read(cx)
1580                    .as_singleton()
1581                    .as_ref()
1582                    != Some(&buffer)
1583                {
1584                    git_panel.commit_editor = cx.new(|cx| {
1585                        commit_message_editor(
1586                            buffer,
1587                            git_panel.suggested_commit_message.as_deref(),
1588                            git_panel.project.clone(),
1589                            true,
1590                            window,
1591                            cx,
1592                        )
1593                    });
1594                }
1595            })
1596        })
1597        .detach_and_log_err(cx);
1598    }
1599
1600    fn clear_pending(&mut self) {
1601        self.pending.retain(|v| !v.finished)
1602    }
1603
1604    fn update_visible_entries(&mut self, cx: &mut Context<Self>) {
1605        self.entries.clear();
1606        let mut changed_entries = Vec::new();
1607        let mut new_entries = Vec::new();
1608        let mut conflict_entries = Vec::new();
1609
1610        let Some(repo) = self.active_repository.as_ref() else {
1611            // Just clear entries if no repository is active.
1612            cx.notify();
1613            return;
1614        };
1615
1616        // First pass - collect all paths
1617        let repo = repo.read(cx);
1618
1619        // Second pass - create entries with proper depth calculation
1620        for entry in repo.status() {
1621            let is_conflict = repo.has_conflict(&entry.repo_path);
1622            let is_new = entry.status.is_created();
1623            let is_staged = entry.status.is_staged();
1624
1625            if self.pending.iter().any(|pending| {
1626                pending.target_status == TargetStatus::Reverted
1627                    && !pending.finished
1628                    && pending.repo_paths.contains(&entry.repo_path)
1629            }) {
1630                continue;
1631            }
1632
1633            let entry = GitStatusEntry {
1634                repo_path: entry.repo_path.clone(),
1635                status: entry.status,
1636                is_staged,
1637            };
1638
1639            if is_conflict {
1640                conflict_entries.push(entry);
1641            } else if is_new {
1642                new_entries.push(entry);
1643            } else {
1644                changed_entries.push(entry);
1645            }
1646        }
1647
1648        if conflict_entries.len() > 0 {
1649            self.entries.push(GitListEntry::Header(GitHeaderEntry {
1650                header: Section::Conflict,
1651            }));
1652            self.entries.extend(
1653                conflict_entries
1654                    .into_iter()
1655                    .map(GitListEntry::GitStatusEntry),
1656            );
1657        }
1658
1659        if changed_entries.len() > 0 {
1660            self.entries.push(GitListEntry::Header(GitHeaderEntry {
1661                header: Section::Tracked,
1662            }));
1663            self.entries.extend(
1664                changed_entries
1665                    .into_iter()
1666                    .map(GitListEntry::GitStatusEntry),
1667            );
1668        }
1669        if new_entries.len() > 0 {
1670            self.entries.push(GitListEntry::Header(GitHeaderEntry {
1671                header: Section::New,
1672            }));
1673            self.entries
1674                .extend(new_entries.into_iter().map(GitListEntry::GitStatusEntry));
1675        }
1676
1677        self.update_counts(repo);
1678
1679        self.select_first_entry_if_none(cx);
1680
1681        cx.notify();
1682    }
1683
1684    fn header_state(&self, header_type: Section) -> ToggleState {
1685        let (staged_count, count) = match header_type {
1686            Section::New => (self.new_staged_count, self.new_count),
1687            Section::Tracked => (self.tracked_staged_count, self.tracked_count),
1688            Section::Conflict => (self.conflicted_staged_count, self.conflicted_count),
1689        };
1690        if staged_count == 0 {
1691            ToggleState::Unselected
1692        } else if count == staged_count {
1693            ToggleState::Selected
1694        } else {
1695            ToggleState::Indeterminate
1696        }
1697    }
1698
1699    fn update_counts(&mut self, repo: &Repository) {
1700        self.conflicted_count = 0;
1701        self.conflicted_staged_count = 0;
1702        self.new_count = 0;
1703        self.tracked_count = 0;
1704        self.new_staged_count = 0;
1705        self.tracked_staged_count = 0;
1706        for entry in &self.entries {
1707            let Some(status_entry) = entry.status_entry() else {
1708                continue;
1709            };
1710            if repo.has_conflict(&status_entry.repo_path) {
1711                self.conflicted_count += 1;
1712                if self.entry_is_staged(status_entry) != Some(false) {
1713                    self.conflicted_staged_count += 1;
1714                }
1715            } else if status_entry.status.is_created() {
1716                self.new_count += 1;
1717                if self.entry_is_staged(status_entry) != Some(false) {
1718                    self.new_staged_count += 1;
1719                }
1720            } else {
1721                self.tracked_count += 1;
1722                if self.entry_is_staged(status_entry) != Some(false) {
1723                    self.tracked_staged_count += 1;
1724                }
1725            }
1726        }
1727    }
1728
1729    fn entry_is_staged(&self, entry: &GitStatusEntry) -> Option<bool> {
1730        for pending in self.pending.iter().rev() {
1731            if pending.repo_paths.contains(&entry.repo_path) {
1732                match pending.target_status {
1733                    TargetStatus::Staged => return Some(true),
1734                    TargetStatus::Unstaged => return Some(false),
1735                    TargetStatus::Reverted => continue,
1736                    TargetStatus::Unchanged => continue,
1737                }
1738            }
1739        }
1740        entry.is_staged
1741    }
1742
1743    pub(crate) fn has_staged_changes(&self) -> bool {
1744        self.tracked_staged_count > 0
1745            || self.new_staged_count > 0
1746            || self.conflicted_staged_count > 0
1747    }
1748
1749    pub(crate) fn has_unstaged_changes(&self) -> bool {
1750        self.tracked_count > self.tracked_staged_count
1751            || self.new_count > self.new_staged_count
1752            || self.conflicted_count > self.conflicted_staged_count
1753    }
1754
1755    fn has_conflicts(&self) -> bool {
1756        self.conflicted_count > 0
1757    }
1758
1759    fn has_tracked_changes(&self) -> bool {
1760        self.tracked_count > 0
1761    }
1762
1763    pub fn has_unstaged_conflicts(&self) -> bool {
1764        self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count
1765    }
1766
1767    fn show_err_toast(&self, e: anyhow::Error, cx: &mut App) {
1768        let Some(workspace) = self.workspace.upgrade() else {
1769            return;
1770        };
1771        let notif_id = NotificationId::Named("git-operation-error".into());
1772
1773        let message = e.to_string();
1774        workspace.update(cx, |workspace, cx| {
1775            let toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| {
1776                window.dispatch_action(workspace::OpenLog.boxed_clone(), cx);
1777            });
1778            workspace.show_toast(toast, cx);
1779        });
1780    }
1781
1782    pub fn panel_button(
1783        &self,
1784        id: impl Into<SharedString>,
1785        label: impl Into<SharedString>,
1786    ) -> Button {
1787        let id = id.into().clone();
1788        let label = label.into().clone();
1789
1790        Button::new(id, label)
1791            .label_size(LabelSize::Small)
1792            .layer(ElevationIndex::ElevatedSurface)
1793            .size(ButtonSize::Compact)
1794            .style(ButtonStyle::Filled)
1795    }
1796
1797    pub fn indent_size(&self, window: &Window, cx: &mut Context<Self>) -> Pixels {
1798        Checkbox::container_size(cx).to_pixels(window.rem_size())
1799    }
1800
1801    pub fn render_divider(&self, _cx: &mut Context<Self>) -> impl IntoElement {
1802        h_flex()
1803            .items_center()
1804            .h(px(8.))
1805            .child(Divider::horizontal_dashed().color(DividerColor::Border))
1806    }
1807
1808    pub fn render_panel_header(
1809        &self,
1810        window: &mut Window,
1811        cx: &mut Context<Self>,
1812    ) -> Option<impl IntoElement> {
1813        let all_repositories = self
1814            .project
1815            .read(cx)
1816            .git_store()
1817            .read(cx)
1818            .all_repositories();
1819
1820        let has_repo_above = all_repositories.iter().any(|repo| {
1821            repo.read(cx)
1822                .repository_entry
1823                .work_directory
1824                .is_above_project()
1825        });
1826
1827        let has_visible_repo = all_repositories.len() > 0 || has_repo_above;
1828
1829        if has_visible_repo {
1830            Some(
1831                self.panel_header_container(window, cx)
1832                    .child(
1833                        Label::new("Repository")
1834                            .size(LabelSize::Small)
1835                            .color(Color::Muted),
1836                    )
1837                    .child(self.render_repository_selector(cx))
1838                    .child(div().flex_grow()) // spacer
1839                    .child(
1840                        div()
1841                            .h_flex()
1842                            .gap_1()
1843                            .children(self.render_spinner(cx))
1844                            .children(self.render_sync_button(cx))
1845                            .children(self.render_pull_button(cx))
1846                            .child(
1847                                Button::new("diff", "+/-")
1848                                    .tooltip(Tooltip::for_action_title("Open diff", &Diff))
1849                                    .on_click(|_, _, cx| {
1850                                        cx.defer(|cx| {
1851                                            cx.dispatch_action(&Diff);
1852                                        })
1853                                    }),
1854                            )
1855                            .child(self.render_overflow_menu()),
1856                    ),
1857            )
1858        } else {
1859            None
1860        }
1861    }
1862
1863    pub fn render_spinner(&self, _cx: &mut Context<Self>) -> Option<impl IntoElement> {
1864        (!self.pending_remote_operations.borrow().is_empty()).then(|| {
1865            Icon::new(IconName::ArrowCircle)
1866                .size(IconSize::XSmall)
1867                .color(Color::Info)
1868                .with_animation(
1869                    "arrow-circle",
1870                    Animation::new(Duration::from_secs(2)).repeat(),
1871                    |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
1872                )
1873                .into_any_element()
1874        })
1875    }
1876
1877    pub fn render_overflow_menu(&self) -> impl IntoElement {
1878        PopoverMenu::new("overflow-menu")
1879            .trigger(IconButton::new("overflow-menu-trigger", IconName::Ellipsis))
1880            .menu(move |window, cx| Some(Self::panel_context_menu(window, cx)))
1881            .anchor(Corner::TopRight)
1882    }
1883
1884    pub fn render_sync_button(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
1885        let active_repository = self.project.read(cx).active_repository(cx);
1886        active_repository.as_ref().map(|_| {
1887            panel_filled_button("Fetch")
1888                .icon(IconName::ArrowCircle)
1889                .icon_size(IconSize::Small)
1890                .icon_color(Color::Muted)
1891                .icon_position(IconPosition::Start)
1892                .tooltip(Tooltip::for_action_title("git fetch", &git::Fetch))
1893                .on_click(
1894                    cx.listener(move |this, _, window, cx| this.fetch(&git::Fetch, window, cx)),
1895                )
1896                .into_any_element()
1897        })
1898    }
1899
1900    pub fn render_pull_button(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
1901        let active_repository = self.project.read(cx).active_repository(cx);
1902        active_repository
1903            .as_ref()
1904            .and_then(|repo| repo.read(cx).current_branch())
1905            .and_then(|branch| {
1906                branch.upstream.as_ref().map(|upstream| {
1907                    let status = &upstream.tracking;
1908
1909                    let disabled = status.is_gone();
1910
1911                    panel_filled_button(match status {
1912                        git::repository::UpstreamTracking::Tracked(status) if status.behind > 0 => {
1913                            format!("Pull ({})", status.behind)
1914                        }
1915                        _ => "Pull".to_string(),
1916                    })
1917                    .icon(IconName::ArrowDown)
1918                    .icon_size(IconSize::Small)
1919                    .icon_color(Color::Muted)
1920                    .icon_position(IconPosition::Start)
1921                    .disabled(status.is_gone())
1922                    .tooltip(move |window, cx| {
1923                        if disabled {
1924                            Tooltip::simple("Upstream is gone", cx)
1925                        } else {
1926                            // TODO: Add <origin> and <branch> argument substitutions to this
1927                            Tooltip::for_action("git pull", &git::Pull, window, cx)
1928                        }
1929                    })
1930                    .on_click(
1931                        cx.listener(move |this, _, window, cx| this.pull(&git::Pull, window, cx)),
1932                    )
1933                    .into_any_element()
1934                })
1935            })
1936    }
1937
1938    pub fn render_repository_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
1939        let active_repository = self.project.read(cx).active_repository(cx);
1940        let repository_display_name = active_repository
1941            .as_ref()
1942            .map(|repo| repo.read(cx).display_name(self.project.read(cx), cx))
1943            .unwrap_or_default();
1944
1945        RepositorySelectorPopoverMenu::new(
1946            self.repository_selector.clone(),
1947            ButtonLike::new("active-repository")
1948                .style(ButtonStyle::Subtle)
1949                .child(Label::new(repository_display_name).size(LabelSize::Small)),
1950            Tooltip::text("Select a repository"),
1951        )
1952    }
1953
1954    pub fn can_commit(&self) -> bool {
1955        (self.has_staged_changes() || self.has_tracked_changes()) && !self.has_unstaged_conflicts()
1956    }
1957
1958    pub fn can_stage_all(&self) -> bool {
1959        self.has_unstaged_changes()
1960    }
1961
1962    pub fn can_unstage_all(&self) -> bool {
1963        self.has_staged_changes()
1964    }
1965
1966    pub(crate) fn render_co_authors(&self, cx: &Context<Self>) -> Option<AnyElement> {
1967        let potential_co_authors = self.potential_co_authors(cx);
1968        if potential_co_authors.is_empty() {
1969            None
1970        } else {
1971            Some(
1972                IconButton::new("co-authors", IconName::Person)
1973                    .icon_color(Color::Disabled)
1974                    .selected_icon_color(Color::Selected)
1975                    .toggle_state(self.add_coauthors)
1976                    .tooltip(move |_, cx| {
1977                        let title = format!(
1978                            "Add co-authored-by:{}{}",
1979                            if potential_co_authors.len() == 1 {
1980                                ""
1981                            } else {
1982                                "\n"
1983                            },
1984                            potential_co_authors
1985                                .iter()
1986                                .map(|(name, email)| format!(" {} <{}>", name, email))
1987                                .join("\n")
1988                        );
1989                        Tooltip::simple(title, cx)
1990                    })
1991                    .on_click(cx.listener(|this, _, _, cx| {
1992                        this.add_coauthors = !this.add_coauthors;
1993                        cx.notify();
1994                    }))
1995                    .into_any_element(),
1996            )
1997        }
1998    }
1999
2000    pub fn render_commit_editor(
2001        &self,
2002        window: &mut Window,
2003        cx: &mut Context<Self>,
2004    ) -> impl IntoElement {
2005        let editor = self.commit_editor.clone();
2006        let can_commit = self.can_commit()
2007            && self.pending_commit.is_none()
2008            && !editor.read(cx).is_empty(cx)
2009            && self.has_write_access(cx);
2010
2011        let panel_editor_style = panel_editor_style(true, window, cx);
2012        let enable_coauthors = self.render_co_authors(cx);
2013
2014        let tooltip = if self.has_staged_changes() {
2015            "git commit"
2016        } else {
2017            "git commit --all"
2018        };
2019        let title = if self.has_staged_changes() {
2020            "Commit"
2021        } else {
2022            "Commit Tracked"
2023        };
2024        let editor_focus_handle = self.commit_editor.focus_handle(cx);
2025
2026        let commit_button = panel_filled_button(title)
2027            .tooltip(move |window, cx| {
2028                Tooltip::for_action_in(tooltip, &Commit, &editor_focus_handle, window, cx)
2029            })
2030            .disabled(!can_commit)
2031            .on_click({
2032                cx.listener(move |this, _: &ClickEvent, window, cx| this.commit_changes(window, cx))
2033            });
2034
2035        let branch = self
2036            .active_repository
2037            .as_ref()
2038            .and_then(|repo| repo.read(cx).current_branch().map(|b| b.name.clone()))
2039            .unwrap_or_else(|| "<no branch>".into());
2040
2041        let branch_selector = Button::new("branch-selector", branch)
2042            .color(Color::Muted)
2043            .style(ButtonStyle::Subtle)
2044            .icon(IconName::GitBranch)
2045            .icon_size(IconSize::Small)
2046            .icon_color(Color::Muted)
2047            .size(ButtonSize::Compact)
2048            .icon_position(IconPosition::Start)
2049            .tooltip(Tooltip::for_action_title(
2050                "Switch Branch",
2051                &zed_actions::git::Branch,
2052            ))
2053            .on_click(cx.listener(|_, _, window, cx| {
2054                window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
2055            }))
2056            .style(ButtonStyle::Transparent);
2057
2058        let footer_size = px(32.);
2059        let gap = px(16.0);
2060
2061        let max_height = window.line_height() * 6. + gap + footer_size;
2062
2063        panel_editor_container(window, cx)
2064            .id("commit-editor-container")
2065            .relative()
2066            .h(max_height)
2067            .w_full()
2068            .border_t_1()
2069            .border_color(cx.theme().colors().border)
2070            .bg(cx.theme().colors().editor_background)
2071            .cursor_text()
2072            .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
2073                window.focus(&this.commit_editor.focus_handle(cx));
2074            }))
2075            .when(!self.modal_open, |el| {
2076                el.child(EditorElement::new(&self.commit_editor, panel_editor_style))
2077                    .child(
2078                        h_flex()
2079                            .absolute()
2080                            .bottom_0()
2081                            .left_2()
2082                            .h(footer_size)
2083                            .flex_none()
2084                            .child(branch_selector),
2085                    )
2086                    .child(
2087                        h_flex()
2088                            .absolute()
2089                            .bottom_0()
2090                            .right_2()
2091                            .h(footer_size)
2092                            .flex_none()
2093                            .children(enable_coauthors)
2094                            .child(commit_button),
2095                    )
2096            })
2097    }
2098
2099    fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
2100        let active_repository = self.active_repository.as_ref()?;
2101        let branch = active_repository.read(cx).current_branch()?;
2102        let commit = branch.most_recent_commit.as_ref()?.clone();
2103
2104        let this = cx.entity();
2105        Some(
2106            h_flex()
2107                .items_center()
2108                .py_1p5()
2109                .px(px(8.))
2110                .bg(cx.theme().colors().background)
2111                .border_t_1()
2112                .border_color(cx.theme().colors().border)
2113                .gap_1p5()
2114                .child(
2115                    div()
2116                        .flex_grow()
2117                        .overflow_hidden()
2118                        .max_w(relative(0.6))
2119                        .h_full()
2120                        .child(
2121                            Label::new(commit.subject.clone())
2122                                .size(LabelSize::Small)
2123                                .text_ellipsis(),
2124                        )
2125                        .id("commit-msg-hover")
2126                        .hoverable_tooltip(move |window, cx| {
2127                            GitPanelMessageTooltip::new(
2128                                this.clone(),
2129                                commit.sha.clone(),
2130                                window,
2131                                cx,
2132                            )
2133                            .into()
2134                        }),
2135                )
2136                .child(div().flex_1())
2137                .child(
2138                    panel_filled_button("Uncommit")
2139                        .icon(IconName::Undo)
2140                        .icon_size(IconSize::Small)
2141                        .icon_color(Color::Muted)
2142                        .icon_position(IconPosition::Start)
2143                        .tooltip(Tooltip::for_action_title(
2144                            if self.has_staged_changes() {
2145                                "git reset HEAD^ --soft"
2146                            } else {
2147                                "git reset HEAD^"
2148                            },
2149                            &git::Uncommit,
2150                        ))
2151                        .on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))),
2152                )
2153                .child(self.render_push_button(branch, cx)),
2154        )
2155    }
2156
2157    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
2158        h_flex()
2159            .h_full()
2160            .flex_grow()
2161            .justify_center()
2162            .items_center()
2163            .child(
2164                v_flex()
2165                    .gap_3()
2166                    .child(if self.active_repository.is_some() {
2167                        "No changes to commit"
2168                    } else {
2169                        "No Git repositories"
2170                    })
2171                    .text_ui_sm(cx)
2172                    .mx_auto()
2173                    .text_color(Color::Placeholder.color(cx)),
2174            )
2175    }
2176
2177    fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
2178        let scroll_bar_style = self.show_scrollbar(cx);
2179        let show_container = matches!(scroll_bar_style, ShowScrollbar::Always);
2180
2181        if !self.should_show_scrollbar(cx)
2182            || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
2183        {
2184            return None;
2185        }
2186
2187        Some(
2188            div()
2189                .id("git-panel-vertical-scroll")
2190                .occlude()
2191                .flex_none()
2192                .h_full()
2193                .cursor_default()
2194                .when(show_container, |this| this.pl_1().px_1p5())
2195                .when(!show_container, |this| {
2196                    this.absolute().right_1().top_1().bottom_1().w(px(12.))
2197                })
2198                .on_mouse_move(cx.listener(|_, _, _, cx| {
2199                    cx.notify();
2200                    cx.stop_propagation()
2201                }))
2202                .on_hover(|_, _, cx| {
2203                    cx.stop_propagation();
2204                })
2205                .on_any_mouse_down(|_, _, cx| {
2206                    cx.stop_propagation();
2207                })
2208                .on_mouse_up(
2209                    MouseButton::Left,
2210                    cx.listener(|this, _, window, cx| {
2211                        if !this.scrollbar_state.is_dragging()
2212                            && !this.focus_handle.contains_focused(window, cx)
2213                        {
2214                            this.hide_scrollbar(window, cx);
2215                            cx.notify();
2216                        }
2217
2218                        cx.stop_propagation();
2219                    }),
2220                )
2221                .on_scroll_wheel(cx.listener(|_, _, _, cx| {
2222                    cx.notify();
2223                }))
2224                .children(Scrollbar::vertical(
2225                    // percentage as f32..end_offset as f32,
2226                    self.scrollbar_state.clone(),
2227                )),
2228        )
2229    }
2230
2231    pub fn render_buffer_header_controls(
2232        &self,
2233        entity: &Entity<Self>,
2234        file: &Arc<dyn File>,
2235        _: &Window,
2236        cx: &App,
2237    ) -> Option<AnyElement> {
2238        let repo = self.active_repository.as_ref()?.read(cx);
2239        let repo_path = repo.worktree_id_path_to_repo_path(file.worktree_id(cx), file.path())?;
2240        let ix = self.entry_by_path(&repo_path)?;
2241        let entry = self.entries.get(ix)?;
2242
2243        let is_staged = self.entry_is_staged(entry.status_entry()?);
2244
2245        let checkbox = Checkbox::new("stage-file", is_staged.into())
2246            .disabled(!self.has_write_access(cx))
2247            .fill()
2248            .elevation(ElevationIndex::Surface)
2249            .on_click({
2250                let entry = entry.clone();
2251                let git_panel = entity.downgrade();
2252                move |_, window, cx| {
2253                    git_panel
2254                        .update(cx, |this, cx| {
2255                            this.toggle_staged_for_entry(&entry, window, cx);
2256                            cx.stop_propagation();
2257                        })
2258                        .ok();
2259                }
2260            });
2261        Some(
2262            h_flex()
2263                .id("start-slot")
2264                .text_lg()
2265                .child(checkbox)
2266                .on_mouse_down(MouseButton::Left, |_, _, cx| {
2267                    // prevent the list item active state triggering when toggling checkbox
2268                    cx.stop_propagation();
2269                })
2270                .into_any_element(),
2271        )
2272    }
2273
2274    fn render_entries(
2275        &self,
2276        has_write_access: bool,
2277        _: &Window,
2278        cx: &mut Context<Self>,
2279    ) -> impl IntoElement {
2280        let entry_count = self.entries.len();
2281
2282        v_flex()
2283            .size_full()
2284            .flex_grow()
2285            .overflow_hidden()
2286            .child(
2287                uniform_list(cx.entity().clone(), "entries", entry_count, {
2288                    move |this, range, window, cx| {
2289                        let mut items = Vec::with_capacity(range.end - range.start);
2290
2291                        for ix in range {
2292                            match &this.entries.get(ix) {
2293                                Some(GitListEntry::GitStatusEntry(entry)) => {
2294                                    items.push(this.render_entry(
2295                                        ix,
2296                                        entry,
2297                                        has_write_access,
2298                                        window,
2299                                        cx,
2300                                    ));
2301                                }
2302                                Some(GitListEntry::Header(header)) => {
2303                                    items.push(this.render_list_header(
2304                                        ix,
2305                                        header,
2306                                        has_write_access,
2307                                        window,
2308                                        cx,
2309                                    ));
2310                                }
2311                                None => {}
2312                            }
2313                        }
2314
2315                        items
2316                    }
2317                })
2318                .size_full()
2319                .with_sizing_behavior(ListSizingBehavior::Infer)
2320                .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
2321                .track_scroll(self.scroll_handle.clone()),
2322            )
2323            .on_mouse_down(
2324                MouseButton::Right,
2325                cx.listener(move |this, event: &MouseDownEvent, window, cx| {
2326                    this.deploy_panel_context_menu(event.position, window, cx)
2327                }),
2328            )
2329            .children(self.render_scrollbar(cx))
2330    }
2331
2332    fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
2333        Label::new(label.into()).color(color).single_line()
2334    }
2335
2336    fn render_list_header(
2337        &self,
2338        ix: usize,
2339        header: &GitHeaderEntry,
2340        _: bool,
2341        _: &Window,
2342        _: &Context<Self>,
2343    ) -> AnyElement {
2344        div()
2345            .w_full()
2346            .child(
2347                ListItem::new(ix)
2348                    .spacing(ListItemSpacing::Sparse)
2349                    .disabled(true)
2350                    .child(
2351                        Label::new(header.title())
2352                            .color(Color::Muted)
2353                            .size(LabelSize::Small)
2354                            .single_line(),
2355                    ),
2356            )
2357            .into_any_element()
2358    }
2359
2360    fn load_commit_details(
2361        &self,
2362        sha: &str,
2363        cx: &mut Context<Self>,
2364    ) -> Task<Result<CommitDetails>> {
2365        let Some(repo) = self.active_repository.clone() else {
2366            return Task::ready(Err(anyhow::anyhow!("no active repo")));
2367        };
2368
2369        let show = repo.read(cx).show(sha);
2370        cx.spawn(|_, _| async move { show.await? })
2371    }
2372
2373    fn deploy_entry_context_menu(
2374        &mut self,
2375        position: Point<Pixels>,
2376        ix: usize,
2377        window: &mut Window,
2378        cx: &mut Context<Self>,
2379    ) {
2380        let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else {
2381            return;
2382        };
2383        let stage_title = if entry.status.is_staged() == Some(true) {
2384            "Unstage File"
2385        } else {
2386            "Stage File"
2387        };
2388        let restore_title = if entry.status.is_created() {
2389            "Trash File"
2390        } else {
2391            "Restore File"
2392        };
2393        let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
2394            context_menu
2395                .action(stage_title, ToggleStaged.boxed_clone())
2396                .action(restore_title, git::RestoreFile.boxed_clone())
2397                .separator()
2398                .action("Open Diff", Confirm.boxed_clone())
2399                .action("Open File", SecondaryConfirm.boxed_clone())
2400        });
2401        self.selected_entry = Some(ix);
2402        self.set_context_menu(context_menu, position, window, cx);
2403    }
2404
2405    fn panel_context_menu(window: &mut Window, cx: &mut App) -> Entity<ContextMenu> {
2406        ContextMenu::build(window, cx, |context_menu, _, _| {
2407            context_menu
2408                .action("Stage All", StageAll.boxed_clone())
2409                .action("Unstage All", UnstageAll.boxed_clone())
2410                .separator()
2411                .action("Open Diff", project_diff::Diff.boxed_clone())
2412                .separator()
2413                .action("Restore Tracked Files", RestoreTrackedFiles.boxed_clone())
2414                .action("Trash Untracked Files", TrashUntrackedFiles.boxed_clone())
2415        })
2416    }
2417
2418    fn deploy_panel_context_menu(
2419        &mut self,
2420        position: Point<Pixels>,
2421        window: &mut Window,
2422        cx: &mut Context<Self>,
2423    ) {
2424        let context_menu = Self::panel_context_menu(window, cx);
2425        self.set_context_menu(context_menu, position, window, cx);
2426    }
2427
2428    fn set_context_menu(
2429        &mut self,
2430        context_menu: Entity<ContextMenu>,
2431        position: Point<Pixels>,
2432        window: &Window,
2433        cx: &mut Context<Self>,
2434    ) {
2435        let subscription = cx.subscribe_in(
2436            &context_menu,
2437            window,
2438            |this, _, _: &DismissEvent, window, cx| {
2439                if this.context_menu.as_ref().is_some_and(|context_menu| {
2440                    context_menu.0.focus_handle(cx).contains_focused(window, cx)
2441                }) {
2442                    cx.focus_self(window);
2443                }
2444                this.context_menu.take();
2445                cx.notify();
2446            },
2447        );
2448        self.context_menu = Some((context_menu, position, subscription));
2449        cx.notify();
2450    }
2451
2452    fn render_entry(
2453        &self,
2454        ix: usize,
2455        entry: &GitStatusEntry,
2456        has_write_access: bool,
2457        window: &Window,
2458        cx: &Context<Self>,
2459    ) -> AnyElement {
2460        let display_name = entry
2461            .repo_path
2462            .file_name()
2463            .map(|name| name.to_string_lossy().into_owned())
2464            .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
2465
2466        let repo_path = entry.repo_path.clone();
2467        let selected = self.selected_entry == Some(ix);
2468        let status_style = GitPanelSettings::get_global(cx).status_style;
2469        let status = entry.status;
2470        let has_conflict = status.is_conflicted();
2471        let is_modified = status.is_modified();
2472        let is_deleted = status.is_deleted();
2473
2474        let label_color = if status_style == StatusStyle::LabelColor {
2475            if has_conflict {
2476                Color::Conflict
2477            } else if is_modified {
2478                Color::Modified
2479            } else if is_deleted {
2480                // We don't want a bunch of red labels in the list
2481                Color::Disabled
2482            } else {
2483                Color::Created
2484            }
2485        } else {
2486            Color::Default
2487        };
2488
2489        let path_color = if status.is_deleted() {
2490            Color::Disabled
2491        } else {
2492            Color::Muted
2493        };
2494
2495        let id: ElementId = ElementId::Name(format!("entry_{}", display_name).into());
2496
2497        let is_entry_staged = self.entry_is_staged(entry);
2498        let mut is_staged: ToggleState = self.entry_is_staged(entry).into();
2499
2500        if !self.has_staged_changes() && !self.has_conflicts() && !entry.status.is_created() {
2501            is_staged = ToggleState::Selected;
2502        }
2503
2504        let checkbox = Checkbox::new(id, is_staged)
2505            .disabled(!has_write_access)
2506            .fill()
2507            .placeholder(!self.has_staged_changes() && !self.has_conflicts())
2508            .elevation(ElevationIndex::Surface)
2509            .on_click({
2510                let entry = entry.clone();
2511                cx.listener(move |this, _, window, cx| {
2512                    this.toggle_staged_for_entry(
2513                        &GitListEntry::GitStatusEntry(entry.clone()),
2514                        window,
2515                        cx,
2516                    );
2517                    cx.stop_propagation();
2518                })
2519            });
2520
2521        let start_slot = h_flex()
2522            .id(("start-slot", ix))
2523            .gap(DynamicSpacing::Base04.rems(cx))
2524            .child(checkbox.tooltip(move |window, cx| {
2525                let tooltip_name = if is_entry_staged.unwrap_or(false) {
2526                    "Unstage"
2527                } else {
2528                    "Stage"
2529                };
2530
2531                Tooltip::for_action(tooltip_name, &ToggleStaged, window, cx)
2532            }))
2533            .child(git_status_icon(status, cx))
2534            .on_mouse_down(MouseButton::Left, |_, _, cx| {
2535                // prevent the list item active state triggering when toggling checkbox
2536                cx.stop_propagation();
2537            });
2538
2539        div()
2540            .w_full()
2541            .child(
2542                ListItem::new(ix)
2543                    .spacing(ListItemSpacing::Sparse)
2544                    .start_slot(start_slot)
2545                    .toggle_state(selected)
2546                    .focused(selected && self.focus_handle(cx).is_focused(window))
2547                    .disabled(!has_write_access)
2548                    .on_click({
2549                        cx.listener(move |this, event: &ClickEvent, window, cx| {
2550                            this.selected_entry = Some(ix);
2551                            cx.notify();
2552                            if event.modifiers().secondary() {
2553                                this.open_file(&Default::default(), window, cx)
2554                            } else {
2555                                this.open_diff(&Default::default(), window, cx);
2556                            }
2557                        })
2558                    })
2559                    .on_secondary_mouse_down(cx.listener(
2560                        move |this, event: &MouseDownEvent, window, cx| {
2561                            this.deploy_entry_context_menu(event.position, ix, window, cx);
2562                            cx.stop_propagation();
2563                        },
2564                    ))
2565                    .child(
2566                        h_flex()
2567                            .when_some(repo_path.parent(), |this, parent| {
2568                                let parent_str = parent.to_string_lossy();
2569                                if !parent_str.is_empty() {
2570                                    this.child(
2571                                        self.entry_label(format!("{}/", parent_str), path_color)
2572                                            .when(status.is_deleted(), |this| this.strikethrough()),
2573                                    )
2574                                } else {
2575                                    this
2576                                }
2577                            })
2578                            .child(
2579                                self.entry_label(display_name.clone(), label_color)
2580                                    .when(status.is_deleted(), |this| this.strikethrough()),
2581                            ),
2582                    ),
2583            )
2584            .into_any_element()
2585    }
2586
2587    fn render_push_button(&self, branch: &Branch, cx: &Context<Self>) -> AnyElement {
2588        let mut disabled = false;
2589
2590        // TODO: Add <origin> and <branch> argument substitutions to this
2591        let button: SharedString;
2592        let tooltip: SharedString;
2593        let action: Option<Push>;
2594        if let Some(upstream) = &branch.upstream {
2595            match upstream.tracking {
2596                UpstreamTracking::Gone => {
2597                    button = "Republish".into();
2598                    tooltip = "git push --set-upstream".into();
2599                    action = Some(git::Push {
2600                        options: Some(PushOptions::SetUpstream),
2601                    });
2602                }
2603                UpstreamTracking::Tracked(tracking) => {
2604                    if tracking.behind > 0 {
2605                        disabled = true;
2606                        button = "Push".into();
2607                        tooltip = "Upstream is ahead of local branch".into();
2608                        action = None;
2609                    } else if tracking.ahead > 0 {
2610                        button = format!("Push ({})", tracking.ahead).into();
2611                        tooltip = "git push".into();
2612                        action = Some(git::Push { options: None });
2613                    } else {
2614                        disabled = true;
2615                        button = "Push".into();
2616                        tooltip = "Upstream matches local branch".into();
2617                        action = None;
2618                    }
2619                }
2620            }
2621        } else {
2622            button = "Publish".into();
2623            tooltip = "git push --set-upstream".into();
2624            action = Some(git::Push {
2625                options: Some(PushOptions::SetUpstream),
2626            });
2627        };
2628
2629        panel_filled_button(button)
2630            .icon(IconName::ArrowUp)
2631            .icon_size(IconSize::Small)
2632            .icon_color(Color::Muted)
2633            .icon_position(IconPosition::Start)
2634            .disabled(disabled)
2635            .when_some(action, |this, action| {
2636                this.on_click(
2637                    cx.listener(move |this, _, window, cx| this.push(&action, window, cx)),
2638                )
2639            })
2640            .tooltip(move |window, cx| {
2641                if let Some(action) = action.as_ref() {
2642                    Tooltip::for_action(tooltip.clone(), action, window, cx)
2643                } else {
2644                    Tooltip::simple(tooltip.clone(), cx)
2645                }
2646            })
2647            .into_any_element()
2648    }
2649
2650    fn has_write_access(&self, cx: &App) -> bool {
2651        !self.project.read(cx).is_read_only(cx)
2652    }
2653}
2654
2655impl Render for GitPanel {
2656    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2657        let project = self.project.read(cx);
2658        let has_entries = self.entries.len() > 0;
2659        let room = self
2660            .workspace
2661            .upgrade()
2662            .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
2663
2664        let has_write_access = self.has_write_access(cx);
2665
2666        let has_co_authors = room.map_or(false, |room| {
2667            room.read(cx)
2668                .remote_participants()
2669                .values()
2670                .any(|remote_participant| remote_participant.can_write())
2671        });
2672
2673        v_flex()
2674            .id("git_panel")
2675            .key_context(self.dispatch_context(window, cx))
2676            .track_focus(&self.focus_handle)
2677            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
2678            .when(has_write_access && !project.is_read_only(cx), |this| {
2679                this.on_action(cx.listener(|this, &ToggleStaged, window, cx| {
2680                    this.toggle_staged_for_selected(&ToggleStaged, window, cx)
2681                }))
2682                .on_action(cx.listener(GitPanel::commit))
2683            })
2684            .on_action(cx.listener(Self::select_first))
2685            .on_action(cx.listener(Self::select_next))
2686            .on_action(cx.listener(Self::select_prev))
2687            .on_action(cx.listener(Self::select_last))
2688            .on_action(cx.listener(Self::close_panel))
2689            .on_action(cx.listener(Self::open_diff))
2690            .on_action(cx.listener(Self::open_file))
2691            .on_action(cx.listener(Self::revert_selected))
2692            .on_action(cx.listener(Self::focus_changes_list))
2693            .on_action(cx.listener(Self::focus_editor))
2694            .on_action(cx.listener(Self::toggle_staged_for_selected))
2695            .on_action(cx.listener(Self::stage_all))
2696            .on_action(cx.listener(Self::unstage_all))
2697            .on_action(cx.listener(Self::restore_tracked_files))
2698            .on_action(cx.listener(Self::clean_all))
2699            .on_action(cx.listener(Self::fetch))
2700            .on_action(cx.listener(Self::pull))
2701            .on_action(cx.listener(Self::push))
2702            .when(has_write_access && has_co_authors, |git_panel| {
2703                git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
2704            })
2705            // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx)))
2706            .on_hover(cx.listener(|this, hovered, window, cx| {
2707                if *hovered {
2708                    this.show_scrollbar = true;
2709                    this.hide_scrollbar_task.take();
2710                    cx.notify();
2711                } else if !this.focus_handle.contains_focused(window, cx) {
2712                    this.hide_scrollbar(window, cx);
2713                }
2714            }))
2715            .size_full()
2716            .overflow_hidden()
2717            .bg(ElevationIndex::Surface.bg(cx))
2718            .child(
2719                v_flex()
2720                    .size_full()
2721                    .children(self.render_panel_header(window, cx))
2722                    .map(|this| {
2723                        if has_entries {
2724                            this.child(self.render_entries(has_write_access, window, cx))
2725                        } else {
2726                            this.child(self.render_empty_state(cx).into_any_element())
2727                        }
2728                    })
2729                    .children(self.render_previous_commit(cx))
2730                    .child(self.render_commit_editor(window, cx))
2731                    .into_any_element(),
2732            )
2733            .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2734                deferred(
2735                    anchored()
2736                        .position(*position)
2737                        .anchor(gpui::Corner::TopLeft)
2738                        .child(menu.clone()),
2739                )
2740                .with_priority(1)
2741            }))
2742    }
2743}
2744
2745impl Focusable for GitPanel {
2746    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
2747        self.focus_handle.clone()
2748    }
2749}
2750
2751impl EventEmitter<Event> for GitPanel {}
2752
2753impl EventEmitter<PanelEvent> for GitPanel {}
2754
2755pub(crate) struct GitPanelAddon {
2756    pub(crate) workspace: WeakEntity<Workspace>,
2757}
2758
2759impl editor::Addon for GitPanelAddon {
2760    fn to_any(&self) -> &dyn std::any::Any {
2761        self
2762    }
2763
2764    fn render_buffer_header_controls(
2765        &self,
2766        excerpt_info: &ExcerptInfo,
2767        window: &Window,
2768        cx: &App,
2769    ) -> Option<AnyElement> {
2770        let file = excerpt_info.buffer.file()?;
2771        let git_panel = self.workspace.upgrade()?.read(cx).panel::<GitPanel>(cx)?;
2772
2773        git_panel
2774            .read(cx)
2775            .render_buffer_header_controls(&git_panel, &file, window, cx)
2776    }
2777}
2778
2779impl Panel for GitPanel {
2780    fn persistent_name() -> &'static str {
2781        "GitPanel"
2782    }
2783
2784    fn position(&self, _: &Window, cx: &App) -> DockPosition {
2785        GitPanelSettings::get_global(cx).dock
2786    }
2787
2788    fn position_is_valid(&self, position: DockPosition) -> bool {
2789        matches!(position, DockPosition::Left | DockPosition::Right)
2790    }
2791
2792    fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
2793        settings::update_settings_file::<GitPanelSettings>(
2794            self.fs.clone(),
2795            cx,
2796            move |settings, _| settings.dock = Some(position),
2797        );
2798    }
2799
2800    fn size(&self, _: &Window, cx: &App) -> Pixels {
2801        self.width
2802            .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
2803    }
2804
2805    fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
2806        self.width = size;
2807        self.serialize(cx);
2808        cx.notify();
2809    }
2810
2811    fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
2812        Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
2813    }
2814
2815    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
2816        Some("Git Panel")
2817    }
2818
2819    fn toggle_action(&self) -> Box<dyn Action> {
2820        Box::new(ToggleFocus)
2821    }
2822
2823    fn activation_priority(&self) -> u32 {
2824        2
2825    }
2826}
2827
2828impl PanelHeader for GitPanel {}
2829
2830struct GitPanelMessageTooltip {
2831    commit_tooltip: Option<Entity<CommitTooltip>>,
2832}
2833
2834impl GitPanelMessageTooltip {
2835    fn new(
2836        git_panel: Entity<GitPanel>,
2837        sha: SharedString,
2838        window: &mut Window,
2839        cx: &mut App,
2840    ) -> Entity<Self> {
2841        cx.new(|cx| {
2842            cx.spawn_in(window, |this, mut cx| async move {
2843                let details = git_panel
2844                    .update(&mut cx, |git_panel, cx| {
2845                        git_panel.load_commit_details(&sha, cx)
2846                    })?
2847                    .await?;
2848
2849                let commit_details = editor::commit_tooltip::CommitDetails {
2850                    sha: details.sha.clone(),
2851                    committer_name: details.committer_name.clone(),
2852                    committer_email: details.committer_email.clone(),
2853                    commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
2854                    message: Some(editor::commit_tooltip::ParsedCommitMessage {
2855                        message: details.message.clone(),
2856                        ..Default::default()
2857                    }),
2858                };
2859
2860                this.update_in(&mut cx, |this: &mut GitPanelMessageTooltip, window, cx| {
2861                    this.commit_tooltip =
2862                        Some(cx.new(move |cx| CommitTooltip::new(commit_details, window, cx)));
2863                    cx.notify();
2864                })
2865            })
2866            .detach();
2867
2868            Self {
2869                commit_tooltip: None,
2870            }
2871        })
2872    }
2873}
2874
2875impl Render for GitPanelMessageTooltip {
2876    fn render(&mut self, _window: &mut Window, _cx: &mut Context<'_, Self>) -> impl IntoElement {
2877        if let Some(commit_tooltip) = &self.commit_tooltip {
2878            commit_tooltip.clone().into_any_element()
2879        } else {
2880            gpui::Empty.into_any_element()
2881        }
2882    }
2883}