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