git_panel.rs

   1use crate::git_panel_settings::StatusStyle;
   2use crate::repository_selector::RepositorySelectorPopoverMenu;
   3use crate::ProjectDiff;
   4use crate::{
   5    git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
   6};
   7use collections::HashMap;
   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::{CommitDetails, ResetMode};
  15use git::{repository::RepoPath, status::FileStatus, Commit, ToggleStaged};
  16use gpui::*;
  17use itertools::Itertools;
  18use language::{markdown, Buffer, File, ParsedMarkdown};
  19use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
  20use multi_buffer::ExcerptInfo;
  21use panel::{panel_editor_container, panel_editor_style, panel_filled_button, PanelHeader};
  22use project::{
  23    git::{GitEvent, Repository},
  24    Fs, Project, ProjectPath,
  25};
  26use serde::{Deserialize, Serialize};
  27use settings::Settings as _;
  28use std::{collections::HashSet, path::PathBuf, sync::Arc, time::Duration, usize};
  29use time::OffsetDateTime;
  30use ui::{
  31    prelude::*, ButtonLike, Checkbox, ContextMenu, Divider, DividerColor, ElevationIndex, ListItem,
  32    ListItemSpacing, Scrollbar, ScrollbarState, Tooltip,
  33};
  34use util::{maybe, ResultExt, TryFutureExt};
  35use workspace::SaveIntent;
  36use workspace::{
  37    dock::{DockPosition, Panel, PanelEvent},
  38    notifications::{DetachAndPromptErr, NotificationId},
  39    Toast, Workspace,
  40};
  41
  42actions!(
  43    git_panel,
  44    [
  45        Close,
  46        ToggleFocus,
  47        OpenMenu,
  48        FocusEditor,
  49        FocusChanges,
  50        ToggleFillCoAuthors,
  51    ]
  52);
  53
  54const GIT_PANEL_KEY: &str = "GitPanel";
  55
  56const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
  57
  58pub fn init(cx: &mut App) {
  59    cx.observe_new(
  60        |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
  61            workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
  62                workspace.toggle_panel_focus::<GitPanel>(window, cx);
  63            });
  64
  65            workspace.register_action(|workspace, _: &Commit, window, cx| {
  66                workspace.open_panel::<GitPanel>(window, cx);
  67                if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
  68                    git_panel
  69                        .read(cx)
  70                        .commit_editor
  71                        .focus_handle(cx)
  72                        .focus(window);
  73                }
  74            });
  75        },
  76    )
  77    .detach();
  78}
  79
  80#[derive(Debug, Clone)]
  81pub enum Event {
  82    Focus,
  83}
  84
  85#[derive(Serialize, Deserialize)]
  86struct SerializedGitPanel {
  87    width: Option<Pixels>,
  88}
  89
  90#[derive(Debug, PartialEq, Eq, Clone, Copy)]
  91enum Section {
  92    Conflict,
  93    Tracked,
  94    New,
  95}
  96
  97#[derive(Debug, PartialEq, Eq, Clone)]
  98struct GitHeaderEntry {
  99    header: Section,
 100}
 101
 102impl GitHeaderEntry {
 103    pub fn contains(&self, status_entry: &GitStatusEntry, repo: &Repository) -> bool {
 104        let this = &self.header;
 105        let status = status_entry.status;
 106        match this {
 107            Section::Conflict => repo.has_conflict(&status_entry.repo_path),
 108            Section::Tracked => !status.is_created(),
 109            Section::New => status.is_created(),
 110        }
 111    }
 112    pub fn title(&self) -> &'static str {
 113        match self.header {
 114            Section::Conflict => "Conflicts",
 115            Section::Tracked => "Changes",
 116            Section::New => "New",
 117        }
 118    }
 119}
 120
 121#[derive(Debug, PartialEq, Eq, Clone)]
 122enum GitListEntry {
 123    GitStatusEntry(GitStatusEntry),
 124    Header(GitHeaderEntry),
 125}
 126
 127impl GitListEntry {
 128    fn status_entry(&self) -> Option<&GitStatusEntry> {
 129        match self {
 130            GitListEntry::GitStatusEntry(entry) => Some(entry),
 131            _ => None,
 132        }
 133    }
 134}
 135
 136#[derive(Debug, PartialEq, Eq, Clone)]
 137pub struct GitStatusEntry {
 138    pub(crate) depth: usize,
 139    pub(crate) display_name: String,
 140    pub(crate) repo_path: RepoPath,
 141    pub(crate) status: FileStatus,
 142    pub(crate) is_staged: Option<bool>,
 143}
 144
 145struct PendingOperation {
 146    finished: bool,
 147    will_become_staged: bool,
 148    repo_paths: HashSet<RepoPath>,
 149    op_id: usize,
 150}
 151
 152pub struct GitPanel {
 153    active_repository: Option<Entity<Repository>>,
 154    commit_editor: Entity<Editor>,
 155    conflicted_count: usize,
 156    conflicted_staged_count: usize,
 157    current_modifiers: Modifiers,
 158    add_coauthors: bool,
 159    entries: Vec<GitListEntry>,
 160    entries_by_path: collections::HashMap<RepoPath, usize>,
 161    focus_handle: FocusHandle,
 162    fs: Arc<dyn Fs>,
 163    hide_scrollbar_task: Option<Task<()>>,
 164    new_count: usize,
 165    new_staged_count: usize,
 166    pending: Vec<PendingOperation>,
 167    pending_commit: Option<Task<()>>,
 168    pending_serialization: Task<Option<()>>,
 169    project: Entity<Project>,
 170    repository_selector: Entity<RepositorySelector>,
 171    scroll_handle: UniformListScrollHandle,
 172    scrollbar_state: ScrollbarState,
 173    selected_entry: Option<usize>,
 174    show_scrollbar: bool,
 175    tracked_count: usize,
 176    tracked_staged_count: usize,
 177    update_visible_entries_task: Task<()>,
 178    width: Option<Pixels>,
 179    workspace: WeakEntity<Workspace>,
 180    context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
 181}
 182
 183fn commit_message_editor(
 184    commit_message_buffer: Entity<Buffer>,
 185    project: Entity<Project>,
 186    window: &mut Window,
 187    cx: &mut Context<'_, Editor>,
 188) -> Editor {
 189    let buffer = cx.new(|cx| MultiBuffer::singleton(commit_message_buffer, cx));
 190    let mut commit_editor = Editor::new(
 191        EditorMode::AutoHeight { max_lines: 6 },
 192        buffer,
 193        None,
 194        false,
 195        window,
 196        cx,
 197    );
 198    commit_editor.set_collaboration_hub(Box::new(project));
 199    commit_editor.set_use_autoclose(false);
 200    commit_editor.set_show_gutter(false, cx);
 201    commit_editor.set_show_wrap_guides(false, cx);
 202    commit_editor.set_show_indent_guides(false, cx);
 203    commit_editor.set_placeholder_text("Enter commit message", cx);
 204    commit_editor
 205}
 206
 207impl GitPanel {
 208    pub fn new(
 209        workspace: &mut Workspace,
 210        window: &mut Window,
 211        cx: &mut Context<Workspace>,
 212    ) -> Entity<Self> {
 213        let fs = workspace.app_state().fs.clone();
 214        let project = workspace.project().clone();
 215        let git_store = project.read(cx).git_store().clone();
 216        let active_repository = project.read(cx).active_repository(cx);
 217        let workspace = cx.entity().downgrade();
 218
 219        cx.new(|cx| {
 220            let focus_handle = cx.focus_handle();
 221            cx.on_focus(&focus_handle, window, Self::focus_in).detach();
 222            cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
 223                this.hide_scrollbar(window, cx);
 224            })
 225            .detach();
 226
 227            // just to let us render a placeholder editor.
 228            // Once the active git repo is set, this buffer will be replaced.
 229            let temporary_buffer = cx.new(|cx| Buffer::local("", cx));
 230            let commit_editor =
 231                cx.new(|cx| commit_message_editor(temporary_buffer, project.clone(), window, cx));
 232            commit_editor.update(cx, |editor, cx| {
 233                editor.clear(window, cx);
 234            });
 235
 236            let scroll_handle = UniformListScrollHandle::new();
 237
 238            cx.subscribe_in(
 239                &git_store,
 240                window,
 241                move |this, git_store, event, window, cx| match event {
 242                    GitEvent::FileSystemUpdated => {
 243                        this.schedule_update(false, window, cx);
 244                    }
 245                    GitEvent::ActiveRepositoryChanged | GitEvent::GitStateUpdated => {
 246                        this.active_repository = git_store.read(cx).active_repository();
 247                        this.schedule_update(true, window, cx);
 248                    }
 249                },
 250            )
 251            .detach();
 252
 253            let scrollbar_state =
 254                ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity());
 255
 256            let repository_selector =
 257                cx.new(|cx| RepositorySelector::new(project.clone(), window, cx));
 258
 259            let mut git_panel = Self {
 260                active_repository,
 261                commit_editor,
 262                conflicted_count: 0,
 263                conflicted_staged_count: 0,
 264                current_modifiers: window.modifiers(),
 265                add_coauthors: true,
 266                entries: Vec::new(),
 267                entries_by_path: HashMap::default(),
 268                focus_handle: cx.focus_handle(),
 269                fs,
 270                hide_scrollbar_task: None,
 271                new_count: 0,
 272                new_staged_count: 0,
 273                pending: Vec::new(),
 274                pending_commit: None,
 275                pending_serialization: Task::ready(None),
 276                project,
 277                repository_selector,
 278                scroll_handle,
 279                scrollbar_state,
 280                selected_entry: None,
 281                show_scrollbar: false,
 282                tracked_count: 0,
 283                tracked_staged_count: 0,
 284                update_visible_entries_task: Task::ready(()),
 285                width: Some(px(360.)),
 286                context_menu: None,
 287                workspace,
 288            };
 289            git_panel.schedule_update(false, window, cx);
 290            git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx);
 291            git_panel
 292        })
 293    }
 294
 295    pub fn select_entry_by_path(
 296        &mut self,
 297        path: ProjectPath,
 298        _: &mut Window,
 299        cx: &mut Context<Self>,
 300    ) {
 301        let Some(git_repo) = self.active_repository.as_ref() else {
 302            return;
 303        };
 304        let Some(repo_path) = git_repo.read(cx).project_path_to_repo_path(&path) else {
 305            return;
 306        };
 307        let Some(ix) = self.entries_by_path.get(&repo_path) else {
 308            return;
 309        };
 310        self.selected_entry = Some(*ix);
 311        cx.notify();
 312    }
 313
 314    fn serialize(&mut self, cx: &mut Context<Self>) {
 315        let width = self.width;
 316        self.pending_serialization = cx.background_executor().spawn(
 317            async move {
 318                KEY_VALUE_STORE
 319                    .write_kvp(
 320                        GIT_PANEL_KEY.into(),
 321                        serde_json::to_string(&SerializedGitPanel { width })?,
 322                    )
 323                    .await?;
 324                anyhow::Ok(())
 325            }
 326            .log_err(),
 327        );
 328    }
 329
 330    fn dispatch_context(&self, window: &mut Window, cx: &Context<Self>) -> KeyContext {
 331        let mut dispatch_context = KeyContext::new_with_defaults();
 332        dispatch_context.add("GitPanel");
 333
 334        if self.is_focused(window, cx) {
 335            dispatch_context.add("menu");
 336            dispatch_context.add("ChangesList");
 337        }
 338
 339        if self.commit_editor.read(cx).is_focused(window) {
 340            dispatch_context.add("CommitEditor");
 341        }
 342
 343        dispatch_context
 344    }
 345
 346    fn is_focused(&self, window: &Window, cx: &Context<Self>) -> bool {
 347        window
 348            .focused(cx)
 349            .map_or(false, |focused| self.focus_handle == focused)
 350    }
 351
 352    fn close_panel(&mut self, _: &Close, _window: &mut Window, cx: &mut Context<Self>) {
 353        cx.emit(PanelEvent::Close);
 354    }
 355
 356    fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 357        if !self.focus_handle.contains_focused(window, cx) {
 358            cx.emit(Event::Focus);
 359        }
 360    }
 361
 362    fn show_scrollbar(&self, cx: &mut Context<Self>) -> ShowScrollbar {
 363        GitPanelSettings::get_global(cx)
 364            .scrollbar
 365            .show
 366            .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show)
 367    }
 368
 369    fn should_show_scrollbar(&self, cx: &mut Context<Self>) -> bool {
 370        let show = self.show_scrollbar(cx);
 371        match show {
 372            ShowScrollbar::Auto => true,
 373            ShowScrollbar::System => true,
 374            ShowScrollbar::Always => true,
 375            ShowScrollbar::Never => false,
 376        }
 377    }
 378
 379    fn should_autohide_scrollbar(&self, cx: &mut Context<Self>) -> bool {
 380        let show = self.show_scrollbar(cx);
 381        match show {
 382            ShowScrollbar::Auto => true,
 383            ShowScrollbar::System => cx
 384                .try_global::<ScrollbarAutoHide>()
 385                .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
 386            ShowScrollbar::Always => false,
 387            ShowScrollbar::Never => true,
 388        }
 389    }
 390
 391    fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 392        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
 393        if !self.should_autohide_scrollbar(cx) {
 394            return;
 395        }
 396        self.hide_scrollbar_task = Some(cx.spawn_in(window, |panel, mut cx| async move {
 397            cx.background_executor()
 398                .timer(SCROLLBAR_SHOW_INTERVAL)
 399                .await;
 400            panel
 401                .update(&mut cx, |panel, cx| {
 402                    panel.show_scrollbar = false;
 403                    cx.notify();
 404                })
 405                .log_err();
 406        }))
 407    }
 408
 409    fn handle_modifiers_changed(
 410        &mut self,
 411        event: &ModifiersChangedEvent,
 412        _: &mut Window,
 413        cx: &mut Context<Self>,
 414    ) {
 415        self.current_modifiers = event.modifiers;
 416        cx.notify();
 417    }
 418
 419    fn calculate_depth_and_difference(
 420        repo_path: &RepoPath,
 421        visible_entries: &HashSet<RepoPath>,
 422    ) -> (usize, usize) {
 423        let ancestors = repo_path.ancestors().skip(1);
 424        for ancestor in ancestors {
 425            if let Some(parent_entry) = visible_entries.get(ancestor) {
 426                let entry_component_count = repo_path.components().count();
 427                let parent_component_count = parent_entry.components().count();
 428
 429                let difference = entry_component_count - parent_component_count;
 430
 431                let parent_depth = parent_entry
 432                    .ancestors()
 433                    .skip(1) // Skip the parent itself
 434                    .filter(|ancestor| visible_entries.contains(*ancestor))
 435                    .count();
 436
 437                return (parent_depth + 1, difference);
 438            }
 439        }
 440
 441        (0, 0)
 442    }
 443
 444    fn scroll_to_selected_entry(&mut self, cx: &mut Context<Self>) {
 445        if let Some(selected_entry) = self.selected_entry {
 446            self.scroll_handle
 447                .scroll_to_item(selected_entry, ScrollStrategy::Center);
 448        }
 449
 450        cx.notify();
 451    }
 452
 453    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
 454        if self.entries.first().is_some() {
 455            self.selected_entry = Some(1);
 456            self.scroll_to_selected_entry(cx);
 457        }
 458    }
 459
 460    fn select_prev(&mut self, _: &SelectPrev, _window: &mut Window, cx: &mut Context<Self>) {
 461        let item_count = self.entries.len();
 462        if item_count == 0 {
 463            return;
 464        }
 465
 466        if let Some(selected_entry) = self.selected_entry {
 467            let new_selected_entry = if selected_entry > 0 {
 468                selected_entry - 1
 469            } else {
 470                selected_entry
 471            };
 472
 473            if matches!(
 474                self.entries.get(new_selected_entry),
 475                Some(GitListEntry::Header(..))
 476            ) {
 477                if new_selected_entry > 0 {
 478                    self.selected_entry = Some(new_selected_entry - 1)
 479                }
 480            } else {
 481                self.selected_entry = Some(new_selected_entry);
 482            }
 483
 484            self.scroll_to_selected_entry(cx);
 485        }
 486
 487        cx.notify();
 488    }
 489
 490    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
 491        let item_count = self.entries.len();
 492        if item_count == 0 {
 493            return;
 494        }
 495
 496        if let Some(selected_entry) = self.selected_entry {
 497            let new_selected_entry = if selected_entry < item_count - 1 {
 498                selected_entry + 1
 499            } else {
 500                selected_entry
 501            };
 502            if matches!(
 503                self.entries.get(new_selected_entry),
 504                Some(GitListEntry::Header(..))
 505            ) {
 506                self.selected_entry = Some(new_selected_entry + 1);
 507            } else {
 508                self.selected_entry = Some(new_selected_entry);
 509            }
 510
 511            self.scroll_to_selected_entry(cx);
 512        }
 513
 514        cx.notify();
 515    }
 516
 517    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
 518        if self.entries.last().is_some() {
 519            self.selected_entry = Some(self.entries.len() - 1);
 520            self.scroll_to_selected_entry(cx);
 521        }
 522    }
 523
 524    fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
 525        self.commit_editor.update(cx, |editor, cx| {
 526            window.focus(&editor.focus_handle(cx));
 527        });
 528        cx.notify();
 529    }
 530
 531    fn select_first_entry_if_none(&mut self, cx: &mut Context<Self>) {
 532        let have_entries = self
 533            .active_repository
 534            .as_ref()
 535            .map_or(false, |active_repository| {
 536                active_repository.read(cx).entry_count() > 0
 537            });
 538        if have_entries && self.selected_entry.is_none() {
 539            self.selected_entry = Some(1);
 540            self.scroll_to_selected_entry(cx);
 541            cx.notify();
 542        }
 543    }
 544
 545    fn focus_changes_list(
 546        &mut self,
 547        _: &FocusChanges,
 548        window: &mut Window,
 549        cx: &mut Context<Self>,
 550    ) {
 551        self.select_first_entry_if_none(cx);
 552
 553        cx.focus_self(window);
 554        cx.notify();
 555    }
 556
 557    fn get_selected_entry(&self) -> Option<&GitListEntry> {
 558        self.selected_entry.and_then(|i| self.entries.get(i))
 559    }
 560
 561    fn open_diff(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
 562        maybe!({
 563            let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
 564
 565            self.workspace
 566                .update(cx, |workspace, cx| {
 567                    ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
 568                })
 569                .ok()
 570        });
 571        self.focus_handle.focus(window);
 572    }
 573
 574    fn open_file(
 575        &mut self,
 576        _: &menu::SecondaryConfirm,
 577        window: &mut Window,
 578        cx: &mut Context<Self>,
 579    ) {
 580        maybe!({
 581            let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
 582            let active_repo = self.active_repository.as_ref()?;
 583            let path = active_repo
 584                .read(cx)
 585                .repo_path_to_project_path(&entry.repo_path)?;
 586            if entry.status.is_deleted() {
 587                return None;
 588            }
 589
 590            self.workspace
 591                .update(cx, |workspace, cx| {
 592                    workspace
 593                        .open_path_preview(path, None, false, false, window, cx)
 594                        .detach_and_prompt_err("Failed to open file", window, cx, |e, _, _| {
 595                            Some(format!("{e}"))
 596                        });
 597                })
 598                .ok()
 599        });
 600    }
 601
 602    fn revert(
 603        &mut self,
 604        _: &editor::actions::RevertFile,
 605        window: &mut Window,
 606        cx: &mut Context<Self>,
 607    ) {
 608        maybe!({
 609            let list_entry = self.entries.get(self.selected_entry?)?.clone();
 610            let entry = list_entry.status_entry()?;
 611            let active_repo = self.active_repository.as_ref()?;
 612            let path = active_repo
 613                .read(cx)
 614                .repo_path_to_project_path(&entry.repo_path)?;
 615            let workspace = self.workspace.clone();
 616
 617            if entry.status.is_staged() != Some(false) {
 618                self.update_staging_area_for_entries(false, vec![entry.repo_path.clone()], cx);
 619            }
 620
 621            if entry.status.is_created() {
 622                let prompt = window.prompt(
 623                    PromptLevel::Info,
 624                    "Do you want to trash this file?",
 625                    None,
 626                    &["Trash", "Cancel"],
 627                    cx,
 628                );
 629                cx.spawn_in(window, |_, mut cx| async move {
 630                    match prompt.await {
 631                        Ok(0) => {}
 632                        _ => return Ok(()),
 633                    }
 634                    let task = workspace.update(&mut cx, |workspace, cx| {
 635                        workspace
 636                            .project()
 637                            .update(cx, |project, cx| project.delete_file(path, true, cx))
 638                    })?;
 639                    if let Some(task) = task {
 640                        task.await?;
 641                    }
 642                    Ok(())
 643                })
 644                .detach_and_prompt_err(
 645                    "Failed to trash file",
 646                    window,
 647                    cx,
 648                    |e, _, _| Some(format!("{e}")),
 649                );
 650                return Some(());
 651            }
 652
 653            let open_path = workspace.update(cx, |workspace, cx| {
 654                workspace.open_path_preview(path, None, true, false, window, cx)
 655            });
 656
 657            cx.spawn_in(window, |_, mut cx| async move {
 658                let item = open_path?.await?;
 659                let editor = cx.update(|_, cx| {
 660                    item.act_as::<Editor>(cx)
 661                        .ok_or_else(|| anyhow::anyhow!("didn't open editor"))
 662                })??;
 663
 664                if let Some(task) =
 665                    editor.update(&mut cx, |editor, _| editor.wait_for_diff_to_load())?
 666                {
 667                    task.await
 668                };
 669
 670                editor.update_in(&mut cx, |editor, window, cx| {
 671                    editor.revert_file(&Default::default(), window, cx);
 672                })?;
 673
 674                workspace
 675                    .update_in(&mut cx, |workspace, window, cx| {
 676                        workspace.save_active_item(SaveIntent::Save, window, cx)
 677                    })?
 678                    .await?;
 679                Ok(())
 680            })
 681            .detach_and_prompt_err("Failed to open file", window, cx, |e, _, _| {
 682                Some(format!("{e}"))
 683            });
 684
 685            Some(())
 686        });
 687    }
 688
 689    fn toggle_staged_for_entry(
 690        &mut self,
 691        entry: &GitListEntry,
 692        _window: &mut Window,
 693        cx: &mut Context<Self>,
 694    ) {
 695        let Some(active_repository) = self.active_repository.as_ref() else {
 696            return;
 697        };
 698        let (stage, repo_paths) = match entry {
 699            GitListEntry::GitStatusEntry(status_entry) => {
 700                if status_entry.status.is_staged().unwrap_or(false) {
 701                    (false, vec![status_entry.repo_path.clone()])
 702                } else {
 703                    (true, vec![status_entry.repo_path.clone()])
 704                }
 705            }
 706            GitListEntry::Header(section) => {
 707                let goal_staged_state = !self.header_state(section.header).selected();
 708                let repository = active_repository.read(cx);
 709                let entries = self
 710                    .entries
 711                    .iter()
 712                    .filter_map(|entry| entry.status_entry())
 713                    .filter(|status_entry| {
 714                        section.contains(&status_entry, repository)
 715                            && status_entry.is_staged != Some(goal_staged_state)
 716                    })
 717                    .map(|status_entry| status_entry.repo_path.clone())
 718                    .collect::<Vec<_>>();
 719
 720                (goal_staged_state, entries)
 721            }
 722        };
 723        self.update_staging_area_for_entries(stage, repo_paths, cx);
 724    }
 725
 726    fn update_staging_area_for_entries(
 727        &mut self,
 728        stage: bool,
 729        repo_paths: Vec<RepoPath>,
 730        cx: &mut Context<Self>,
 731    ) {
 732        let Some(active_repository) = self.active_repository.clone() else {
 733            return;
 734        };
 735        let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1;
 736        self.pending.push(PendingOperation {
 737            op_id,
 738            will_become_staged: stage,
 739            repo_paths: repo_paths.iter().cloned().collect(),
 740            finished: false,
 741        });
 742        let repo_paths = repo_paths.clone();
 743        let repository = active_repository.read(cx);
 744        self.update_counts(repository);
 745        cx.notify();
 746
 747        cx.spawn({
 748            |this, mut cx| async move {
 749                let result = cx
 750                    .update(|cx| {
 751                        if stage {
 752                            active_repository
 753                                .update(cx, |repo, cx| repo.stage_entries(repo_paths.clone(), cx))
 754                        } else {
 755                            active_repository
 756                                .update(cx, |repo, cx| repo.unstage_entries(repo_paths.clone(), cx))
 757                        }
 758                    })?
 759                    .await;
 760
 761                this.update(&mut cx, |this, cx| {
 762                    for pending in this.pending.iter_mut() {
 763                        if pending.op_id == op_id {
 764                            pending.finished = true
 765                        }
 766                    }
 767                    result
 768                        .map_err(|e| {
 769                            this.show_err_toast(e, cx);
 770                        })
 771                        .ok();
 772                    cx.notify();
 773                })
 774            }
 775        })
 776        .detach();
 777    }
 778
 779    fn toggle_staged_for_selected(
 780        &mut self,
 781        _: &git::ToggleStaged,
 782        window: &mut Window,
 783        cx: &mut Context<Self>,
 784    ) {
 785        if let Some(selected_entry) = self.get_selected_entry().cloned() {
 786            self.toggle_staged_for_entry(&selected_entry, window, cx);
 787        }
 788    }
 789
 790    /// Commit all staged changes
 791    fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
 792        let editor = self.commit_editor.read(cx);
 793        if editor.is_empty(cx) {
 794            if !editor.focus_handle(cx).contains_focused(window, cx) {
 795                editor.focus_handle(cx).focus(window);
 796                return;
 797            }
 798        }
 799
 800        self.commit_changes(window, cx)
 801    }
 802
 803    fn commit_changes(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 804        let Some(active_repository) = self.active_repository.clone() else {
 805            return;
 806        };
 807        let error_spawn = |message, window: &mut Window, cx: &mut App| {
 808            let prompt = window.prompt(PromptLevel::Warning, message, None, &["Ok"], cx);
 809            cx.spawn(|_| async move {
 810                prompt.await.ok();
 811            })
 812            .detach();
 813        };
 814
 815        if self.has_unstaged_conflicts() {
 816            error_spawn(
 817                "There are still conflicts. You must stage these before committing",
 818                window,
 819                cx,
 820            );
 821            return;
 822        }
 823
 824        let mut message = self.commit_editor.read(cx).text(cx);
 825        if message.trim().is_empty() {
 826            self.commit_editor.read(cx).focus_handle(cx).focus(window);
 827            return;
 828        }
 829        if self.add_coauthors {
 830            self.fill_co_authors(&mut message, cx);
 831        }
 832
 833        let task = if self.has_staged_changes() {
 834            // Repository serializes all git operations, so we can just send a commit immediately
 835            let commit_task = active_repository.read(cx).commit(message.into(), None);
 836            cx.background_executor()
 837                .spawn(async move { commit_task.await? })
 838        } else {
 839            let changed_files = self
 840                .entries
 841                .iter()
 842                .filter_map(|entry| entry.status_entry())
 843                .filter(|status_entry| !status_entry.status.is_created())
 844                .map(|status_entry| status_entry.repo_path.clone())
 845                .collect::<Vec<_>>();
 846
 847            if changed_files.is_empty() {
 848                error_spawn("No changes to commit", window, cx);
 849                return;
 850            }
 851
 852            let stage_task =
 853                active_repository.update(cx, |repo, cx| repo.stage_entries(changed_files, cx));
 854            cx.spawn(|_, mut cx| async move {
 855                stage_task.await?;
 856                let commit_task = active_repository
 857                    .update(&mut cx, |repo, _| repo.commit(message.into(), None))?;
 858                commit_task.await?
 859            })
 860        };
 861        let task = cx.spawn_in(window, |this, mut cx| async move {
 862            let result = task.await;
 863            this.update_in(&mut cx, |this, window, cx| {
 864                this.pending_commit.take();
 865                match result {
 866                    Ok(()) => {
 867                        this.commit_editor
 868                            .update(cx, |editor, cx| editor.clear(window, cx));
 869                    }
 870                    Err(e) => this.show_err_toast(e, cx),
 871                }
 872            })
 873            .ok();
 874        });
 875
 876        self.pending_commit = Some(task);
 877    }
 878
 879    fn uncommit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 880        let Some(repo) = self.active_repository.clone() else {
 881            return;
 882        };
 883        let prior_head = self.load_commit_details("HEAD", cx);
 884
 885        let task = cx.spawn(|_, mut cx| async move {
 886            let prior_head = prior_head.await?;
 887
 888            repo.update(&mut cx, |repo, _| repo.reset("HEAD^", ResetMode::Soft))?
 889                .await??;
 890
 891            Ok(prior_head)
 892        });
 893
 894        let task = cx.spawn_in(window, |this, mut cx| async move {
 895            let result = task.await;
 896            this.update_in(&mut cx, |this, window, cx| {
 897                this.pending_commit.take();
 898                match result {
 899                    Ok(prior_commit) => {
 900                        this.commit_editor.update(cx, |editor, cx| {
 901                            editor.set_text(prior_commit.message, window, cx)
 902                        });
 903                    }
 904                    Err(e) => this.show_err_toast(e, cx),
 905                }
 906            })
 907            .ok();
 908        });
 909
 910        self.pending_commit = Some(task);
 911    }
 912
 913    fn potential_co_authors(&self, cx: &App) -> Vec<(String, String)> {
 914        let mut new_co_authors = Vec::new();
 915        let project = self.project.read(cx);
 916
 917        let Some(room) = self
 918            .workspace
 919            .upgrade()
 920            .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned())
 921        else {
 922            return Vec::default();
 923        };
 924
 925        let room = room.read(cx);
 926
 927        for (peer_id, collaborator) in project.collaborators() {
 928            if collaborator.is_host {
 929                continue;
 930            }
 931
 932            let Some(participant) = room.remote_participant_for_peer_id(*peer_id) else {
 933                continue;
 934            };
 935            if participant.can_write() && participant.user.email.is_some() {
 936                let email = participant.user.email.clone().unwrap();
 937
 938                new_co_authors.push((
 939                    participant
 940                        .user
 941                        .name
 942                        .clone()
 943                        .unwrap_or_else(|| participant.user.github_login.clone()),
 944                    email,
 945                ))
 946            }
 947        }
 948        if !project.is_local() && !project.is_read_only(cx) {
 949            if let Some(user) = room.local_participant_user(cx) {
 950                if let Some(email) = user.email.clone() {
 951                    new_co_authors.push((
 952                        user.name
 953                            .clone()
 954                            .unwrap_or_else(|| user.github_login.clone()),
 955                        email.clone(),
 956                    ))
 957                }
 958            }
 959        }
 960        new_co_authors
 961    }
 962
 963    fn toggle_fill_co_authors(
 964        &mut self,
 965        _: &ToggleFillCoAuthors,
 966        _: &mut Window,
 967        cx: &mut Context<Self>,
 968    ) {
 969        self.add_coauthors = !self.add_coauthors;
 970        cx.notify();
 971    }
 972
 973    fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context<Self>) {
 974        const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
 975
 976        let existing_text = message.to_ascii_lowercase();
 977        let lowercase_co_author_prefix = CO_AUTHOR_PREFIX.to_lowercase();
 978        let mut ends_with_co_authors = false;
 979        let existing_co_authors = existing_text
 980            .lines()
 981            .filter_map(|line| {
 982                let line = line.trim();
 983                if line.starts_with(&lowercase_co_author_prefix) {
 984                    ends_with_co_authors = true;
 985                    Some(line)
 986                } else {
 987                    ends_with_co_authors = false;
 988                    None
 989                }
 990            })
 991            .collect::<HashSet<_>>();
 992
 993        let new_co_authors = self
 994            .potential_co_authors(cx)
 995            .into_iter()
 996            .filter(|(_, email)| {
 997                !existing_co_authors
 998                    .iter()
 999                    .any(|existing| existing.contains(email.as_str()))
1000            })
1001            .collect::<Vec<_>>();
1002
1003        if new_co_authors.is_empty() {
1004            return;
1005        }
1006
1007        if !ends_with_co_authors {
1008            message.push('\n');
1009        }
1010        for (name, email) in new_co_authors {
1011            message.push('\n');
1012            message.push_str(CO_AUTHOR_PREFIX);
1013            message.push_str(&name);
1014            message.push_str(" <");
1015            message.push_str(&email);
1016            message.push('>');
1017        }
1018        message.push('\n');
1019    }
1020
1021    fn schedule_update(
1022        &mut self,
1023        clear_pending: bool,
1024        window: &mut Window,
1025        cx: &mut Context<Self>,
1026    ) {
1027        let handle = cx.entity().downgrade();
1028        self.reopen_commit_buffer(window, cx);
1029        self.update_visible_entries_task = cx.spawn_in(window, |_, mut cx| async move {
1030            cx.background_executor().timer(UPDATE_DEBOUNCE).await;
1031            if let Some(git_panel) = handle.upgrade() {
1032                git_panel
1033                    .update_in(&mut cx, |git_panel, _, cx| {
1034                        if clear_pending {
1035                            git_panel.clear_pending();
1036                        }
1037                        git_panel.update_visible_entries(cx);
1038                    })
1039                    .ok();
1040            }
1041        });
1042    }
1043
1044    fn reopen_commit_buffer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1045        let Some(active_repo) = self.active_repository.as_ref() else {
1046            return;
1047        };
1048        let load_buffer = active_repo.update(cx, |active_repo, cx| {
1049            let project = self.project.read(cx);
1050            active_repo.open_commit_buffer(
1051                Some(project.languages().clone()),
1052                project.buffer_store().clone(),
1053                cx,
1054            )
1055        });
1056
1057        cx.spawn_in(window, |git_panel, mut cx| async move {
1058            let buffer = load_buffer.await?;
1059            git_panel.update_in(&mut cx, |git_panel, window, cx| {
1060                if git_panel
1061                    .commit_editor
1062                    .read(cx)
1063                    .buffer()
1064                    .read(cx)
1065                    .as_singleton()
1066                    .as_ref()
1067                    != Some(&buffer)
1068                {
1069                    git_panel.commit_editor = cx.new(|cx| {
1070                        commit_message_editor(buffer, git_panel.project.clone(), window, cx)
1071                    });
1072                }
1073            })
1074        })
1075        .detach_and_log_err(cx);
1076    }
1077
1078    fn clear_pending(&mut self) {
1079        self.pending.retain(|v| !v.finished)
1080    }
1081
1082    fn update_visible_entries(&mut self, cx: &mut Context<Self>) {
1083        self.entries.clear();
1084        self.entries_by_path.clear();
1085        let mut changed_entries = Vec::new();
1086        let mut new_entries = Vec::new();
1087        let mut conflict_entries = Vec::new();
1088
1089        let Some(repo) = self.active_repository.as_ref() else {
1090            // Just clear entries if no repository is active.
1091            cx.notify();
1092            return;
1093        };
1094
1095        // First pass - collect all paths
1096        let repo = repo.read(cx);
1097        let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path));
1098
1099        // Second pass - create entries with proper depth calculation
1100        for entry in repo.status() {
1101            let (depth, difference) =
1102                Self::calculate_depth_and_difference(&entry.repo_path, &path_set);
1103
1104            let is_conflict = repo.has_conflict(&entry.repo_path);
1105            let is_new = entry.status.is_created();
1106            let is_staged = entry.status.is_staged();
1107
1108            let display_name = if difference > 1 {
1109                // Show partial path for deeply nested files
1110                entry
1111                    .repo_path
1112                    .as_ref()
1113                    .iter()
1114                    .skip(entry.repo_path.components().count() - difference)
1115                    .collect::<PathBuf>()
1116                    .to_string_lossy()
1117                    .into_owned()
1118            } else {
1119                // Just show filename
1120                entry
1121                    .repo_path
1122                    .file_name()
1123                    .map(|name| name.to_string_lossy().into_owned())
1124                    .unwrap_or_default()
1125            };
1126
1127            let entry = GitStatusEntry {
1128                depth,
1129                display_name,
1130                repo_path: entry.repo_path.clone(),
1131                status: entry.status,
1132                is_staged,
1133            };
1134
1135            if is_conflict {
1136                conflict_entries.push(entry);
1137            } else if is_new {
1138                new_entries.push(entry);
1139            } else {
1140                changed_entries.push(entry);
1141            }
1142        }
1143
1144        // Sort entries by path to maintain consistent order
1145        conflict_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
1146        changed_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
1147        new_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
1148
1149        if conflict_entries.len() > 0 {
1150            self.entries.push(GitListEntry::Header(GitHeaderEntry {
1151                header: Section::Conflict,
1152            }));
1153            self.entries.extend(
1154                conflict_entries
1155                    .into_iter()
1156                    .map(GitListEntry::GitStatusEntry),
1157            );
1158        }
1159
1160        if changed_entries.len() > 0 {
1161            self.entries.push(GitListEntry::Header(GitHeaderEntry {
1162                header: Section::Tracked,
1163            }));
1164            self.entries.extend(
1165                changed_entries
1166                    .into_iter()
1167                    .map(GitListEntry::GitStatusEntry),
1168            );
1169        }
1170        if new_entries.len() > 0 {
1171            self.entries.push(GitListEntry::Header(GitHeaderEntry {
1172                header: Section::New,
1173            }));
1174            self.entries
1175                .extend(new_entries.into_iter().map(GitListEntry::GitStatusEntry));
1176        }
1177
1178        for (ix, entry) in self.entries.iter().enumerate() {
1179            if let Some(status_entry) = entry.status_entry() {
1180                self.entries_by_path
1181                    .insert(status_entry.repo_path.clone(), ix);
1182            }
1183        }
1184        self.update_counts(repo);
1185
1186        self.select_first_entry_if_none(cx);
1187
1188        cx.notify();
1189    }
1190
1191    fn header_state(&self, header_type: Section) -> ToggleState {
1192        let (staged_count, count) = match header_type {
1193            Section::New => (self.new_staged_count, self.new_count),
1194            Section::Tracked => (self.tracked_staged_count, self.tracked_count),
1195            Section::Conflict => (self.conflicted_staged_count, self.conflicted_count),
1196        };
1197        if staged_count == 0 {
1198            ToggleState::Unselected
1199        } else if count == staged_count {
1200            ToggleState::Selected
1201        } else {
1202            ToggleState::Indeterminate
1203        }
1204    }
1205
1206    fn update_counts(&mut self, repo: &Repository) {
1207        self.conflicted_count = 0;
1208        self.conflicted_staged_count = 0;
1209        self.new_count = 0;
1210        self.tracked_count = 0;
1211        self.new_staged_count = 0;
1212        self.tracked_staged_count = 0;
1213        for entry in &self.entries {
1214            let Some(status_entry) = entry.status_entry() else {
1215                continue;
1216            };
1217            if repo.has_conflict(&status_entry.repo_path) {
1218                self.conflicted_count += 1;
1219                if self.entry_is_staged(status_entry) != Some(false) {
1220                    self.conflicted_staged_count += 1;
1221                }
1222            } else if status_entry.status.is_created() {
1223                self.new_count += 1;
1224                if self.entry_is_staged(status_entry) != Some(false) {
1225                    self.new_staged_count += 1;
1226                }
1227            } else {
1228                self.tracked_count += 1;
1229                if self.entry_is_staged(status_entry) != Some(false) {
1230                    self.tracked_staged_count += 1;
1231                }
1232            }
1233        }
1234    }
1235
1236    fn entry_is_staged(&self, entry: &GitStatusEntry) -> Option<bool> {
1237        for pending in self.pending.iter().rev() {
1238            if pending.repo_paths.contains(&entry.repo_path) {
1239                return Some(pending.will_become_staged);
1240            }
1241        }
1242        entry.is_staged
1243    }
1244
1245    fn has_staged_changes(&self) -> bool {
1246        self.tracked_staged_count > 0
1247            || self.new_staged_count > 0
1248            || self.conflicted_staged_count > 0
1249    }
1250
1251    fn has_tracked_changes(&self) -> bool {
1252        self.tracked_count > 0
1253    }
1254
1255    fn has_unstaged_conflicts(&self) -> bool {
1256        self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count
1257    }
1258
1259    fn show_err_toast(&self, e: anyhow::Error, cx: &mut App) {
1260        let Some(workspace) = self.workspace.upgrade() else {
1261            return;
1262        };
1263        let notif_id = NotificationId::Named("git-operation-error".into());
1264
1265        let message = e.to_string();
1266        workspace.update(cx, |workspace, cx| {
1267            let toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| {
1268                window.dispatch_action(workspace::OpenLog.boxed_clone(), cx);
1269            });
1270            workspace.show_toast(toast, cx);
1271        });
1272    }
1273
1274    pub fn panel_button(
1275        &self,
1276        id: impl Into<SharedString>,
1277        label: impl Into<SharedString>,
1278    ) -> Button {
1279        let id = id.into().clone();
1280        let label = label.into().clone();
1281
1282        Button::new(id, label)
1283            .label_size(LabelSize::Small)
1284            .layer(ElevationIndex::ElevatedSurface)
1285            .size(ButtonSize::Compact)
1286            .style(ButtonStyle::Filled)
1287    }
1288
1289    pub fn indent_size(&self, window: &Window, cx: &mut Context<Self>) -> Pixels {
1290        Checkbox::container_size(cx).to_pixels(window.rem_size())
1291    }
1292
1293    pub fn render_divider(&self, _cx: &mut Context<Self>) -> impl IntoElement {
1294        h_flex()
1295            .items_center()
1296            .h(px(8.))
1297            .child(Divider::horizontal_dashed().color(DividerColor::Border))
1298    }
1299
1300    pub fn render_panel_header(
1301        &self,
1302        window: &mut Window,
1303        cx: &mut Context<Self>,
1304    ) -> impl IntoElement {
1305        let all_repositories = self
1306            .project
1307            .read(cx)
1308            .git_store()
1309            .read(cx)
1310            .all_repositories();
1311
1312        let has_repo_above = all_repositories.iter().any(|repo| {
1313            repo.read(cx)
1314                .repository_entry
1315                .work_directory
1316                .is_above_project()
1317        });
1318
1319        self.panel_header_container(window, cx)
1320            .when(all_repositories.len() > 1 || has_repo_above, |el| {
1321                el.child(self.render_repository_selector(cx))
1322            })
1323    }
1324
1325    pub fn render_repository_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
1326        let active_repository = self.project.read(cx).active_repository(cx);
1327        let repository_display_name = active_repository
1328            .as_ref()
1329            .map(|repo| repo.read(cx).display_name(self.project.read(cx), cx))
1330            .unwrap_or_default();
1331
1332        RepositorySelectorPopoverMenu::new(
1333            self.repository_selector.clone(),
1334            ButtonLike::new("active-repository")
1335                .style(ButtonStyle::Subtle)
1336                .child(Label::new(repository_display_name).size(LabelSize::Small)),
1337            Tooltip::text("Select a repository"),
1338        )
1339    }
1340
1341    pub fn render_commit_editor(
1342        &self,
1343        window: &mut Window,
1344        cx: &mut Context<Self>,
1345    ) -> impl IntoElement {
1346        let editor = self.commit_editor.clone();
1347        let can_commit = (self.has_staged_changes() || self.has_tracked_changes())
1348            && self.pending_commit.is_none()
1349            && !editor.read(cx).is_empty(cx)
1350            && !self.has_unstaged_conflicts()
1351            && self.has_write_access(cx);
1352
1353        // let can_commit_all =
1354        //     !self.commit_pending && self.can_commit_all && !editor.read(cx).is_empty(cx);
1355        let panel_editor_style = panel_editor_style(true, window, cx);
1356
1357        let editor_focus_handle = editor.read(cx).focus_handle(cx).clone();
1358
1359        let focus_handle_1 = self.focus_handle(cx).clone();
1360        let tooltip = if self.has_staged_changes() {
1361            "Commit staged changes"
1362        } else {
1363            "Commit changes to tracked files"
1364        };
1365        let title = if self.has_staged_changes() {
1366            "Commit"
1367        } else {
1368            "Commit All"
1369        };
1370
1371        let commit_button = panel_filled_button(title)
1372            .tooltip(move |window, cx| {
1373                let focus_handle = focus_handle_1.clone();
1374                Tooltip::for_action_in(tooltip, &Commit, &focus_handle, window, cx)
1375            })
1376            .disabled(!can_commit)
1377            .on_click({
1378                cx.listener(move |this, _: &ClickEvent, window, cx| this.commit_changes(window, cx))
1379            });
1380
1381        let potential_co_authors = self.potential_co_authors(cx);
1382        let enable_coauthors = if potential_co_authors.is_empty() {
1383            None
1384        } else {
1385            Some(
1386                IconButton::new("co-authors", IconName::Person)
1387                    .icon_color(Color::Disabled)
1388                    .selected_icon_color(Color::Selected)
1389                    .toggle_state(self.add_coauthors)
1390                    .tooltip(move |_, cx| {
1391                        let title = format!(
1392                            "Add co-authored-by:{}{}",
1393                            if potential_co_authors.len() == 1 {
1394                                ""
1395                            } else {
1396                                "\n"
1397                            },
1398                            potential_co_authors
1399                                .iter()
1400                                .map(|(name, email)| format!(" {} <{}>", name, email))
1401                                .join("\n")
1402                        );
1403                        Tooltip::simple(title, cx)
1404                    })
1405                    .on_click(cx.listener(|this, _, _, cx| {
1406                        this.add_coauthors = !this.add_coauthors;
1407                        cx.notify();
1408                    })),
1409            )
1410        };
1411
1412        let branch = self
1413            .active_repository
1414            .as_ref()
1415            .and_then(|repo| repo.read(cx).branch().map(|b| b.name.clone()))
1416            .unwrap_or_else(|| "<no branch>".into());
1417
1418        let branch_selector = Button::new("branch-selector", branch)
1419            .color(Color::Muted)
1420            .style(ButtonStyle::Subtle)
1421            .icon(IconName::GitBranch)
1422            .icon_size(IconSize::Small)
1423            .icon_color(Color::Muted)
1424            .size(ButtonSize::Compact)
1425            .icon_position(IconPosition::Start)
1426            .tooltip(Tooltip::for_action_title(
1427                "Switch Branch",
1428                &zed_actions::git::Branch,
1429            ))
1430            .on_click(cx.listener(|_, _, window, cx| {
1431                window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
1432            }))
1433            .style(ButtonStyle::Transparent);
1434
1435        let footer_size = px(32.);
1436        let gap = px(16.0);
1437
1438        let max_height = window.line_height() * 6. + gap + footer_size;
1439
1440        panel_editor_container(window, cx)
1441            .id("commit-editor-container")
1442            .relative()
1443            .h(max_height)
1444            .w_full()
1445            .border_t_1()
1446            .border_color(cx.theme().colors().border)
1447            .bg(cx.theme().colors().editor_background)
1448            .on_click(cx.listener(move |_, _: &ClickEvent, window, _cx| {
1449                window.focus(&editor_focus_handle);
1450            }))
1451            .child(EditorElement::new(&self.commit_editor, panel_editor_style))
1452            .child(
1453                h_flex()
1454                    .absolute()
1455                    .bottom_0()
1456                    .left_2()
1457                    .h(footer_size)
1458                    .flex_none()
1459                    .child(branch_selector),
1460            )
1461            .child(
1462                h_flex()
1463                    .absolute()
1464                    .bottom_0()
1465                    .right_2()
1466                    .h(footer_size)
1467                    .flex_none()
1468                    .children(enable_coauthors)
1469                    .child(commit_button),
1470            )
1471    }
1472
1473    fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
1474        let active_repository = self.active_repository.as_ref()?;
1475        let branch = active_repository.read(cx).branch()?;
1476        let commit = branch.most_recent_commit.as_ref()?.clone();
1477
1478        if branch.upstream.as_ref().is_some_and(|upstream| {
1479            if let Some(tracking) = &upstream.tracking {
1480                tracking.ahead == 0
1481            } else {
1482                true
1483            }
1484        }) {
1485            return None;
1486        }
1487        let tooltip = if self.has_staged_changes() {
1488            "git reset HEAD^ --soft"
1489        } else {
1490            "git reset HEAD^"
1491        };
1492
1493        let this = cx.entity();
1494        Some(
1495            h_flex()
1496                .items_center()
1497                .py_1p5()
1498                .px(px(8.))
1499                .bg(cx.theme().colors().background)
1500                .border_t_1()
1501                .border_color(cx.theme().colors().border)
1502                .gap_1p5()
1503                .child(
1504                    div()
1505                        .flex_grow()
1506                        .overflow_hidden()
1507                        .max_w(relative(0.6))
1508                        .h_full()
1509                        .child(
1510                            Label::new(commit.subject.clone())
1511                                .size(LabelSize::Small)
1512                                .text_ellipsis(),
1513                        )
1514                        .id("commit-msg-hover")
1515                        .hoverable_tooltip(move |window, cx| {
1516                            GitPanelMessageTooltip::new(
1517                                this.clone(),
1518                                commit.sha.clone(),
1519                                window,
1520                                cx,
1521                            )
1522                            .into()
1523                        }),
1524                )
1525                .child(div().flex_1())
1526                .child(
1527                    panel_filled_button("Uncommit")
1528                        .icon(IconName::Undo)
1529                        .icon_size(IconSize::Small)
1530                        .icon_color(Color::Muted)
1531                        .icon_position(IconPosition::Start)
1532                        .tooltip(Tooltip::for_action_title(tooltip, &git::Uncommit))
1533                        .on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))),
1534                ),
1535        )
1536    }
1537
1538    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1539        h_flex()
1540            .h_full()
1541            .flex_grow()
1542            .justify_center()
1543            .items_center()
1544            .child(
1545                v_flex()
1546                    .gap_3()
1547                    .child(if self.active_repository.is_some() {
1548                        "No changes to commit"
1549                    } else {
1550                        "No Git repositories"
1551                    })
1552                    .text_ui_sm(cx)
1553                    .mx_auto()
1554                    .text_color(Color::Placeholder.color(cx)),
1555            )
1556    }
1557
1558    fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
1559        let scroll_bar_style = self.show_scrollbar(cx);
1560        let show_container = matches!(scroll_bar_style, ShowScrollbar::Always);
1561
1562        if !self.should_show_scrollbar(cx)
1563            || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
1564        {
1565            return None;
1566        }
1567
1568        Some(
1569            div()
1570                .id("git-panel-vertical-scroll")
1571                .occlude()
1572                .flex_none()
1573                .h_full()
1574                .cursor_default()
1575                .when(show_container, |this| this.pl_1().px_1p5())
1576                .when(!show_container, |this| {
1577                    this.absolute().right_1().top_1().bottom_1().w(px(12.))
1578                })
1579                .on_mouse_move(cx.listener(|_, _, _, cx| {
1580                    cx.notify();
1581                    cx.stop_propagation()
1582                }))
1583                .on_hover(|_, _, cx| {
1584                    cx.stop_propagation();
1585                })
1586                .on_any_mouse_down(|_, _, cx| {
1587                    cx.stop_propagation();
1588                })
1589                .on_mouse_up(
1590                    MouseButton::Left,
1591                    cx.listener(|this, _, window, cx| {
1592                        if !this.scrollbar_state.is_dragging()
1593                            && !this.focus_handle.contains_focused(window, cx)
1594                        {
1595                            this.hide_scrollbar(window, cx);
1596                            cx.notify();
1597                        }
1598
1599                        cx.stop_propagation();
1600                    }),
1601                )
1602                .on_scroll_wheel(cx.listener(|_, _, _, cx| {
1603                    cx.notify();
1604                }))
1605                .children(Scrollbar::vertical(
1606                    // percentage as f32..end_offset as f32,
1607                    self.scrollbar_state.clone(),
1608                )),
1609        )
1610    }
1611
1612    pub fn render_buffer_header_controls(
1613        &self,
1614        entity: &Entity<Self>,
1615        file: &Arc<dyn File>,
1616        _: &Window,
1617        cx: &App,
1618    ) -> Option<AnyElement> {
1619        let repo = self.active_repository.as_ref()?.read(cx);
1620        let repo_path = repo.worktree_id_path_to_repo_path(file.worktree_id(cx), file.path())?;
1621        let ix = self.entries_by_path.get(&repo_path)?;
1622        let entry = self.entries.get(*ix)?;
1623
1624        let is_staged = self.entry_is_staged(entry.status_entry()?);
1625
1626        let checkbox = Checkbox::new("stage-file", is_staged.into())
1627            .disabled(!self.has_write_access(cx))
1628            .fill()
1629            .elevation(ElevationIndex::Surface)
1630            .on_click({
1631                let entry = entry.clone();
1632                let git_panel = entity.downgrade();
1633                move |_, window, cx| {
1634                    git_panel
1635                        .update(cx, |this, cx| {
1636                            this.toggle_staged_for_entry(&entry, window, cx);
1637                            cx.stop_propagation();
1638                        })
1639                        .ok();
1640                }
1641            });
1642        Some(
1643            h_flex()
1644                .id("start-slot")
1645                .child(checkbox)
1646                .child(git_status_icon(entry.status_entry()?.status, cx))
1647                .on_mouse_down(MouseButton::Left, |_, _, cx| {
1648                    // prevent the list item active state triggering when toggling checkbox
1649                    cx.stop_propagation();
1650                })
1651                .into_any_element(),
1652        )
1653    }
1654
1655    fn render_entries(
1656        &self,
1657        has_write_access: bool,
1658        _: &Window,
1659        cx: &mut Context<Self>,
1660    ) -> impl IntoElement {
1661        let entry_count = self.entries.len();
1662
1663        v_flex()
1664            .size_full()
1665            .flex_grow()
1666            .overflow_hidden()
1667            .child(
1668                uniform_list(cx.entity().clone(), "entries", entry_count, {
1669                    move |this, range, window, cx| {
1670                        let mut items = Vec::with_capacity(range.end - range.start);
1671
1672                        for ix in range {
1673                            match &this.entries.get(ix) {
1674                                Some(GitListEntry::GitStatusEntry(entry)) => {
1675                                    items.push(this.render_entry(
1676                                        ix,
1677                                        entry,
1678                                        has_write_access,
1679                                        window,
1680                                        cx,
1681                                    ));
1682                                }
1683                                Some(GitListEntry::Header(header)) => {
1684                                    items.push(this.render_list_header(
1685                                        ix,
1686                                        header,
1687                                        has_write_access,
1688                                        window,
1689                                        cx,
1690                                    ));
1691                                }
1692                                None => {}
1693                            }
1694                        }
1695
1696                        items
1697                    }
1698                })
1699                .size_full()
1700                .with_sizing_behavior(ListSizingBehavior::Infer)
1701                .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
1702                .track_scroll(self.scroll_handle.clone()),
1703            )
1704            .children(self.render_scrollbar(cx))
1705    }
1706
1707    fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
1708        Label::new(label.into()).color(color).single_line()
1709    }
1710
1711    fn render_list_header(
1712        &self,
1713        ix: usize,
1714        header: &GitHeaderEntry,
1715        _: bool,
1716        _: &Window,
1717        _: &Context<Self>,
1718    ) -> AnyElement {
1719        div()
1720            .w_full()
1721            .child(
1722                ListItem::new(ix)
1723                    .spacing(ListItemSpacing::Sparse)
1724                    .disabled(true)
1725                    .child(
1726                        Label::new(header.title())
1727                            .color(Color::Muted)
1728                            .size(LabelSize::Small)
1729                            .single_line(),
1730                    ),
1731            )
1732            .into_any_element()
1733    }
1734
1735    fn load_commit_details(
1736        &self,
1737        sha: &str,
1738        cx: &mut Context<Self>,
1739    ) -> Task<Result<CommitDetails>> {
1740        let Some(repo) = self.active_repository.clone() else {
1741            return Task::ready(Err(anyhow::anyhow!("no active repo")));
1742        };
1743        repo.update(cx, |repo, cx| repo.show(sha, cx))
1744    }
1745
1746    fn deploy_context_menu(
1747        &mut self,
1748        position: Point<Pixels>,
1749        ix: usize,
1750        window: &mut Window,
1751        cx: &mut Context<Self>,
1752    ) {
1753        let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else {
1754            return;
1755        };
1756        let revert_title = if entry.status.is_deleted() {
1757            "Restore file"
1758        } else if entry.status.is_created() {
1759            "Trash file"
1760        } else {
1761            "Discard changes"
1762        };
1763        let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
1764            context_menu
1765                .action("Stage File", ToggleStaged.boxed_clone())
1766                .action(revert_title, editor::actions::RevertFile.boxed_clone())
1767                .separator()
1768                .action("Open Diff", Confirm.boxed_clone())
1769                .action("Open File", SecondaryConfirm.boxed_clone())
1770        });
1771
1772        let subscription = cx.subscribe_in(
1773            &context_menu,
1774            window,
1775            |this, _, _: &DismissEvent, window, cx| {
1776                if this.context_menu.as_ref().is_some_and(|context_menu| {
1777                    context_menu.0.focus_handle(cx).contains_focused(window, cx)
1778                }) {
1779                    cx.focus_self(window);
1780                }
1781                this.context_menu.take();
1782                cx.notify();
1783            },
1784        );
1785        self.selected_entry = Some(ix);
1786        self.context_menu = Some((context_menu, position, subscription));
1787        cx.notify();
1788    }
1789
1790    fn render_entry(
1791        &self,
1792        ix: usize,
1793        entry: &GitStatusEntry,
1794        has_write_access: bool,
1795        window: &Window,
1796        cx: &Context<Self>,
1797    ) -> AnyElement {
1798        let display_name = entry
1799            .repo_path
1800            .file_name()
1801            .map(|name| name.to_string_lossy().into_owned())
1802            .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
1803
1804        let repo_path = entry.repo_path.clone();
1805        let selected = self.selected_entry == Some(ix);
1806        let status_style = GitPanelSettings::get_global(cx).status_style;
1807        let status = entry.status;
1808        let has_conflict = status.is_conflicted();
1809        let is_modified = status.is_modified();
1810        let is_deleted = status.is_deleted();
1811
1812        let label_color = if status_style == StatusStyle::LabelColor {
1813            if has_conflict {
1814                Color::Conflict
1815            } else if is_modified {
1816                Color::Modified
1817            } else if is_deleted {
1818                // We don't want a bunch of red labels in the list
1819                Color::Disabled
1820            } else {
1821                Color::Created
1822            }
1823        } else {
1824            Color::Default
1825        };
1826
1827        let path_color = if status.is_deleted() {
1828            Color::Disabled
1829        } else {
1830            Color::Muted
1831        };
1832
1833        let id: ElementId = ElementId::Name(format!("entry_{}", display_name).into());
1834
1835        let mut is_staged: ToggleState = self.entry_is_staged(entry).into();
1836
1837        if !self.has_staged_changes() && !entry.status.is_created() {
1838            is_staged = ToggleState::Selected;
1839        }
1840
1841        let checkbox = Checkbox::new(id, is_staged)
1842            .disabled(!has_write_access)
1843            .fill()
1844            .placeholder(!self.has_staged_changes())
1845            .elevation(ElevationIndex::Surface)
1846            .on_click({
1847                let entry = entry.clone();
1848                cx.listener(move |this, _, window, cx| {
1849                    this.toggle_staged_for_entry(
1850                        &GitListEntry::GitStatusEntry(entry.clone()),
1851                        window,
1852                        cx,
1853                    );
1854                    cx.stop_propagation();
1855                })
1856            });
1857
1858        let start_slot = h_flex()
1859            .id(("start-slot", ix))
1860            .gap(DynamicSpacing::Base04.rems(cx))
1861            .child(checkbox)
1862            .tooltip(|window, cx| Tooltip::for_action("Stage File", &ToggleStaged, window, cx))
1863            .child(git_status_icon(status, cx))
1864            .on_mouse_down(MouseButton::Left, |_, _, cx| {
1865                // prevent the list item active state triggering when toggling checkbox
1866                cx.stop_propagation();
1867            });
1868
1869        div()
1870            .w_full()
1871            .child(
1872                ListItem::new(ix)
1873                    .spacing(ListItemSpacing::Sparse)
1874                    .start_slot(start_slot)
1875                    .toggle_state(selected)
1876                    .focused(selected && self.focus_handle(cx).is_focused(window))
1877                    .disabled(!has_write_access)
1878                    .on_click({
1879                        cx.listener(move |this, event: &ClickEvent, window, cx| {
1880                            this.selected_entry = Some(ix);
1881                            cx.notify();
1882                            if event.modifiers().secondary() {
1883                                this.open_file(&Default::default(), window, cx)
1884                            } else {
1885                                this.open_diff(&Default::default(), window, cx);
1886                            }
1887                        })
1888                    })
1889                    .on_secondary_mouse_down(cx.listener(
1890                        move |this, event: &MouseDownEvent, window, cx| {
1891                            this.deploy_context_menu(event.position, ix, window, cx)
1892                        },
1893                    ))
1894                    .child(
1895                        h_flex()
1896                            .when_some(repo_path.parent(), |this, parent| {
1897                                let parent_str = parent.to_string_lossy();
1898                                if !parent_str.is_empty() {
1899                                    this.child(
1900                                        self.entry_label(format!("{}/", parent_str), path_color)
1901                                            .when(status.is_deleted(), |this| this.strikethrough()),
1902                                    )
1903                                } else {
1904                                    this
1905                                }
1906                            })
1907                            .child(
1908                                self.entry_label(display_name.clone(), label_color)
1909                                    .when(status.is_deleted(), |this| this.strikethrough()),
1910                            ),
1911                    ),
1912            )
1913            .into_any_element()
1914    }
1915
1916    fn has_write_access(&self, cx: &App) -> bool {
1917        !self.project.read(cx).is_read_only(cx)
1918    }
1919}
1920
1921impl Render for GitPanel {
1922    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1923        let project = self.project.read(cx);
1924        let has_entries = self
1925            .active_repository
1926            .as_ref()
1927            .map_or(false, |active_repository| {
1928                active_repository.read(cx).entry_count() > 0
1929            });
1930        let room = self
1931            .workspace
1932            .upgrade()
1933            .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
1934
1935        let has_write_access = self.has_write_access(cx);
1936
1937        let has_co_authors = room.map_or(false, |room| {
1938            room.read(cx)
1939                .remote_participants()
1940                .values()
1941                .any(|remote_participant| remote_participant.can_write())
1942        });
1943
1944        v_flex()
1945            .id("git_panel")
1946            .key_context(self.dispatch_context(window, cx))
1947            .track_focus(&self.focus_handle)
1948            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
1949            .when(has_write_access && !project.is_read_only(cx), |this| {
1950                this.on_action(cx.listener(|this, &ToggleStaged, window, cx| {
1951                    this.toggle_staged_for_selected(&ToggleStaged, window, cx)
1952                }))
1953                .on_action(cx.listener(GitPanel::commit))
1954            })
1955            .on_action(cx.listener(Self::select_first))
1956            .on_action(cx.listener(Self::select_next))
1957            .on_action(cx.listener(Self::select_prev))
1958            .on_action(cx.listener(Self::select_last))
1959            .on_action(cx.listener(Self::close_panel))
1960            .on_action(cx.listener(Self::open_diff))
1961            .on_action(cx.listener(Self::open_file))
1962            .on_action(cx.listener(Self::revert))
1963            .on_action(cx.listener(Self::focus_changes_list))
1964            .on_action(cx.listener(Self::focus_editor))
1965            .on_action(cx.listener(Self::toggle_staged_for_selected))
1966            .when(has_write_access && has_co_authors, |git_panel| {
1967                git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
1968            })
1969            // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx)))
1970            .on_hover(cx.listener(|this, hovered, window, cx| {
1971                if *hovered {
1972                    this.show_scrollbar = true;
1973                    this.hide_scrollbar_task.take();
1974                    cx.notify();
1975                } else if !this.focus_handle.contains_focused(window, cx) {
1976                    this.hide_scrollbar(window, cx);
1977                }
1978            }))
1979            .size_full()
1980            .overflow_hidden()
1981            .bg(ElevationIndex::Surface.bg(cx))
1982            .child(self.render_panel_header(window, cx))
1983            .child(if has_entries {
1984                self.render_entries(has_write_access, window, cx)
1985                    .into_any_element()
1986            } else {
1987                self.render_empty_state(cx).into_any_element()
1988            })
1989            .children(self.render_previous_commit(cx))
1990            .child(self.render_commit_editor(window, cx))
1991            .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1992                deferred(
1993                    anchored()
1994                        .position(*position)
1995                        .anchor(gpui::Corner::TopLeft)
1996                        .child(menu.clone()),
1997                )
1998                .with_priority(1)
1999            }))
2000    }
2001}
2002
2003impl Focusable for GitPanel {
2004    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
2005        self.focus_handle.clone()
2006    }
2007}
2008
2009impl EventEmitter<Event> for GitPanel {}
2010
2011impl EventEmitter<PanelEvent> for GitPanel {}
2012
2013pub(crate) struct GitPanelAddon {
2014    pub(crate) git_panel: Entity<GitPanel>,
2015}
2016
2017impl editor::Addon for GitPanelAddon {
2018    fn to_any(&self) -> &dyn std::any::Any {
2019        self
2020    }
2021
2022    fn render_buffer_header_controls(
2023        &self,
2024        excerpt_info: &ExcerptInfo,
2025        window: &Window,
2026        cx: &App,
2027    ) -> Option<AnyElement> {
2028        let file = excerpt_info.buffer.file()?;
2029        let git_panel = self.git_panel.read(cx);
2030
2031        git_panel.render_buffer_header_controls(&self.git_panel, &file, window, cx)
2032    }
2033}
2034
2035impl Panel for GitPanel {
2036    fn persistent_name() -> &'static str {
2037        "GitPanel"
2038    }
2039
2040    fn position(&self, _: &Window, cx: &App) -> DockPosition {
2041        GitPanelSettings::get_global(cx).dock
2042    }
2043
2044    fn position_is_valid(&self, position: DockPosition) -> bool {
2045        matches!(position, DockPosition::Left | DockPosition::Right)
2046    }
2047
2048    fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
2049        settings::update_settings_file::<GitPanelSettings>(
2050            self.fs.clone(),
2051            cx,
2052            move |settings, _| settings.dock = Some(position),
2053        );
2054    }
2055
2056    fn size(&self, _: &Window, cx: &App) -> Pixels {
2057        self.width
2058            .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
2059    }
2060
2061    fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
2062        self.width = size;
2063        self.serialize(cx);
2064        cx.notify();
2065    }
2066
2067    fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
2068        Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
2069    }
2070
2071    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
2072        Some("Git Panel")
2073    }
2074
2075    fn toggle_action(&self) -> Box<dyn Action> {
2076        Box::new(ToggleFocus)
2077    }
2078
2079    fn activation_priority(&self) -> u32 {
2080        2
2081    }
2082}
2083
2084impl PanelHeader for GitPanel {}
2085
2086struct GitPanelMessageTooltip {
2087    commit_tooltip: Option<Entity<CommitTooltip>>,
2088}
2089
2090impl GitPanelMessageTooltip {
2091    fn new(
2092        git_panel: Entity<GitPanel>,
2093        sha: SharedString,
2094        window: &mut Window,
2095        cx: &mut App,
2096    ) -> Entity<Self> {
2097        let workspace = git_panel.read(cx).workspace.clone();
2098        cx.new(|cx| {
2099            cx.spawn_in(window, |this, mut cx| async move {
2100                let language_registry = workspace.update(&mut cx, |workspace, _cx| {
2101                    workspace.app_state().languages.clone()
2102                })?;
2103
2104                let details = git_panel
2105                    .update(&mut cx, |git_panel, cx| {
2106                        git_panel.load_commit_details(&sha, cx)
2107                    })?
2108                    .await?;
2109
2110                let mut parsed_message = ParsedMarkdown::default();
2111                markdown::parse_markdown_block(
2112                    &details.message,
2113                    Some(&language_registry),
2114                    None,
2115                    &mut parsed_message.text,
2116                    &mut parsed_message.highlights,
2117                    &mut parsed_message.region_ranges,
2118                    &mut parsed_message.regions,
2119                )
2120                .await;
2121
2122                let commit_details = editor::commit_tooltip::CommitDetails {
2123                    sha: details.sha.clone(),
2124                    committer_name: details.committer_name.clone(),
2125                    committer_email: details.committer_email.clone(),
2126                    commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
2127                    message: Some(editor::commit_tooltip::ParsedCommitMessage {
2128                        message: details.message.clone(),
2129                        parsed_message,
2130                        ..Default::default()
2131                    }),
2132                };
2133
2134                this.update_in(&mut cx, |this: &mut GitPanelMessageTooltip, window, cx| {
2135                    this.commit_tooltip = Some(cx.new(move |cx| {
2136                        CommitTooltip::new(
2137                            commit_details,
2138                            panel_editor_style(true, window, cx),
2139                            Some(workspace),
2140                        )
2141                    }));
2142                    cx.notify();
2143                })
2144            })
2145            .detach();
2146
2147            Self {
2148                commit_tooltip: None,
2149            }
2150        })
2151    }
2152}
2153
2154impl Render for GitPanelMessageTooltip {
2155    fn render(&mut self, _window: &mut Window, _cx: &mut Context<'_, Self>) -> impl IntoElement {
2156        if let Some(commit_tooltip) = &self.commit_tooltip {
2157            commit_tooltip.clone().into_any_element()
2158        } else {
2159            gpui::Empty.into_any_element()
2160        }
2161    }
2162}