git_panel.rs

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