git_panel.rs

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