git_panel.rs

   1use crate::askpass_modal::AskPassModal;
   2use crate::commit_modal::CommitModal;
   3use crate::commit_tooltip::CommitTooltip;
   4use crate::commit_view::CommitView;
   5use crate::git_panel_settings::StatusStyle;
   6use crate::project_diff::{self, Diff, ProjectDiff};
   7use crate::remote_output::{self, RemoteAction, SuccessMessage};
   8use crate::{branch_picker, picker_prompt, render_remote_button};
   9use crate::{
  10    git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
  11};
  12use agent_settings::AgentSettings;
  13use anyhow::Context as _;
  14use askpass::AskPassDelegate;
  15use client::DisableAiSettings;
  16use db::kvp::KEY_VALUE_STORE;
  17use editor::{
  18    Editor, EditorElement, EditorMode, EditorSettings, MultiBuffer, ShowScrollbar,
  19    scroll::ScrollbarAutoHide,
  20};
  21use futures::StreamExt as _;
  22use git::blame::ParsedCommitMessage;
  23use git::repository::{
  24    Branch, CommitDetails, CommitOptions, CommitSummary, DiffType, FetchOptions, GitCommitter,
  25    PushOptions, Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking,
  26    UpstreamTrackingStatus, get_git_committer,
  27};
  28use git::status::StageStatus;
  29use git::{Amend, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus};
  30use git::{ExpandCommitEditor, RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll};
  31use gpui::{
  32    Action, Animation, AnimationExt as _, AsyncApp, AsyncWindowContext, Axis, ClickEvent, Corner,
  33    DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, KeyContext,
  34    ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, MouseDownEvent, Point,
  35    PromptLevel, ScrollStrategy, Subscription, Task, Transformation, UniformListScrollHandle,
  36    WeakEntity, actions, anchored, deferred, percentage, uniform_list,
  37};
  38use itertools::Itertools;
  39use language::{Buffer, File};
  40use language_model::{
  41    ConfiguredModel, LanguageModel, LanguageModelRegistry, LanguageModelRequest,
  42    LanguageModelRequestMessage, Role,
  43};
  44use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
  45use multi_buffer::ExcerptInfo;
  46use notifications::status_toast::{StatusToast, ToastIcon};
  47use panel::{
  48    PanelHeader, panel_button, panel_editor_container, panel_editor_style, panel_filled_button,
  49    panel_icon_button,
  50};
  51use project::git_store::{RepositoryEvent, RepositoryId};
  52use project::{
  53    Fs, Project, ProjectPath,
  54    git_store::{GitStoreEvent, Repository},
  55};
  56use serde::{Deserialize, Serialize};
  57use settings::{Settings, SettingsStore};
  58use std::future::Future;
  59use std::ops::Range;
  60use std::path::{Path, PathBuf};
  61use std::{collections::HashSet, sync::Arc, time::Duration, usize};
  62use strum::{IntoEnumIterator, VariantNames};
  63use time::OffsetDateTime;
  64use ui::{
  65    Checkbox, ContextMenu, ElevationIndex, IconPosition, Label, LabelSize, PopoverMenu, Scrollbar,
  66    ScrollbarState, SplitButton, Tooltip, prelude::*,
  67};
  68use util::{ResultExt, TryFutureExt, maybe};
  69use workspace::SERIALIZATION_THROTTLE_TIME;
  70
  71use workspace::{
  72    Workspace,
  73    dock::{DockPosition, Panel, PanelEvent},
  74    notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId},
  75};
  76use zed_llm_client::CompletionIntent;
  77
  78actions!(
  79    git_panel,
  80    [
  81        /// Closes the git panel.
  82        Close,
  83        /// Toggles focus on the git panel.
  84        ToggleFocus,
  85        /// Opens the git panel menu.
  86        OpenMenu,
  87        /// Focuses on the commit message editor.
  88        FocusEditor,
  89        /// Focuses on the changes list.
  90        FocusChanges,
  91        /// Toggles automatic co-author suggestions.
  92        ToggleFillCoAuthors,
  93    ]
  94);
  95
  96fn prompt<T>(
  97    msg: &str,
  98    detail: Option<&str>,
  99    window: &mut Window,
 100    cx: &mut App,
 101) -> Task<anyhow::Result<T>>
 102where
 103    T: IntoEnumIterator + VariantNames + 'static,
 104{
 105    let rx = window.prompt(PromptLevel::Info, msg, detail, &T::VARIANTS, cx);
 106    cx.spawn(async move |_| Ok(T::iter().nth(rx.await?).unwrap()))
 107}
 108
 109#[derive(strum::EnumIter, strum::VariantNames)]
 110#[strum(serialize_all = "title_case")]
 111enum TrashCancel {
 112    Trash,
 113    Cancel,
 114}
 115
 116struct GitMenuState {
 117    has_tracked_changes: bool,
 118    has_staged_changes: bool,
 119    has_unstaged_changes: bool,
 120    has_new_changes: bool,
 121}
 122
 123fn git_panel_context_menu(
 124    focus_handle: FocusHandle,
 125    state: GitMenuState,
 126    window: &mut Window,
 127    cx: &mut App,
 128) -> Entity<ContextMenu> {
 129    ContextMenu::build(window, cx, move |context_menu, _, _| {
 130        context_menu
 131            .context(focus_handle)
 132            .action_disabled_when(
 133                !state.has_unstaged_changes,
 134                "Stage All",
 135                StageAll.boxed_clone(),
 136            )
 137            .action_disabled_when(
 138                !state.has_staged_changes,
 139                "Unstage All",
 140                UnstageAll.boxed_clone(),
 141            )
 142            .separator()
 143            .action("Open Diff", project_diff::Diff.boxed_clone())
 144            .separator()
 145            .action_disabled_when(
 146                !state.has_tracked_changes,
 147                "Discard Tracked Changes",
 148                RestoreTrackedFiles.boxed_clone(),
 149            )
 150            .action_disabled_when(
 151                !state.has_new_changes,
 152                "Trash Untracked Files",
 153                TrashUntrackedFiles.boxed_clone(),
 154            )
 155    })
 156}
 157
 158const GIT_PANEL_KEY: &str = "GitPanel";
 159
 160const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
 161
 162pub fn register(workspace: &mut Workspace) {
 163    workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
 164        workspace.toggle_panel_focus::<GitPanel>(window, cx);
 165    });
 166    workspace.register_action(|workspace, _: &ExpandCommitEditor, window, cx| {
 167        CommitModal::toggle(workspace, None, window, cx)
 168    });
 169}
 170
 171#[derive(Debug, Clone)]
 172pub enum Event {
 173    Focus,
 174}
 175
 176#[derive(Serialize, Deserialize)]
 177struct SerializedGitPanel {
 178    width: Option<Pixels>,
 179    #[serde(default)]
 180    amend_pending: bool,
 181    #[serde(default)]
 182    signoff_enabled: bool,
 183}
 184
 185#[derive(Debug, PartialEq, Eq, Clone, Copy)]
 186enum Section {
 187    Conflict,
 188    Tracked,
 189    New,
 190}
 191
 192#[derive(Debug, PartialEq, Eq, Clone)]
 193struct GitHeaderEntry {
 194    header: Section,
 195}
 196
 197impl GitHeaderEntry {
 198    pub fn contains(&self, status_entry: &GitStatusEntry, repo: &Repository) -> bool {
 199        let this = &self.header;
 200        let status = status_entry.status;
 201        match this {
 202            Section::Conflict => {
 203                repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path)
 204            }
 205            Section::Tracked => !status.is_created(),
 206            Section::New => status.is_created(),
 207        }
 208    }
 209    pub fn title(&self) -> &'static str {
 210        match self.header {
 211            Section::Conflict => "Conflicts",
 212            Section::Tracked => "Tracked",
 213            Section::New => "Untracked",
 214        }
 215    }
 216}
 217
 218#[derive(Debug, PartialEq, Eq, Clone)]
 219enum GitListEntry {
 220    Status(GitStatusEntry),
 221    Header(GitHeaderEntry),
 222}
 223
 224impl GitListEntry {
 225    fn status_entry(&self) -> Option<&GitStatusEntry> {
 226        match self {
 227            GitListEntry::Status(entry) => Some(entry),
 228            _ => None,
 229        }
 230    }
 231}
 232
 233#[derive(Debug, PartialEq, Eq, Clone)]
 234pub struct GitStatusEntry {
 235    pub(crate) repo_path: RepoPath,
 236    pub(crate) abs_path: PathBuf,
 237    pub(crate) status: FileStatus,
 238    pub(crate) staging: StageStatus,
 239}
 240
 241impl GitStatusEntry {
 242    fn display_name(&self) -> String {
 243        self.repo_path
 244            .file_name()
 245            .map(|name| name.to_string_lossy().into_owned())
 246            .unwrap_or_else(|| self.repo_path.to_string_lossy().into_owned())
 247    }
 248
 249    fn parent_dir(&self) -> Option<String> {
 250        self.repo_path
 251            .parent()
 252            .map(|parent| parent.to_string_lossy().into_owned())
 253    }
 254}
 255
 256#[derive(Clone, Copy, Debug, PartialEq, Eq)]
 257enum TargetStatus {
 258    Staged,
 259    Unstaged,
 260    Reverted,
 261    Unchanged,
 262}
 263
 264struct PendingOperation {
 265    finished: bool,
 266    target_status: TargetStatus,
 267    entries: Vec<GitStatusEntry>,
 268    op_id: usize,
 269}
 270
 271// computed state related to how to render scrollbars
 272// one per axis
 273// on render we just read this off the panel
 274// we update it when
 275// - settings change
 276// - on focus in, on focus out, on hover, etc.
 277#[derive(Debug)]
 278struct ScrollbarProperties {
 279    axis: Axis,
 280    show_scrollbar: bool,
 281    show_track: bool,
 282    auto_hide: bool,
 283    hide_task: Option<Task<()>>,
 284    state: ScrollbarState,
 285}
 286
 287impl ScrollbarProperties {
 288    // Shows the scrollbar and cancels any pending hide task
 289    fn show(&mut self, cx: &mut Context<GitPanel>) {
 290        if !self.auto_hide {
 291            return;
 292        }
 293        self.show_scrollbar = true;
 294        self.hide_task.take();
 295        cx.notify();
 296    }
 297
 298    fn hide(&mut self, window: &mut Window, cx: &mut Context<GitPanel>) {
 299        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
 300
 301        if !self.auto_hide {
 302            return;
 303        }
 304
 305        let axis = self.axis;
 306        self.hide_task = Some(cx.spawn_in(window, async move |panel, cx| {
 307            cx.background_executor()
 308                .timer(SCROLLBAR_SHOW_INTERVAL)
 309                .await;
 310
 311            if let Some(panel) = panel.upgrade() {
 312                panel
 313                    .update(cx, |panel, cx| {
 314                        match axis {
 315                            Axis::Vertical => panel.vertical_scrollbar.show_scrollbar = false,
 316                            Axis::Horizontal => panel.horizontal_scrollbar.show_scrollbar = false,
 317                        }
 318                        cx.notify();
 319                    })
 320                    .log_err();
 321            }
 322        }));
 323    }
 324}
 325
 326pub struct GitPanel {
 327    pub(crate) active_repository: Option<Entity<Repository>>,
 328    pub(crate) commit_editor: Entity<Editor>,
 329    conflicted_count: usize,
 330    conflicted_staged_count: usize,
 331    add_coauthors: bool,
 332    generate_commit_message_task: Option<Task<Option<()>>>,
 333    entries: Vec<GitListEntry>,
 334    single_staged_entry: Option<GitStatusEntry>,
 335    single_tracked_entry: Option<GitStatusEntry>,
 336    focus_handle: FocusHandle,
 337    fs: Arc<dyn Fs>,
 338    horizontal_scrollbar: ScrollbarProperties,
 339    vertical_scrollbar: ScrollbarProperties,
 340    new_count: usize,
 341    entry_count: usize,
 342    new_staged_count: usize,
 343    pending: Vec<PendingOperation>,
 344    pending_commit: Option<Task<()>>,
 345    amend_pending: bool,
 346    signoff_enabled: bool,
 347    pending_serialization: Task<()>,
 348    pub(crate) project: Entity<Project>,
 349    scroll_handle: UniformListScrollHandle,
 350    max_width_item_index: Option<usize>,
 351    selected_entry: Option<usize>,
 352    marked_entries: Vec<usize>,
 353    tracked_count: usize,
 354    tracked_staged_count: usize,
 355    update_visible_entries_task: Task<()>,
 356    width: Option<Pixels>,
 357    workspace: WeakEntity<Workspace>,
 358    context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
 359    modal_open: bool,
 360    show_placeholders: bool,
 361    local_committer: Option<GitCommitter>,
 362    local_committer_task: Option<Task<()>>,
 363    bulk_staging: Option<BulkStaging>,
 364    _settings_subscription: Subscription,
 365}
 366
 367#[derive(Clone, Debug, PartialEq, Eq)]
 368struct BulkStaging {
 369    repo_id: RepositoryId,
 370    anchor: RepoPath,
 371}
 372
 373const MAX_PANEL_EDITOR_LINES: usize = 6;
 374
 375pub(crate) fn commit_message_editor(
 376    commit_message_buffer: Entity<Buffer>,
 377    placeholder: Option<SharedString>,
 378    project: Entity<Project>,
 379    in_panel: bool,
 380    window: &mut Window,
 381    cx: &mut Context<Editor>,
 382) -> Editor {
 383    project.update(cx, |this, cx| {
 384        this.mark_buffer_as_non_searchable(commit_message_buffer.read(cx).remote_id(), cx);
 385    });
 386    let buffer = cx.new(|cx| MultiBuffer::singleton(commit_message_buffer, cx));
 387    let max_lines = if in_panel { MAX_PANEL_EDITOR_LINES } else { 18 };
 388    let mut commit_editor = Editor::new(
 389        EditorMode::AutoHeight {
 390            min_lines: 1,
 391            max_lines: Some(max_lines),
 392        },
 393        buffer,
 394        None,
 395        window,
 396        cx,
 397    );
 398    commit_editor.set_collaboration_hub(Box::new(project));
 399    commit_editor.set_use_autoclose(false);
 400    commit_editor.set_show_gutter(false, cx);
 401    commit_editor.set_use_modal_editing(true);
 402    commit_editor.set_show_wrap_guides(false, cx);
 403    commit_editor.set_show_indent_guides(false, cx);
 404    let placeholder = placeholder.unwrap_or("Enter commit message".into());
 405    commit_editor.set_placeholder_text(placeholder, cx);
 406    commit_editor
 407}
 408
 409impl GitPanel {
 410    fn new(
 411        workspace: &mut Workspace,
 412        window: &mut Window,
 413        cx: &mut Context<Workspace>,
 414    ) -> Entity<Self> {
 415        let project = workspace.project().clone();
 416        let app_state = workspace.app_state().clone();
 417        let fs = app_state.fs.clone();
 418        let git_store = project.read(cx).git_store().clone();
 419        let active_repository = project.read(cx).active_repository(cx);
 420
 421        let git_panel = cx.new(|cx| {
 422            let focus_handle = cx.focus_handle();
 423            cx.on_focus(&focus_handle, window, Self::focus_in).detach();
 424            cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
 425                this.hide_scrollbars(window, cx);
 426            })
 427            .detach();
 428
 429            let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
 430            cx.observe_global::<SettingsStore>(move |this, cx| {
 431                let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
 432                if is_sort_by_path != was_sort_by_path {
 433                    this.update_visible_entries(cx);
 434                }
 435                was_sort_by_path = is_sort_by_path
 436            })
 437            .detach();
 438
 439            // just to let us render a placeholder editor.
 440            // Once the active git repo is set, this buffer will be replaced.
 441            let temporary_buffer = cx.new(|cx| Buffer::local("", cx));
 442            let commit_editor = cx.new(|cx| {
 443                commit_message_editor(temporary_buffer, None, project.clone(), true, window, cx)
 444            });
 445
 446            commit_editor.update(cx, |editor, cx| {
 447                editor.clear(window, cx);
 448            });
 449
 450            let scroll_handle = UniformListScrollHandle::new();
 451
 452            let vertical_scrollbar = ScrollbarProperties {
 453                axis: Axis::Vertical,
 454                state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
 455                show_scrollbar: false,
 456                show_track: false,
 457                auto_hide: false,
 458                hide_task: None,
 459            };
 460
 461            let horizontal_scrollbar = ScrollbarProperties {
 462                axis: Axis::Horizontal,
 463                state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
 464                show_scrollbar: false,
 465                show_track: false,
 466                auto_hide: false,
 467                hide_task: None,
 468            };
 469
 470            let mut assistant_enabled = AgentSettings::get_global(cx).enabled;
 471            let mut was_ai_disabled = DisableAiSettings::get_global(cx).disable_ai;
 472            let _settings_subscription = cx.observe_global::<SettingsStore>(move |_, cx| {
 473                let is_ai_disabled = DisableAiSettings::get_global(cx).disable_ai;
 474                if assistant_enabled != AgentSettings::get_global(cx).enabled
 475                    || was_ai_disabled != is_ai_disabled
 476                {
 477                    assistant_enabled = AgentSettings::get_global(cx).enabled;
 478                    was_ai_disabled = is_ai_disabled;
 479                    cx.notify();
 480                }
 481            });
 482
 483            cx.subscribe_in(
 484                &git_store,
 485                window,
 486                move |this, _git_store, event, window, cx| match event {
 487                    GitStoreEvent::ActiveRepositoryChanged(_) => {
 488                        this.active_repository = this.project.read(cx).active_repository(cx);
 489                        this.schedule_update(true, window, cx);
 490                    }
 491                    GitStoreEvent::RepositoryUpdated(
 492                        _,
 493                        RepositoryEvent::Updated { full_scan, .. },
 494                        true,
 495                    ) => {
 496                        this.schedule_update(*full_scan, window, cx);
 497                    }
 498
 499                    GitStoreEvent::RepositoryAdded(_) | GitStoreEvent::RepositoryRemoved(_) => {
 500                        this.schedule_update(false, window, cx);
 501                    }
 502                    GitStoreEvent::IndexWriteError(error) => {
 503                        this.workspace
 504                            .update(cx, |workspace, cx| {
 505                                workspace.show_error(error, cx);
 506                            })
 507                            .ok();
 508                    }
 509                    GitStoreEvent::RepositoryUpdated(_, _, _) => {}
 510                    GitStoreEvent::JobsUpdated | GitStoreEvent::ConflictsUpdated => {}
 511                },
 512            )
 513            .detach();
 514
 515            let mut this = Self {
 516                active_repository,
 517                commit_editor,
 518                conflicted_count: 0,
 519                conflicted_staged_count: 0,
 520                add_coauthors: true,
 521                generate_commit_message_task: None,
 522                entries: Vec::new(),
 523                focus_handle: cx.focus_handle(),
 524                fs,
 525                new_count: 0,
 526                new_staged_count: 0,
 527                pending: Vec::new(),
 528                pending_commit: None,
 529                amend_pending: false,
 530                signoff_enabled: false,
 531                pending_serialization: Task::ready(()),
 532                single_staged_entry: None,
 533                single_tracked_entry: None,
 534                project,
 535                scroll_handle,
 536                max_width_item_index: None,
 537                selected_entry: None,
 538                marked_entries: Vec::new(),
 539                tracked_count: 0,
 540                tracked_staged_count: 0,
 541                update_visible_entries_task: Task::ready(()),
 542                width: None,
 543                show_placeholders: false,
 544                local_committer: None,
 545                local_committer_task: None,
 546                context_menu: None,
 547                workspace: workspace.weak_handle(),
 548                modal_open: false,
 549                entry_count: 0,
 550                horizontal_scrollbar,
 551                vertical_scrollbar,
 552                bulk_staging: None,
 553                _settings_subscription,
 554            };
 555
 556            this.schedule_update(false, window, cx);
 557            this
 558        });
 559
 560        git_panel
 561    }
 562
 563    fn hide_scrollbars(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 564        self.horizontal_scrollbar.hide(window, cx);
 565        self.vertical_scrollbar.hide(window, cx);
 566    }
 567
 568    fn update_scrollbar_properties(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
 569        // TODO: This PR should have defined Editor's `scrollbar.axis`
 570        // as an Option<ScrollbarAxis>, not a ScrollbarAxes as it would allow you to
 571        // `.unwrap_or(EditorSettings::get_global(cx).scrollbar.show)`.
 572        //
 573        // Once this is fixed we can extend the GitPanelSettings with a `scrollbar.axis`
 574        // so we can show each axis based on the settings.
 575        //
 576        // We should fix this. PR: https://github.com/zed-industries/zed/pull/19495
 577
 578        let show_setting = GitPanelSettings::get_global(cx)
 579            .scrollbar
 580            .show
 581            .unwrap_or(EditorSettings::get_global(cx).scrollbar.show);
 582
 583        let scroll_handle = self.scroll_handle.0.borrow();
 584
 585        let autohide = |show: ShowScrollbar, cx: &mut Context<Self>| match show {
 586            ShowScrollbar::Auto => true,
 587            ShowScrollbar::System => cx
 588                .try_global::<ScrollbarAutoHide>()
 589                .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
 590            ShowScrollbar::Always => false,
 591            ShowScrollbar::Never => false,
 592        };
 593
 594        let longest_item_width = scroll_handle.last_item_size.and_then(|size| {
 595            (size.contents.width > size.item.width).then_some(size.contents.width)
 596        });
 597
 598        // is there an item long enough that we should show a horizontal scrollbar?
 599        let item_wider_than_container = if let Some(longest_item_width) = longest_item_width {
 600            longest_item_width > px(scroll_handle.base_handle.bounds().size.width.0)
 601        } else {
 602            true
 603        };
 604
 605        let show_horizontal = match (show_setting, item_wider_than_container) {
 606            (_, false) => false,
 607            (ShowScrollbar::Auto | ShowScrollbar::System | ShowScrollbar::Always, true) => true,
 608            (ShowScrollbar::Never, true) => false,
 609        };
 610
 611        let show_vertical = match show_setting {
 612            ShowScrollbar::Auto | ShowScrollbar::System | ShowScrollbar::Always => true,
 613            ShowScrollbar::Never => false,
 614        };
 615
 616        let show_horizontal_track =
 617            show_horizontal && matches!(show_setting, ShowScrollbar::Always);
 618
 619        // TODO: we probably should hide the scroll track when the list doesn't need to scroll
 620        let show_vertical_track = show_vertical && matches!(show_setting, ShowScrollbar::Always);
 621
 622        self.vertical_scrollbar = ScrollbarProperties {
 623            axis: self.vertical_scrollbar.axis,
 624            state: self.vertical_scrollbar.state.clone(),
 625            show_scrollbar: show_vertical,
 626            show_track: show_vertical_track,
 627            auto_hide: autohide(show_setting, cx),
 628            hide_task: None,
 629        };
 630
 631        self.horizontal_scrollbar = ScrollbarProperties {
 632            axis: self.horizontal_scrollbar.axis,
 633            state: self.horizontal_scrollbar.state.clone(),
 634            show_scrollbar: show_horizontal,
 635            show_track: show_horizontal_track,
 636            auto_hide: autohide(show_setting, cx),
 637            hide_task: None,
 638        };
 639
 640        cx.notify();
 641    }
 642
 643    pub fn entry_by_path(&self, path: &RepoPath, cx: &App) -> Option<usize> {
 644        if GitPanelSettings::get_global(cx).sort_by_path {
 645            return self
 646                .entries
 647                .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(&path))
 648                .ok();
 649        }
 650
 651        if self.conflicted_count > 0 {
 652            let conflicted_start = 1;
 653            if let Ok(ix) = self.entries[conflicted_start..conflicted_start + self.conflicted_count]
 654                .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(&path))
 655            {
 656                return Some(conflicted_start + ix);
 657            }
 658        }
 659        if self.tracked_count > 0 {
 660            let tracked_start = if self.conflicted_count > 0 {
 661                1 + self.conflicted_count
 662            } else {
 663                0
 664            } + 1;
 665            if let Ok(ix) = self.entries[tracked_start..tracked_start + self.tracked_count]
 666                .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(&path))
 667            {
 668                return Some(tracked_start + ix);
 669            }
 670        }
 671        if self.new_count > 0 {
 672            let untracked_start = if self.conflicted_count > 0 {
 673                1 + self.conflicted_count
 674            } else {
 675                0
 676            } + if self.tracked_count > 0 {
 677                1 + self.tracked_count
 678            } else {
 679                0
 680            } + 1;
 681            if let Ok(ix) = self.entries[untracked_start..untracked_start + self.new_count]
 682                .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(&path))
 683            {
 684                return Some(untracked_start + ix);
 685            }
 686        }
 687        None
 688    }
 689
 690    pub fn select_entry_by_path(
 691        &mut self,
 692        path: ProjectPath,
 693        _: &mut Window,
 694        cx: &mut Context<Self>,
 695    ) {
 696        let Some(git_repo) = self.active_repository.as_ref() else {
 697            return;
 698        };
 699        let Some(repo_path) = git_repo.read(cx).project_path_to_repo_path(&path, cx) else {
 700            return;
 701        };
 702        let Some(ix) = self.entry_by_path(&repo_path, cx) else {
 703            return;
 704        };
 705        self.selected_entry = Some(ix);
 706        cx.notify();
 707    }
 708
 709    fn serialization_key(workspace: &Workspace) -> Option<String> {
 710        workspace
 711            .database_id()
 712            .map(|id| i64::from(id).to_string())
 713            .or(workspace.session_id())
 714            .map(|id| format!("{}-{:?}", GIT_PANEL_KEY, id))
 715    }
 716
 717    fn serialize(&mut self, cx: &mut Context<Self>) {
 718        let width = self.width;
 719        let amend_pending = self.amend_pending;
 720        let signoff_enabled = self.signoff_enabled;
 721
 722        self.pending_serialization = cx.spawn(async move |git_panel, cx| {
 723            cx.background_executor()
 724                .timer(SERIALIZATION_THROTTLE_TIME)
 725                .await;
 726            let Some(serialization_key) = git_panel
 727                .update(cx, |git_panel, cx| {
 728                    git_panel
 729                        .workspace
 730                        .read_with(cx, |workspace, _| Self::serialization_key(workspace))
 731                        .ok()
 732                        .flatten()
 733                })
 734                .ok()
 735                .flatten()
 736            else {
 737                return;
 738            };
 739            cx.background_spawn(
 740                async move {
 741                    KEY_VALUE_STORE
 742                        .write_kvp(
 743                            serialization_key,
 744                            serde_json::to_string(&SerializedGitPanel {
 745                                width,
 746                                amend_pending,
 747                                signoff_enabled,
 748                            })?,
 749                        )
 750                        .await?;
 751                    anyhow::Ok(())
 752                }
 753                .log_err(),
 754            )
 755            .await;
 756        });
 757    }
 758
 759    pub(crate) fn set_modal_open(&mut self, open: bool, cx: &mut Context<Self>) {
 760        self.modal_open = open;
 761        cx.notify();
 762    }
 763
 764    fn dispatch_context(&self, window: &mut Window, cx: &Context<Self>) -> KeyContext {
 765        let mut dispatch_context = KeyContext::new_with_defaults();
 766        dispatch_context.add("GitPanel");
 767
 768        if window
 769            .focused(cx)
 770            .map_or(false, |focused| self.focus_handle == focused)
 771        {
 772            dispatch_context.add("menu");
 773            dispatch_context.add("ChangesList");
 774        }
 775
 776        if self.commit_editor.read(cx).is_focused(window) {
 777            dispatch_context.add("CommitEditor");
 778        }
 779
 780        dispatch_context
 781    }
 782
 783    fn close_panel(&mut self, _: &Close, _window: &mut Window, cx: &mut Context<Self>) {
 784        cx.emit(PanelEvent::Close);
 785    }
 786
 787    fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 788        if !self.focus_handle.contains_focused(window, cx) {
 789            cx.emit(Event::Focus);
 790        }
 791    }
 792
 793    fn scroll_to_selected_entry(&mut self, cx: &mut Context<Self>) {
 794        if let Some(selected_entry) = self.selected_entry {
 795            self.scroll_handle
 796                .scroll_to_item(selected_entry, ScrollStrategy::Center);
 797        }
 798
 799        cx.notify();
 800    }
 801
 802    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
 803        if !self.entries.is_empty() {
 804            self.selected_entry = Some(1);
 805            self.scroll_to_selected_entry(cx);
 806        }
 807    }
 808
 809    fn select_previous(
 810        &mut self,
 811        _: &SelectPrevious,
 812        _window: &mut Window,
 813        cx: &mut Context<Self>,
 814    ) {
 815        let item_count = self.entries.len();
 816        if item_count == 0 {
 817            return;
 818        }
 819
 820        if let Some(selected_entry) = self.selected_entry {
 821            let new_selected_entry = if selected_entry > 0 {
 822                selected_entry - 1
 823            } else {
 824                selected_entry
 825            };
 826
 827            if matches!(
 828                self.entries.get(new_selected_entry),
 829                Some(GitListEntry::Header(..))
 830            ) {
 831                if new_selected_entry > 0 {
 832                    self.selected_entry = Some(new_selected_entry - 1)
 833                }
 834            } else {
 835                self.selected_entry = Some(new_selected_entry);
 836            }
 837
 838            self.scroll_to_selected_entry(cx);
 839        }
 840
 841        cx.notify();
 842    }
 843
 844    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
 845        let item_count = self.entries.len();
 846        if item_count == 0 {
 847            return;
 848        }
 849
 850        if let Some(selected_entry) = self.selected_entry {
 851            let new_selected_entry = if selected_entry < item_count - 1 {
 852                selected_entry + 1
 853            } else {
 854                selected_entry
 855            };
 856            if matches!(
 857                self.entries.get(new_selected_entry),
 858                Some(GitListEntry::Header(..))
 859            ) {
 860                self.selected_entry = Some(new_selected_entry + 1);
 861            } else {
 862                self.selected_entry = Some(new_selected_entry);
 863            }
 864
 865            self.scroll_to_selected_entry(cx);
 866        }
 867
 868        cx.notify();
 869    }
 870
 871    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
 872        if self.entries.last().is_some() {
 873            self.selected_entry = Some(self.entries.len() - 1);
 874            self.scroll_to_selected_entry(cx);
 875        }
 876    }
 877
 878    fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
 879        self.commit_editor.update(cx, |editor, cx| {
 880            window.focus(&editor.focus_handle(cx));
 881        });
 882        cx.notify();
 883    }
 884
 885    fn select_first_entry_if_none(&mut self, cx: &mut Context<Self>) {
 886        let have_entries = self
 887            .active_repository
 888            .as_ref()
 889            .map_or(false, |active_repository| {
 890                active_repository.read(cx).status_summary().count > 0
 891            });
 892        if have_entries && self.selected_entry.is_none() {
 893            self.selected_entry = Some(1);
 894            self.scroll_to_selected_entry(cx);
 895            cx.notify();
 896        }
 897    }
 898
 899    fn focus_changes_list(
 900        &mut self,
 901        _: &FocusChanges,
 902        window: &mut Window,
 903        cx: &mut Context<Self>,
 904    ) {
 905        self.select_first_entry_if_none(cx);
 906
 907        cx.focus_self(window);
 908        cx.notify();
 909    }
 910
 911    fn get_selected_entry(&self) -> Option<&GitListEntry> {
 912        self.selected_entry.and_then(|i| self.entries.get(i))
 913    }
 914
 915    fn open_diff(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
 916        maybe!({
 917            let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
 918            let workspace = self.workspace.upgrade()?;
 919            let git_repo = self.active_repository.as_ref()?;
 920
 921            if let Some(project_diff) = workspace.read(cx).active_item_as::<ProjectDiff>(cx) {
 922                if let Some(project_path) = project_diff.read(cx).active_path(cx) {
 923                    if Some(&entry.repo_path)
 924                        == git_repo
 925                            .read(cx)
 926                            .project_path_to_repo_path(&project_path, cx)
 927                            .as_ref()
 928                    {
 929                        project_diff.focus_handle(cx).focus(window);
 930                        project_diff.update(cx, |project_diff, cx| project_diff.autoscroll(cx));
 931                        return None;
 932                    }
 933                }
 934            };
 935
 936            self.workspace
 937                .update(cx, |workspace, cx| {
 938                    ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
 939                })
 940                .ok();
 941            self.focus_handle.focus(window);
 942
 943            Some(())
 944        });
 945    }
 946
 947    fn open_file(
 948        &mut self,
 949        _: &menu::SecondaryConfirm,
 950        window: &mut Window,
 951        cx: &mut Context<Self>,
 952    ) {
 953        maybe!({
 954            let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
 955            let active_repo = self.active_repository.as_ref()?;
 956            let path = active_repo
 957                .read(cx)
 958                .repo_path_to_project_path(&entry.repo_path, cx)?;
 959            if entry.status.is_deleted() {
 960                return None;
 961            }
 962
 963            self.workspace
 964                .update(cx, |workspace, cx| {
 965                    workspace
 966                        .open_path_preview(path, None, false, false, true, window, cx)
 967                        .detach_and_prompt_err("Failed to open file", window, cx, |e, _, _| {
 968                            Some(format!("{e}"))
 969                        });
 970                })
 971                .ok()
 972        });
 973    }
 974
 975    fn revert_selected(
 976        &mut self,
 977        action: &git::RestoreFile,
 978        window: &mut Window,
 979        cx: &mut Context<Self>,
 980    ) {
 981        maybe!({
 982            let list_entry = self.entries.get(self.selected_entry?)?.clone();
 983            let entry = list_entry.status_entry()?.to_owned();
 984            let skip_prompt = action.skip_prompt || entry.status.is_created();
 985
 986            let prompt = if skip_prompt {
 987                Task::ready(Ok(0))
 988            } else {
 989                let prompt = window.prompt(
 990                    PromptLevel::Warning,
 991                    &format!(
 992                        "Are you sure you want to restore {}?",
 993                        entry
 994                            .repo_path
 995                            .file_name()
 996                            .unwrap_or(entry.repo_path.as_os_str())
 997                            .to_string_lossy()
 998                    ),
 999                    None,
1000                    &["Restore", "Cancel"],
1001                    cx,
1002                );
1003                cx.background_spawn(prompt)
1004            };
1005
1006            let this = cx.weak_entity();
1007            window
1008                .spawn(cx, async move |cx| {
1009                    if prompt.await? != 0 {
1010                        return anyhow::Ok(());
1011                    }
1012
1013                    this.update_in(cx, |this, window, cx| {
1014                        this.revert_entry(&entry, window, cx);
1015                    })?;
1016
1017                    Ok(())
1018                })
1019                .detach();
1020            Some(())
1021        });
1022    }
1023
1024    fn revert_entry(
1025        &mut self,
1026        entry: &GitStatusEntry,
1027        window: &mut Window,
1028        cx: &mut Context<Self>,
1029    ) {
1030        maybe!({
1031            let active_repo = self.active_repository.clone()?;
1032            let path = active_repo
1033                .read(cx)
1034                .repo_path_to_project_path(&entry.repo_path, cx)?;
1035            let workspace = self.workspace.clone();
1036
1037            if entry.status.staging().has_staged() {
1038                self.change_file_stage(false, vec![entry.clone()], cx);
1039            }
1040            let filename = path.path.file_name()?.to_string_lossy();
1041
1042            if !entry.status.is_created() {
1043                self.perform_checkout(vec![entry.clone()], cx);
1044            } else {
1045                let prompt = prompt(&format!("Trash {}?", filename), None, window, cx);
1046                cx.spawn_in(window, async move |_, cx| {
1047                    match prompt.await? {
1048                        TrashCancel::Trash => {}
1049                        TrashCancel::Cancel => return Ok(()),
1050                    }
1051                    let task = workspace.update(cx, |workspace, cx| {
1052                        workspace
1053                            .project()
1054                            .update(cx, |project, cx| project.delete_file(path, true, cx))
1055                    })?;
1056                    if let Some(task) = task {
1057                        task.await?;
1058                    }
1059                    Ok(())
1060                })
1061                .detach_and_prompt_err(
1062                    "Failed to trash file",
1063                    window,
1064                    cx,
1065                    |e, _, _| Some(format!("{e}")),
1066                );
1067            }
1068            Some(())
1069        });
1070    }
1071
1072    fn perform_checkout(&mut self, entries: Vec<GitStatusEntry>, cx: &mut Context<Self>) {
1073        let workspace = self.workspace.clone();
1074        let Some(active_repository) = self.active_repository.clone() else {
1075            return;
1076        };
1077
1078        let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1;
1079        self.pending.push(PendingOperation {
1080            op_id,
1081            target_status: TargetStatus::Reverted,
1082            entries: entries.clone(),
1083            finished: false,
1084        });
1085        self.update_visible_entries(cx);
1086        let task = cx.spawn(async move |_, cx| {
1087            let tasks: Vec<_> = workspace.update(cx, |workspace, cx| {
1088                workspace.project().update(cx, |project, cx| {
1089                    entries
1090                        .iter()
1091                        .filter_map(|entry| {
1092                            let path = active_repository
1093                                .read(cx)
1094                                .repo_path_to_project_path(&entry.repo_path, cx)?;
1095                            Some(project.open_buffer(path, cx))
1096                        })
1097                        .collect()
1098                })
1099            })?;
1100
1101            let buffers = futures::future::join_all(tasks).await;
1102
1103            active_repository
1104                .update(cx, |repo, cx| {
1105                    repo.checkout_files(
1106                        "HEAD",
1107                        entries
1108                            .into_iter()
1109                            .map(|entries| entries.repo_path)
1110                            .collect(),
1111                        cx,
1112                    )
1113                })?
1114                .await??;
1115
1116            let tasks: Vec<_> = cx.update(|cx| {
1117                buffers
1118                    .iter()
1119                    .filter_map(|buffer| {
1120                        buffer.as_ref().ok()?.update(cx, |buffer, cx| {
1121                            buffer.is_dirty().then(|| buffer.reload(cx))
1122                        })
1123                    })
1124                    .collect()
1125            })?;
1126
1127            futures::future::join_all(tasks).await;
1128
1129            Ok(())
1130        });
1131
1132        cx.spawn(async move |this, cx| {
1133            let result = task.await;
1134
1135            this.update(cx, |this, cx| {
1136                for pending in this.pending.iter_mut() {
1137                    if pending.op_id == op_id {
1138                        pending.finished = true;
1139                        if result.is_err() {
1140                            pending.target_status = TargetStatus::Unchanged;
1141                            this.update_visible_entries(cx);
1142                        }
1143                        break;
1144                    }
1145                }
1146                result
1147                    .map_err(|e| {
1148                        this.show_error_toast("checkout", e, cx);
1149                    })
1150                    .ok();
1151            })
1152            .ok();
1153        })
1154        .detach();
1155    }
1156
1157    fn restore_tracked_files(
1158        &mut self,
1159        _: &RestoreTrackedFiles,
1160        window: &mut Window,
1161        cx: &mut Context<Self>,
1162    ) {
1163        let entries = self
1164            .entries
1165            .iter()
1166            .filter_map(|entry| entry.status_entry().cloned())
1167            .filter(|status_entry| !status_entry.status.is_created())
1168            .collect::<Vec<_>>();
1169
1170        match entries.len() {
1171            0 => return,
1172            1 => return self.revert_entry(&entries[0], window, cx),
1173            _ => {}
1174        }
1175        let mut details = entries
1176            .iter()
1177            .filter_map(|entry| entry.repo_path.0.file_name())
1178            .map(|filename| filename.to_string_lossy())
1179            .take(5)
1180            .join("\n");
1181        if entries.len() > 5 {
1182            details.push_str(&format!("\nand {} more…", entries.len() - 5))
1183        }
1184
1185        #[derive(strum::EnumIter, strum::VariantNames)]
1186        #[strum(serialize_all = "title_case")]
1187        enum RestoreCancel {
1188            RestoreTrackedFiles,
1189            Cancel,
1190        }
1191        let prompt = prompt(
1192            "Discard changes to these files?",
1193            Some(&details),
1194            window,
1195            cx,
1196        );
1197        cx.spawn(async move |this, cx| match prompt.await {
1198            Ok(RestoreCancel::RestoreTrackedFiles) => {
1199                this.update(cx, |this, cx| {
1200                    this.perform_checkout(entries, cx);
1201                })
1202                .ok();
1203            }
1204            _ => {
1205                return;
1206            }
1207        })
1208        .detach();
1209    }
1210
1211    fn clean_all(&mut self, _: &TrashUntrackedFiles, window: &mut Window, cx: &mut Context<Self>) {
1212        let workspace = self.workspace.clone();
1213        let Some(active_repo) = self.active_repository.clone() else {
1214            return;
1215        };
1216        let to_delete = self
1217            .entries
1218            .iter()
1219            .filter_map(|entry| entry.status_entry())
1220            .filter(|status_entry| status_entry.status.is_created())
1221            .cloned()
1222            .collect::<Vec<_>>();
1223
1224        match to_delete.len() {
1225            0 => return,
1226            1 => return self.revert_entry(&to_delete[0], window, cx),
1227            _ => {}
1228        };
1229
1230        let mut details = to_delete
1231            .iter()
1232            .map(|entry| {
1233                entry
1234                    .repo_path
1235                    .0
1236                    .file_name()
1237                    .map(|f| f.to_string_lossy())
1238                    .unwrap_or_default()
1239            })
1240            .take(5)
1241            .join("\n");
1242
1243        if to_delete.len() > 5 {
1244            details.push_str(&format!("\nand {} more…", to_delete.len() - 5))
1245        }
1246
1247        let prompt = prompt("Trash these files?", Some(&details), window, cx);
1248        cx.spawn_in(window, async move |this, cx| {
1249            match prompt.await? {
1250                TrashCancel::Trash => {}
1251                TrashCancel::Cancel => return Ok(()),
1252            }
1253            let tasks = workspace.update(cx, |workspace, cx| {
1254                to_delete
1255                    .iter()
1256                    .filter_map(|entry| {
1257                        workspace.project().update(cx, |project, cx| {
1258                            let project_path = active_repo
1259                                .read(cx)
1260                                .repo_path_to_project_path(&entry.repo_path, cx)?;
1261                            project.delete_file(project_path, true, cx)
1262                        })
1263                    })
1264                    .collect::<Vec<_>>()
1265            })?;
1266            let to_unstage = to_delete
1267                .into_iter()
1268                .filter(|entry| !entry.status.staging().is_fully_unstaged())
1269                .collect();
1270            this.update(cx, |this, cx| this.change_file_stage(false, to_unstage, cx))?;
1271            for task in tasks {
1272                task.await?;
1273            }
1274            Ok(())
1275        })
1276        .detach_and_prompt_err("Failed to trash files", window, cx, |e, _, _| {
1277            Some(format!("{e}"))
1278        });
1279    }
1280
1281    pub fn stage_all(&mut self, _: &StageAll, _window: &mut Window, cx: &mut Context<Self>) {
1282        let entries = self
1283            .entries
1284            .iter()
1285            .filter_map(|entry| entry.status_entry())
1286            .filter(|status_entry| status_entry.staging.has_unstaged())
1287            .cloned()
1288            .collect::<Vec<_>>();
1289        self.change_file_stage(true, entries, cx);
1290    }
1291
1292    pub fn unstage_all(&mut self, _: &UnstageAll, _window: &mut Window, cx: &mut Context<Self>) {
1293        let entries = self
1294            .entries
1295            .iter()
1296            .filter_map(|entry| entry.status_entry())
1297            .filter(|status_entry| status_entry.staging.has_staged())
1298            .cloned()
1299            .collect::<Vec<_>>();
1300        self.change_file_stage(false, entries, cx);
1301    }
1302
1303    fn toggle_staged_for_entry(
1304        &mut self,
1305        entry: &GitListEntry,
1306        _window: &mut Window,
1307        cx: &mut Context<Self>,
1308    ) {
1309        let Some(active_repository) = self.active_repository.as_ref() else {
1310            return;
1311        };
1312        let (stage, repo_paths) = match entry {
1313            GitListEntry::Status(status_entry) => {
1314                if status_entry.status.staging().is_fully_staged() {
1315                    if let Some(op) = self.bulk_staging.clone()
1316                        && op.anchor == status_entry.repo_path
1317                    {
1318                        self.bulk_staging = None;
1319                    }
1320
1321                    (false, vec![status_entry.clone()])
1322                } else {
1323                    self.set_bulk_staging_anchor(status_entry.repo_path.clone(), cx);
1324
1325                    (true, vec![status_entry.clone()])
1326                }
1327            }
1328            GitListEntry::Header(section) => {
1329                let goal_staged_state = !self.header_state(section.header).selected();
1330                let repository = active_repository.read(cx);
1331                let entries = self
1332                    .entries
1333                    .iter()
1334                    .filter_map(|entry| entry.status_entry())
1335                    .filter(|status_entry| {
1336                        section.contains(&status_entry, repository)
1337                            && status_entry.staging.as_bool() != Some(goal_staged_state)
1338                    })
1339                    .map(|status_entry| status_entry.clone())
1340                    .collect::<Vec<_>>();
1341
1342                (goal_staged_state, entries)
1343            }
1344        };
1345        self.change_file_stage(stage, repo_paths, cx);
1346    }
1347
1348    fn change_file_stage(
1349        &mut self,
1350        stage: bool,
1351        entries: Vec<GitStatusEntry>,
1352        cx: &mut Context<Self>,
1353    ) {
1354        let Some(active_repository) = self.active_repository.clone() else {
1355            return;
1356        };
1357        let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1;
1358        self.pending.push(PendingOperation {
1359            op_id,
1360            target_status: if stage {
1361                TargetStatus::Staged
1362            } else {
1363                TargetStatus::Unstaged
1364            },
1365            entries: entries.clone(),
1366            finished: false,
1367        });
1368        let repository = active_repository.read(cx);
1369        self.update_counts(repository);
1370        cx.notify();
1371
1372        cx.spawn({
1373            async move |this, cx| {
1374                let result = cx
1375                    .update(|cx| {
1376                        if stage {
1377                            active_repository.update(cx, |repo, cx| {
1378                                let repo_paths = entries
1379                                    .iter()
1380                                    .map(|entry| entry.repo_path.clone())
1381                                    .collect();
1382                                repo.stage_entries(repo_paths, cx)
1383                            })
1384                        } else {
1385                            active_repository.update(cx, |repo, cx| {
1386                                let repo_paths = entries
1387                                    .iter()
1388                                    .map(|entry| entry.repo_path.clone())
1389                                    .collect();
1390                                repo.unstage_entries(repo_paths, cx)
1391                            })
1392                        }
1393                    })?
1394                    .await;
1395
1396                this.update(cx, |this, cx| {
1397                    for pending in this.pending.iter_mut() {
1398                        if pending.op_id == op_id {
1399                            pending.finished = true
1400                        }
1401                    }
1402                    result
1403                        .map_err(|e| {
1404                            this.show_error_toast(if stage { "add" } else { "reset" }, e, cx);
1405                        })
1406                        .ok();
1407                    cx.notify();
1408                })
1409            }
1410        })
1411        .detach();
1412    }
1413
1414    pub fn total_staged_count(&self) -> usize {
1415        self.tracked_staged_count + self.new_staged_count + self.conflicted_staged_count
1416    }
1417
1418    pub fn commit_message_buffer(&self, cx: &App) -> Entity<Buffer> {
1419        self.commit_editor
1420            .read(cx)
1421            .buffer()
1422            .read(cx)
1423            .as_singleton()
1424            .unwrap()
1425            .clone()
1426    }
1427
1428    fn toggle_staged_for_selected(
1429        &mut self,
1430        _: &git::ToggleStaged,
1431        window: &mut Window,
1432        cx: &mut Context<Self>,
1433    ) {
1434        if let Some(selected_entry) = self.get_selected_entry().cloned() {
1435            self.toggle_staged_for_entry(&selected_entry, window, cx);
1436        }
1437    }
1438
1439    fn stage_range(&mut self, _: &git::StageRange, _window: &mut Window, cx: &mut Context<Self>) {
1440        let Some(index) = self.selected_entry else {
1441            return;
1442        };
1443        self.stage_bulk(index, cx);
1444    }
1445
1446    fn stage_selected(&mut self, _: &git::StageFile, _window: &mut Window, cx: &mut Context<Self>) {
1447        let Some(selected_entry) = self.get_selected_entry() else {
1448            return;
1449        };
1450        let Some(status_entry) = selected_entry.status_entry() else {
1451            return;
1452        };
1453        if status_entry.staging != StageStatus::Staged {
1454            self.change_file_stage(true, vec![status_entry.clone()], cx);
1455        }
1456    }
1457
1458    fn unstage_selected(
1459        &mut self,
1460        _: &git::UnstageFile,
1461        _window: &mut Window,
1462        cx: &mut Context<Self>,
1463    ) {
1464        let Some(selected_entry) = self.get_selected_entry() else {
1465            return;
1466        };
1467        let Some(status_entry) = selected_entry.status_entry() else {
1468            return;
1469        };
1470        if status_entry.staging != StageStatus::Unstaged {
1471            self.change_file_stage(false, vec![status_entry.clone()], cx);
1472        }
1473    }
1474
1475    fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
1476        if self.amend_pending {
1477            return;
1478        }
1479        if self
1480            .commit_editor
1481            .focus_handle(cx)
1482            .contains_focused(window, cx)
1483        {
1484            telemetry::event!("Git Committed", source = "Git Panel");
1485            self.commit_changes(
1486                CommitOptions {
1487                    amend: false,
1488                    signoff: self.signoff_enabled,
1489                },
1490                window,
1491                cx,
1492            )
1493        } else {
1494            cx.propagate();
1495        }
1496    }
1497
1498    fn amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context<Self>) {
1499        if self
1500            .commit_editor
1501            .focus_handle(cx)
1502            .contains_focused(window, cx)
1503        {
1504            if self.head_commit(cx).is_some() {
1505                if !self.amend_pending {
1506                    self.set_amend_pending(true, cx);
1507                    self.load_last_commit_message_if_empty(cx);
1508                } else {
1509                    telemetry::event!("Git Amended", source = "Git Panel");
1510                    self.set_amend_pending(false, cx);
1511                    self.commit_changes(
1512                        CommitOptions {
1513                            amend: true,
1514                            signoff: self.signoff_enabled,
1515                        },
1516                        window,
1517                        cx,
1518                    );
1519                }
1520            }
1521        } else {
1522            cx.propagate();
1523        }
1524    }
1525
1526    pub fn head_commit(&self, cx: &App) -> Option<CommitDetails> {
1527        self.active_repository
1528            .as_ref()
1529            .and_then(|repo| repo.read(cx).head_commit.as_ref())
1530            .cloned()
1531    }
1532
1533    pub fn load_last_commit_message_if_empty(&mut self, cx: &mut Context<Self>) {
1534        if !self.commit_editor.read(cx).is_empty(cx) {
1535            return;
1536        }
1537        let Some(head_commit) = self.head_commit(cx) else {
1538            return;
1539        };
1540        let recent_sha = head_commit.sha.to_string();
1541        let detail_task = self.load_commit_details(recent_sha, cx);
1542        cx.spawn(async move |this, cx| {
1543            if let Ok(message) = detail_task.await.map(|detail| detail.message) {
1544                this.update(cx, |this, cx| {
1545                    this.commit_message_buffer(cx).update(cx, |buffer, cx| {
1546                        let start = buffer.anchor_before(0);
1547                        let end = buffer.anchor_after(buffer.len());
1548                        buffer.edit([(start..end, message)], None, cx);
1549                    });
1550                })
1551                .log_err();
1552            }
1553        })
1554        .detach();
1555    }
1556
1557    fn custom_or_suggested_commit_message(
1558        &self,
1559        window: &mut Window,
1560        cx: &mut Context<Self>,
1561    ) -> Option<String> {
1562        let git_commit_language = self.commit_editor.read(cx).language_at(0, cx);
1563        let message = self.commit_editor.read(cx).text(cx);
1564        if message.is_empty() {
1565            return self
1566                .suggest_commit_message(cx)
1567                .filter(|message| !message.trim().is_empty());
1568        } else if message.trim().is_empty() {
1569            return None;
1570        }
1571        let buffer = cx.new(|cx| {
1572            let mut buffer = Buffer::local(message, cx);
1573            buffer.set_language(git_commit_language, cx);
1574            buffer
1575        });
1576        let editor = cx.new(|cx| Editor::for_buffer(buffer, None, window, cx));
1577        let wrapped_message = editor.update(cx, |editor, cx| {
1578            editor.select_all(&Default::default(), window, cx);
1579            editor.rewrap(&Default::default(), window, cx);
1580            editor.text(cx)
1581        });
1582        if wrapped_message.trim().is_empty() {
1583            return None;
1584        }
1585        Some(wrapped_message)
1586    }
1587
1588    fn has_commit_message(&self, cx: &mut Context<Self>) -> bool {
1589        let text = self.commit_editor.read(cx).text(cx);
1590        if !text.trim().is_empty() {
1591            return true;
1592        } else if text.is_empty() {
1593            return self
1594                .suggest_commit_message(cx)
1595                .is_some_and(|text| !text.trim().is_empty());
1596        } else {
1597            return false;
1598        }
1599    }
1600
1601    pub(crate) fn commit_changes(
1602        &mut self,
1603        options: CommitOptions,
1604        window: &mut Window,
1605        cx: &mut Context<Self>,
1606    ) {
1607        let Some(active_repository) = self.active_repository.clone() else {
1608            return;
1609        };
1610        let error_spawn = |message, window: &mut Window, cx: &mut App| {
1611            let prompt = window.prompt(PromptLevel::Warning, message, None, &["Ok"], cx);
1612            cx.spawn(async move |_| {
1613                prompt.await.ok();
1614            })
1615            .detach();
1616        };
1617
1618        if self.has_unstaged_conflicts() {
1619            error_spawn(
1620                "There are still conflicts. You must stage these before committing",
1621                window,
1622                cx,
1623            );
1624            return;
1625        }
1626
1627        let commit_message = self.custom_or_suggested_commit_message(window, cx);
1628
1629        let Some(mut message) = commit_message else {
1630            self.commit_editor.read(cx).focus_handle(cx).focus(window);
1631            return;
1632        };
1633
1634        if self.add_coauthors {
1635            self.fill_co_authors(&mut message, cx);
1636        }
1637
1638        let task = if self.has_staged_changes() {
1639            // Repository serializes all git operations, so we can just send a commit immediately
1640            let commit_task = active_repository.update(cx, |repo, cx| {
1641                repo.commit(message.into(), None, options, cx)
1642            });
1643            cx.background_spawn(async move { commit_task.await? })
1644        } else {
1645            let changed_files = self
1646                .entries
1647                .iter()
1648                .filter_map(|entry| entry.status_entry())
1649                .filter(|status_entry| !status_entry.status.is_created())
1650                .map(|status_entry| status_entry.repo_path.clone())
1651                .collect::<Vec<_>>();
1652
1653            if changed_files.is_empty() {
1654                error_spawn("No changes to commit", window, cx);
1655                return;
1656            }
1657
1658            let stage_task =
1659                active_repository.update(cx, |repo, cx| repo.stage_entries(changed_files, cx));
1660            cx.spawn(async move |_, cx| {
1661                stage_task.await?;
1662                let commit_task = active_repository.update(cx, |repo, cx| {
1663                    repo.commit(message.into(), None, options, cx)
1664                })?;
1665                commit_task.await?
1666            })
1667        };
1668        let task = cx.spawn_in(window, async move |this, cx| {
1669            let result = task.await;
1670            this.update_in(cx, |this, window, cx| {
1671                this.pending_commit.take();
1672                match result {
1673                    Ok(()) => {
1674                        this.commit_editor
1675                            .update(cx, |editor, cx| editor.clear(window, cx));
1676                    }
1677                    Err(e) => this.show_error_toast("commit", e, cx),
1678                }
1679            })
1680            .ok();
1681        });
1682
1683        self.pending_commit = Some(task);
1684    }
1685
1686    fn uncommit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1687        let Some(repo) = self.active_repository.clone() else {
1688            return;
1689        };
1690        telemetry::event!("Git Uncommitted");
1691
1692        let confirmation = self.check_for_pushed_commits(window, cx);
1693        let prior_head = self.load_commit_details("HEAD".to_string(), cx);
1694
1695        let task = cx.spawn_in(window, async move |this, cx| {
1696            let result = maybe!(async {
1697                if let Ok(true) = confirmation.await {
1698                    let prior_head = prior_head.await?;
1699
1700                    repo.update(cx, |repo, cx| {
1701                        repo.reset("HEAD^".to_string(), ResetMode::Soft, cx)
1702                    })?
1703                    .await??;
1704
1705                    Ok(Some(prior_head))
1706                } else {
1707                    Ok(None)
1708                }
1709            })
1710            .await;
1711
1712            this.update_in(cx, |this, window, cx| {
1713                this.pending_commit.take();
1714                match result {
1715                    Ok(None) => {}
1716                    Ok(Some(prior_commit)) => {
1717                        this.commit_editor.update(cx, |editor, cx| {
1718                            editor.set_text(prior_commit.message, window, cx)
1719                        });
1720                    }
1721                    Err(e) => this.show_error_toast("reset", e, cx),
1722                }
1723            })
1724            .ok();
1725        });
1726
1727        self.pending_commit = Some(task);
1728    }
1729
1730    fn check_for_pushed_commits(
1731        &mut self,
1732        window: &mut Window,
1733        cx: &mut Context<Self>,
1734    ) -> impl Future<Output = anyhow::Result<bool>> + use<> {
1735        let repo = self.active_repository.clone();
1736        let mut cx = window.to_async(cx);
1737
1738        async move {
1739            let repo = repo.context("No active repository")?;
1740
1741            let pushed_to: Vec<SharedString> = repo
1742                .update(&mut cx, |repo, _| repo.check_for_pushed_commits())?
1743                .await??;
1744
1745            if pushed_to.is_empty() {
1746                Ok(true)
1747            } else {
1748                #[derive(strum::EnumIter, strum::VariantNames)]
1749                #[strum(serialize_all = "title_case")]
1750                enum CancelUncommit {
1751                    Uncommit,
1752                    Cancel,
1753                }
1754                let detail = format!(
1755                    "This commit was already pushed to {}.",
1756                    pushed_to.into_iter().join(", ")
1757                );
1758                let result = cx
1759                    .update(|window, cx| prompt("Are you sure?", Some(&detail), window, cx))?
1760                    .await?;
1761
1762                match result {
1763                    CancelUncommit::Cancel => Ok(false),
1764                    CancelUncommit::Uncommit => Ok(true),
1765                }
1766            }
1767        }
1768    }
1769
1770    /// Suggests a commit message based on the changed files and their statuses
1771    pub fn suggest_commit_message(&self, cx: &App) -> Option<String> {
1772        if let Some(merge_message) = self
1773            .active_repository
1774            .as_ref()
1775            .and_then(|repo| repo.read(cx).merge.message.as_ref())
1776        {
1777            return Some(merge_message.to_string());
1778        }
1779
1780        let git_status_entry = if let Some(staged_entry) = &self.single_staged_entry {
1781            Some(staged_entry)
1782        } else if let Some(single_tracked_entry) = &self.single_tracked_entry {
1783            Some(single_tracked_entry)
1784        } else {
1785            None
1786        }?;
1787
1788        let action_text = if git_status_entry.status.is_deleted() {
1789            Some("Delete")
1790        } else if git_status_entry.status.is_created() {
1791            Some("Create")
1792        } else if git_status_entry.status.is_modified() {
1793            Some("Update")
1794        } else {
1795            None
1796        }?;
1797
1798        let file_name = git_status_entry
1799            .repo_path
1800            .file_name()
1801            .unwrap_or_default()
1802            .to_string_lossy();
1803
1804        Some(format!("{} {}", action_text, file_name))
1805    }
1806
1807    fn generate_commit_message_action(
1808        &mut self,
1809        _: &git::GenerateCommitMessage,
1810        _window: &mut Window,
1811        cx: &mut Context<Self>,
1812    ) {
1813        self.generate_commit_message(cx);
1814    }
1815
1816    /// Generates a commit message using an LLM.
1817    pub fn generate_commit_message(&mut self, cx: &mut Context<Self>) {
1818        if !self.can_commit() || DisableAiSettings::get_global(cx).disable_ai {
1819            return;
1820        }
1821
1822        let model = match current_language_model(cx) {
1823            Some(value) => value,
1824            None => return,
1825        };
1826
1827        let Some(repo) = self.active_repository.as_ref() else {
1828            return;
1829        };
1830
1831        telemetry::event!("Git Commit Message Generated");
1832
1833        let diff = repo.update(cx, |repo, cx| {
1834            if self.has_staged_changes() {
1835                repo.diff(DiffType::HeadToIndex, cx)
1836            } else {
1837                repo.diff(DiffType::HeadToWorktree, cx)
1838            }
1839        });
1840
1841        let temperature = AgentSettings::temperature_for_model(&model, cx);
1842
1843        self.generate_commit_message_task = Some(cx.spawn(async move |this, cx| {
1844             async move {
1845                let _defer = cx.on_drop(&this, |this, _cx| {
1846                    this.generate_commit_message_task.take();
1847                });
1848
1849                let mut diff_text = match diff.await {
1850                    Ok(result) => match result {
1851                        Ok(text) => text,
1852                        Err(e) => {
1853                            Self::show_commit_message_error(&this, &e, cx);
1854                            return anyhow::Ok(());
1855                        }
1856                    },
1857                    Err(e) => {
1858                        Self::show_commit_message_error(&this, &e, cx);
1859                        return anyhow::Ok(());
1860                    }
1861                };
1862
1863                const ONE_MB: usize = 1_000_000;
1864                if diff_text.len() > ONE_MB {
1865                    diff_text = diff_text.chars().take(ONE_MB).collect()
1866                }
1867
1868                let subject = this.update(cx, |this, cx| {
1869                    this.commit_editor.read(cx).text(cx).lines().next().map(ToOwned::to_owned).unwrap_or_default()
1870                })?;
1871
1872                let text_empty = subject.trim().is_empty();
1873
1874                let content = if text_empty {
1875                    format!("{PROMPT}\nHere are the changes in this commit:\n{diff_text}")
1876                } else {
1877                    format!("{PROMPT}\nHere is the user's subject line:\n{subject}\nHere are the changes in this commit:\n{diff_text}\n")
1878                };
1879
1880                const PROMPT: &str = include_str!("commit_message_prompt.txt");
1881
1882                let request = LanguageModelRequest {
1883                    thread_id: None,
1884                    prompt_id: None,
1885                    intent: Some(CompletionIntent::GenerateGitCommitMessage),
1886                    mode: None,
1887                    messages: vec![LanguageModelRequestMessage {
1888                        role: Role::User,
1889                        content: vec![content.into()],
1890                        cache: false,
1891                    }],
1892                    tools: Vec::new(),
1893                    tool_choice: None,
1894                    stop: Vec::new(),
1895                    temperature,
1896                    thinking_allowed: false,
1897                };
1898
1899                let stream = model.stream_completion_text(request, &cx);
1900                match stream.await {
1901                    Ok(mut messages) => {
1902                        if !text_empty {
1903                            this.update(cx, |this, cx| {
1904                                this.commit_message_buffer(cx).update(cx, |buffer, cx| {
1905                                    let insert_position = buffer.anchor_before(buffer.len());
1906                                    buffer.edit([(insert_position..insert_position, "\n")], None, cx)
1907                                });
1908                            })?;
1909                        }
1910
1911                        while let Some(message) = messages.stream.next().await {
1912                            match message {
1913                                Ok(text) => {
1914                                    this.update(cx, |this, cx| {
1915                                        this.commit_message_buffer(cx).update(cx, |buffer, cx| {
1916                                            let insert_position = buffer.anchor_before(buffer.len());
1917                                            buffer.edit([(insert_position..insert_position, text)], None, cx);
1918                                        });
1919                                    })?;
1920                                }
1921                                Err(e) => {
1922                                    Self::show_commit_message_error(&this, &e, cx);
1923                                    break;
1924                                }
1925                            }
1926                        }
1927                    }
1928                    Err(e) => {
1929                        Self::show_commit_message_error(&this, &e, cx);
1930                    }
1931                }
1932
1933                anyhow::Ok(())
1934            }
1935            .log_err().await
1936        }));
1937    }
1938
1939    fn get_fetch_options(
1940        &self,
1941        window: &mut Window,
1942        cx: &mut Context<Self>,
1943    ) -> Task<Option<FetchOptions>> {
1944        let repo = self.active_repository.clone();
1945        let workspace = self.workspace.clone();
1946
1947        cx.spawn_in(window, async move |_, cx| {
1948            let repo = repo?;
1949            let remotes = repo
1950                .update(cx, |repo, _| repo.get_remotes(None))
1951                .ok()?
1952                .await
1953                .ok()?
1954                .log_err()?;
1955
1956            let mut remotes: Vec<_> = remotes.into_iter().map(FetchOptions::Remote).collect();
1957            if remotes.len() > 1 {
1958                remotes.push(FetchOptions::All);
1959            }
1960            let selection = cx
1961                .update(|window, cx| {
1962                    picker_prompt::prompt(
1963                        "Pick which remote to fetch",
1964                        remotes.iter().map(|r| r.name()).collect(),
1965                        workspace,
1966                        window,
1967                        cx,
1968                    )
1969                })
1970                .ok()?
1971                .await?;
1972            remotes.get(selection).cloned()
1973        })
1974    }
1975
1976    pub(crate) fn fetch(
1977        &mut self,
1978        is_fetch_all: bool,
1979        window: &mut Window,
1980        cx: &mut Context<Self>,
1981    ) {
1982        if !self.can_push_and_pull(cx) {
1983            return;
1984        }
1985
1986        let Some(repo) = self.active_repository.clone() else {
1987            return;
1988        };
1989        telemetry::event!("Git Fetched");
1990        let askpass = self.askpass_delegate("git fetch", window, cx);
1991        let this = cx.weak_entity();
1992
1993        let fetch_options = if is_fetch_all {
1994            Task::ready(Some(FetchOptions::All))
1995        } else {
1996            self.get_fetch_options(window, cx)
1997        };
1998
1999        window
2000            .spawn(cx, async move |cx| {
2001                let Some(fetch_options) = fetch_options.await else {
2002                    return Ok(());
2003                };
2004                let fetch = repo.update(cx, |repo, cx| {
2005                    repo.fetch(fetch_options.clone(), askpass, cx)
2006                })?;
2007
2008                let remote_message = fetch.await?;
2009                this.update(cx, |this, cx| {
2010                    let action = match fetch_options {
2011                        FetchOptions::All => RemoteAction::Fetch(None),
2012                        FetchOptions::Remote(remote) => RemoteAction::Fetch(Some(remote)),
2013                    };
2014                    match remote_message {
2015                        Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
2016                        Err(e) => {
2017                            log::error!("Error while fetching {:?}", e);
2018                            this.show_error_toast(action.name(), e, cx)
2019                        }
2020                    }
2021
2022                    anyhow::Ok(())
2023                })
2024                .ok();
2025                anyhow::Ok(())
2026            })
2027            .detach_and_log_err(cx);
2028    }
2029
2030    pub(crate) fn git_init(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2031        let worktrees = self
2032            .project
2033            .read(cx)
2034            .visible_worktrees(cx)
2035            .collect::<Vec<_>>();
2036
2037        let worktree = if worktrees.len() == 1 {
2038            Task::ready(Some(worktrees.first().unwrap().clone()))
2039        } else if worktrees.len() == 0 {
2040            let result = window.prompt(
2041                PromptLevel::Warning,
2042                "Unable to initialize a git repository",
2043                Some("Open a directory first"),
2044                &["Ok"],
2045                cx,
2046            );
2047            cx.background_executor()
2048                .spawn(async move {
2049                    result.await.ok();
2050                })
2051                .detach();
2052            return;
2053        } else {
2054            let worktree_directories = worktrees
2055                .iter()
2056                .map(|worktree| worktree.read(cx).abs_path())
2057                .map(|worktree_abs_path| {
2058                    if let Ok(path) = worktree_abs_path.strip_prefix(util::paths::home_dir()) {
2059                        Path::new("~")
2060                            .join(path)
2061                            .to_string_lossy()
2062                            .to_string()
2063                            .into()
2064                    } else {
2065                        worktree_abs_path.to_string_lossy().to_string().into()
2066                    }
2067                })
2068                .collect_vec();
2069            let prompt = picker_prompt::prompt(
2070                "Where would you like to initialize this git repository?",
2071                worktree_directories,
2072                self.workspace.clone(),
2073                window,
2074                cx,
2075            );
2076
2077            cx.spawn(async move |_, _| prompt.await.map(|ix| worktrees[ix].clone()))
2078        };
2079
2080        cx.spawn_in(window, async move |this, cx| {
2081            let worktree = match worktree.await {
2082                Some(worktree) => worktree,
2083                None => {
2084                    return;
2085                }
2086            };
2087
2088            let Ok(result) = this.update(cx, |this, cx| {
2089                let fallback_branch_name = GitPanelSettings::get_global(cx)
2090                    .fallback_branch_name
2091                    .clone();
2092                this.project.read(cx).git_init(
2093                    worktree.read(cx).abs_path(),
2094                    fallback_branch_name,
2095                    cx,
2096                )
2097            }) else {
2098                return;
2099            };
2100
2101            let result = result.await;
2102
2103            this.update_in(cx, |this, _, cx| match result {
2104                Ok(()) => {}
2105                Err(e) => this.show_error_toast("init", e, cx),
2106            })
2107            .ok();
2108        })
2109        .detach();
2110    }
2111
2112    pub(crate) fn pull(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2113        if !self.can_push_and_pull(cx) {
2114            return;
2115        }
2116        let Some(repo) = self.active_repository.clone() else {
2117            return;
2118        };
2119        let Some(branch) = repo.read(cx).branch.as_ref() else {
2120            return;
2121        };
2122        telemetry::event!("Git Pulled");
2123        let branch = branch.clone();
2124        let remote = self.get_remote(false, window, cx);
2125        cx.spawn_in(window, async move |this, cx| {
2126            let remote = match remote.await {
2127                Ok(Some(remote)) => remote,
2128                Ok(None) => {
2129                    return Ok(());
2130                }
2131                Err(e) => {
2132                    log::error!("Failed to get current remote: {}", e);
2133                    this.update(cx, |this, cx| this.show_error_toast("pull", e, cx))
2134                        .ok();
2135                    return Ok(());
2136                }
2137            };
2138
2139            let askpass = this.update_in(cx, |this, window, cx| {
2140                this.askpass_delegate(format!("git pull {}", remote.name), window, cx)
2141            })?;
2142
2143            let pull = repo.update(cx, |repo, cx| {
2144                repo.pull(
2145                    branch.name().to_owned().into(),
2146                    remote.name.clone(),
2147                    askpass,
2148                    cx,
2149                )
2150            })?;
2151
2152            let remote_message = pull.await?;
2153
2154            let action = RemoteAction::Pull(remote);
2155            this.update(cx, |this, cx| match remote_message {
2156                Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
2157                Err(e) => {
2158                    log::error!("Error while pulling {:?}", e);
2159                    this.show_error_toast(action.name(), e, cx)
2160                }
2161            })
2162            .ok();
2163
2164            anyhow::Ok(())
2165        })
2166        .detach_and_log_err(cx);
2167    }
2168
2169    pub(crate) fn push(
2170        &mut self,
2171        force_push: bool,
2172        select_remote: bool,
2173        window: &mut Window,
2174        cx: &mut Context<Self>,
2175    ) {
2176        if !self.can_push_and_pull(cx) {
2177            return;
2178        }
2179        let Some(repo) = self.active_repository.clone() else {
2180            return;
2181        };
2182        let Some(branch) = repo.read(cx).branch.as_ref() else {
2183            return;
2184        };
2185        telemetry::event!("Git Pushed");
2186        let branch = branch.clone();
2187
2188        let options = if force_push {
2189            Some(PushOptions::Force)
2190        } else {
2191            match branch.upstream {
2192                Some(Upstream {
2193                    tracking: UpstreamTracking::Gone,
2194                    ..
2195                })
2196                | None => Some(PushOptions::SetUpstream),
2197                _ => None,
2198            }
2199        };
2200        let remote = self.get_remote(select_remote, window, cx);
2201
2202        cx.spawn_in(window, async move |this, cx| {
2203            let remote = match remote.await {
2204                Ok(Some(remote)) => remote,
2205                Ok(None) => {
2206                    return Ok(());
2207                }
2208                Err(e) => {
2209                    log::error!("Failed to get current remote: {}", e);
2210                    this.update(cx, |this, cx| this.show_error_toast("push", e, cx))
2211                        .ok();
2212                    return Ok(());
2213                }
2214            };
2215
2216            let askpass_delegate = this.update_in(cx, |this, window, cx| {
2217                this.askpass_delegate(format!("git push {}", remote.name), window, cx)
2218            })?;
2219
2220            let push = repo.update(cx, |repo, cx| {
2221                repo.push(
2222                    branch.name().to_owned().into(),
2223                    remote.name.clone(),
2224                    options,
2225                    askpass_delegate,
2226                    cx,
2227                )
2228            })?;
2229
2230            let remote_output = push.await?;
2231
2232            let action = RemoteAction::Push(branch.name().to_owned().into(), remote);
2233            this.update(cx, |this, cx| match remote_output {
2234                Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
2235                Err(e) => {
2236                    log::error!("Error while pushing {:?}", e);
2237                    this.show_error_toast(action.name(), e, cx)
2238                }
2239            })?;
2240
2241            anyhow::Ok(())
2242        })
2243        .detach_and_log_err(cx);
2244    }
2245
2246    fn askpass_delegate(
2247        &self,
2248        operation: impl Into<SharedString>,
2249        window: &mut Window,
2250        cx: &mut Context<Self>,
2251    ) -> AskPassDelegate {
2252        let this = cx.weak_entity();
2253        let operation = operation.into();
2254        let window = window.window_handle();
2255        AskPassDelegate::new(&mut cx.to_async(), move |prompt, tx, cx| {
2256            window
2257                .update(cx, |_, window, cx| {
2258                    this.update(cx, |this, cx| {
2259                        this.workspace.update(cx, |workspace, cx| {
2260                            workspace.toggle_modal(window, cx, |window, cx| {
2261                                AskPassModal::new(operation.clone(), prompt.into(), tx, window, cx)
2262                            });
2263                        })
2264                    })
2265                })
2266                .ok();
2267        })
2268    }
2269
2270    fn can_push_and_pull(&self, cx: &App) -> bool {
2271        !self.project.read(cx).is_via_collab()
2272    }
2273
2274    fn get_remote(
2275        &mut self,
2276        always_select: bool,
2277        window: &mut Window,
2278        cx: &mut Context<Self>,
2279    ) -> impl Future<Output = anyhow::Result<Option<Remote>>> + use<> {
2280        let repo = self.active_repository.clone();
2281        let workspace = self.workspace.clone();
2282        let mut cx = window.to_async(cx);
2283
2284        async move {
2285            let repo = repo.context("No active repository")?;
2286            let current_remotes: Vec<Remote> = repo
2287                .update(&mut cx, |repo, _| {
2288                    let current_branch = if always_select {
2289                        None
2290                    } else {
2291                        let current_branch = repo.branch.as_ref().context("No active branch")?;
2292                        Some(current_branch.name().to_string())
2293                    };
2294                    anyhow::Ok(repo.get_remotes(current_branch))
2295                })??
2296                .await??;
2297
2298            let current_remotes: Vec<_> = current_remotes
2299                .into_iter()
2300                .map(|remotes| remotes.name)
2301                .collect();
2302            let selection = cx
2303                .update(|window, cx| {
2304                    picker_prompt::prompt(
2305                        "Pick which remote to push to",
2306                        current_remotes.clone(),
2307                        workspace,
2308                        window,
2309                        cx,
2310                    )
2311                })?
2312                .await;
2313
2314            Ok(selection.map(|selection| Remote {
2315                name: current_remotes[selection].clone(),
2316            }))
2317        }
2318    }
2319
2320    pub fn load_local_committer(&mut self, cx: &Context<Self>) {
2321        if self.local_committer_task.is_none() {
2322            self.local_committer_task = Some(cx.spawn(async move |this, cx| {
2323                let committer = get_git_committer(cx).await;
2324                this.update(cx, |this, cx| {
2325                    this.local_committer = Some(committer);
2326                    cx.notify()
2327                })
2328                .ok();
2329            }));
2330        }
2331    }
2332
2333    fn potential_co_authors(&self, cx: &App) -> Vec<(String, String)> {
2334        let mut new_co_authors = Vec::new();
2335        let project = self.project.read(cx);
2336
2337        let Some(room) = self
2338            .workspace
2339            .upgrade()
2340            .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned())
2341        else {
2342            return Vec::default();
2343        };
2344
2345        let room = room.read(cx);
2346
2347        for (peer_id, collaborator) in project.collaborators() {
2348            if collaborator.is_host {
2349                continue;
2350            }
2351
2352            let Some(participant) = room.remote_participant_for_peer_id(*peer_id) else {
2353                continue;
2354            };
2355            if !participant.can_write() {
2356                continue;
2357            }
2358            if let Some(email) = &collaborator.committer_email {
2359                let name = collaborator
2360                    .committer_name
2361                    .clone()
2362                    .or_else(|| participant.user.name.clone())
2363                    .unwrap_or_else(|| participant.user.github_login.clone());
2364                new_co_authors.push((name.clone(), email.clone()))
2365            }
2366        }
2367        if !project.is_local() && !project.is_read_only(cx) {
2368            if let Some(local_committer) = self.local_committer(room, cx) {
2369                new_co_authors.push(local_committer);
2370            }
2371        }
2372        new_co_authors
2373    }
2374
2375    fn local_committer(&self, room: &call::Room, cx: &App) -> Option<(String, String)> {
2376        let user = room.local_participant_user(cx)?;
2377        let committer = self.local_committer.as_ref()?;
2378        let email = committer.email.clone()?;
2379        let name = committer
2380            .name
2381            .clone()
2382            .or_else(|| user.name.clone())
2383            .unwrap_or_else(|| user.github_login.clone());
2384        Some((name, email))
2385    }
2386
2387    fn toggle_fill_co_authors(
2388        &mut self,
2389        _: &ToggleFillCoAuthors,
2390        _: &mut Window,
2391        cx: &mut Context<Self>,
2392    ) {
2393        self.add_coauthors = !self.add_coauthors;
2394        cx.notify();
2395    }
2396
2397    fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context<Self>) {
2398        const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
2399
2400        let existing_text = message.to_ascii_lowercase();
2401        let lowercase_co_author_prefix = CO_AUTHOR_PREFIX.to_lowercase();
2402        let mut ends_with_co_authors = false;
2403        let existing_co_authors = existing_text
2404            .lines()
2405            .filter_map(|line| {
2406                let line = line.trim();
2407                if line.starts_with(&lowercase_co_author_prefix) {
2408                    ends_with_co_authors = true;
2409                    Some(line)
2410                } else {
2411                    ends_with_co_authors = false;
2412                    None
2413                }
2414            })
2415            .collect::<HashSet<_>>();
2416
2417        let new_co_authors = self
2418            .potential_co_authors(cx)
2419            .into_iter()
2420            .filter(|(_, email)| {
2421                !existing_co_authors
2422                    .iter()
2423                    .any(|existing| existing.contains(email.as_str()))
2424            })
2425            .collect::<Vec<_>>();
2426
2427        if new_co_authors.is_empty() {
2428            return;
2429        }
2430
2431        if !ends_with_co_authors {
2432            message.push('\n');
2433        }
2434        for (name, email) in new_co_authors {
2435            message.push('\n');
2436            message.push_str(CO_AUTHOR_PREFIX);
2437            message.push_str(&name);
2438            message.push_str(" <");
2439            message.push_str(&email);
2440            message.push('>');
2441        }
2442        message.push('\n');
2443    }
2444
2445    fn schedule_update(
2446        &mut self,
2447        clear_pending: bool,
2448        window: &mut Window,
2449        cx: &mut Context<Self>,
2450    ) {
2451        let handle = cx.entity().downgrade();
2452        self.reopen_commit_buffer(window, cx);
2453        self.update_visible_entries_task = cx.spawn_in(window, async move |_, cx| {
2454            cx.background_executor().timer(UPDATE_DEBOUNCE).await;
2455            if let Some(git_panel) = handle.upgrade() {
2456                git_panel
2457                    .update_in(cx, |git_panel, window, cx| {
2458                        if clear_pending {
2459                            git_panel.clear_pending();
2460                        }
2461                        git_panel.update_visible_entries(cx);
2462                        git_panel.update_scrollbar_properties(window, cx);
2463                    })
2464                    .ok();
2465            }
2466        });
2467    }
2468
2469    fn reopen_commit_buffer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2470        let Some(active_repo) = self.active_repository.as_ref() else {
2471            return;
2472        };
2473        let load_buffer = active_repo.update(cx, |active_repo, cx| {
2474            let project = self.project.read(cx);
2475            active_repo.open_commit_buffer(
2476                Some(project.languages().clone()),
2477                project.buffer_store().clone(),
2478                cx,
2479            )
2480        });
2481
2482        cx.spawn_in(window, async move |git_panel, cx| {
2483            let buffer = load_buffer.await?;
2484            git_panel.update_in(cx, |git_panel, window, cx| {
2485                if git_panel
2486                    .commit_editor
2487                    .read(cx)
2488                    .buffer()
2489                    .read(cx)
2490                    .as_singleton()
2491                    .as_ref()
2492                    != Some(&buffer)
2493                {
2494                    git_panel.commit_editor = cx.new(|cx| {
2495                        commit_message_editor(
2496                            buffer,
2497                            git_panel.suggest_commit_message(cx).map(SharedString::from),
2498                            git_panel.project.clone(),
2499                            true,
2500                            window,
2501                            cx,
2502                        )
2503                    });
2504                }
2505            })
2506        })
2507        .detach_and_log_err(cx);
2508    }
2509
2510    fn clear_pending(&mut self) {
2511        self.pending.retain(|v| !v.finished)
2512    }
2513
2514    fn update_visible_entries(&mut self, cx: &mut Context<Self>) {
2515        let bulk_staging = self.bulk_staging.take();
2516        let last_staged_path_prev_index = bulk_staging
2517            .as_ref()
2518            .and_then(|op| self.entry_by_path(&op.anchor, cx));
2519
2520        self.entries.clear();
2521        self.single_staged_entry.take();
2522        self.single_tracked_entry.take();
2523        self.conflicted_count = 0;
2524        self.conflicted_staged_count = 0;
2525        self.new_count = 0;
2526        self.tracked_count = 0;
2527        self.new_staged_count = 0;
2528        self.tracked_staged_count = 0;
2529        self.entry_count = 0;
2530
2531        let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
2532
2533        let mut changed_entries = Vec::new();
2534        let mut new_entries = Vec::new();
2535        let mut conflict_entries = Vec::new();
2536        let mut single_staged_entry = None;
2537        let mut staged_count = 0;
2538        let mut max_width_item: Option<(RepoPath, usize)> = None;
2539
2540        let Some(repo) = self.active_repository.as_ref() else {
2541            // Just clear entries if no repository is active.
2542            cx.notify();
2543            return;
2544        };
2545
2546        let repo = repo.read(cx);
2547
2548        for entry in repo.cached_status() {
2549            let is_conflict = repo.had_conflict_on_last_merge_head_change(&entry.repo_path);
2550            let is_new = entry.status.is_created();
2551            let staging = entry.status.staging();
2552
2553            if self.pending.iter().any(|pending| {
2554                pending.target_status == TargetStatus::Reverted
2555                    && !pending.finished
2556                    && pending
2557                        .entries
2558                        .iter()
2559                        .any(|pending| pending.repo_path == entry.repo_path)
2560            }) {
2561                continue;
2562            }
2563
2564            let abs_path = repo.work_directory_abs_path.join(&entry.repo_path.0);
2565            let entry = GitStatusEntry {
2566                repo_path: entry.repo_path.clone(),
2567                abs_path,
2568                status: entry.status,
2569                staging,
2570            };
2571
2572            if staging.has_staged() {
2573                staged_count += 1;
2574                single_staged_entry = Some(entry.clone());
2575            }
2576
2577            let width_estimate = Self::item_width_estimate(
2578                entry.parent_dir().map(|s| s.len()).unwrap_or(0),
2579                entry.display_name().len(),
2580            );
2581
2582            match max_width_item.as_mut() {
2583                Some((repo_path, estimate)) => {
2584                    if width_estimate > *estimate {
2585                        *repo_path = entry.repo_path.clone();
2586                        *estimate = width_estimate;
2587                    }
2588                }
2589                None => max_width_item = Some((entry.repo_path.clone(), width_estimate)),
2590            }
2591
2592            if sort_by_path {
2593                changed_entries.push(entry);
2594            } else if is_conflict {
2595                conflict_entries.push(entry);
2596            } else if is_new {
2597                new_entries.push(entry);
2598            } else {
2599                changed_entries.push(entry);
2600            }
2601        }
2602
2603        let mut pending_staged_count = 0;
2604        let mut last_pending_staged = None;
2605        let mut pending_status_for_single_staged = None;
2606        for pending in self.pending.iter() {
2607            if pending.target_status == TargetStatus::Staged {
2608                pending_staged_count += pending.entries.len();
2609                last_pending_staged = pending.entries.iter().next().cloned();
2610            }
2611            if let Some(single_staged) = &single_staged_entry {
2612                if pending
2613                    .entries
2614                    .iter()
2615                    .any(|entry| entry.repo_path == single_staged.repo_path)
2616                {
2617                    pending_status_for_single_staged = Some(pending.target_status);
2618                }
2619            }
2620        }
2621
2622        if conflict_entries.len() == 0 && staged_count == 1 && pending_staged_count == 0 {
2623            match pending_status_for_single_staged {
2624                Some(TargetStatus::Staged) | None => {
2625                    self.single_staged_entry = single_staged_entry;
2626                }
2627                _ => {}
2628            }
2629        } else if conflict_entries.len() == 0 && pending_staged_count == 1 {
2630            self.single_staged_entry = last_pending_staged;
2631        }
2632
2633        if conflict_entries.len() == 0 && changed_entries.len() == 1 {
2634            self.single_tracked_entry = changed_entries.first().cloned();
2635        }
2636
2637        if conflict_entries.len() > 0 {
2638            self.entries.push(GitListEntry::Header(GitHeaderEntry {
2639                header: Section::Conflict,
2640            }));
2641            self.entries
2642                .extend(conflict_entries.into_iter().map(GitListEntry::Status));
2643        }
2644
2645        if changed_entries.len() > 0 {
2646            if !sort_by_path {
2647                self.entries.push(GitListEntry::Header(GitHeaderEntry {
2648                    header: Section::Tracked,
2649                }));
2650            }
2651            self.entries
2652                .extend(changed_entries.into_iter().map(GitListEntry::Status));
2653        }
2654        if new_entries.len() > 0 {
2655            self.entries.push(GitListEntry::Header(GitHeaderEntry {
2656                header: Section::New,
2657            }));
2658            self.entries
2659                .extend(new_entries.into_iter().map(GitListEntry::Status));
2660        }
2661
2662        if let Some((repo_path, _)) = max_width_item {
2663            self.max_width_item_index = self.entries.iter().position(|entry| match entry {
2664                GitListEntry::Status(git_status_entry) => git_status_entry.repo_path == repo_path,
2665                GitListEntry::Header(_) => false,
2666            });
2667        }
2668
2669        self.update_counts(repo);
2670
2671        let bulk_staging_anchor_new_index = bulk_staging
2672            .as_ref()
2673            .filter(|op| op.repo_id == repo.id)
2674            .and_then(|op| self.entry_by_path(&op.anchor, cx));
2675        if bulk_staging_anchor_new_index == last_staged_path_prev_index
2676            && let Some(index) = bulk_staging_anchor_new_index
2677            && let Some(entry) = self.entries.get(index)
2678            && let Some(entry) = entry.status_entry()
2679            && self.entry_staging(entry) == StageStatus::Staged
2680        {
2681            self.bulk_staging = bulk_staging;
2682        }
2683
2684        self.select_first_entry_if_none(cx);
2685
2686        let suggested_commit_message = self.suggest_commit_message(cx);
2687        let placeholder_text = suggested_commit_message.unwrap_or("Enter commit message".into());
2688
2689        self.commit_editor.update(cx, |editor, cx| {
2690            editor.set_placeholder_text(Arc::from(placeholder_text), cx)
2691        });
2692
2693        cx.notify();
2694    }
2695
2696    fn header_state(&self, header_type: Section) -> ToggleState {
2697        let (staged_count, count) = match header_type {
2698            Section::New => (self.new_staged_count, self.new_count),
2699            Section::Tracked => (self.tracked_staged_count, self.tracked_count),
2700            Section::Conflict => (self.conflicted_staged_count, self.conflicted_count),
2701        };
2702        if staged_count == 0 {
2703            ToggleState::Unselected
2704        } else if count == staged_count {
2705            ToggleState::Selected
2706        } else {
2707            ToggleState::Indeterminate
2708        }
2709    }
2710
2711    fn update_counts(&mut self, repo: &Repository) {
2712        self.show_placeholders = false;
2713        self.conflicted_count = 0;
2714        self.conflicted_staged_count = 0;
2715        self.new_count = 0;
2716        self.tracked_count = 0;
2717        self.new_staged_count = 0;
2718        self.tracked_staged_count = 0;
2719        self.entry_count = 0;
2720        for entry in &self.entries {
2721            let Some(status_entry) = entry.status_entry() else {
2722                continue;
2723            };
2724            self.entry_count += 1;
2725            if repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path) {
2726                self.conflicted_count += 1;
2727                if self.entry_staging(status_entry).has_staged() {
2728                    self.conflicted_staged_count += 1;
2729                }
2730            } else if status_entry.status.is_created() {
2731                self.new_count += 1;
2732                if self.entry_staging(status_entry).has_staged() {
2733                    self.new_staged_count += 1;
2734                }
2735            } else {
2736                self.tracked_count += 1;
2737                if self.entry_staging(status_entry).has_staged() {
2738                    self.tracked_staged_count += 1;
2739                }
2740            }
2741        }
2742    }
2743
2744    fn entry_staging(&self, entry: &GitStatusEntry) -> StageStatus {
2745        for pending in self.pending.iter().rev() {
2746            if pending
2747                .entries
2748                .iter()
2749                .any(|pending_entry| pending_entry.repo_path == entry.repo_path)
2750            {
2751                match pending.target_status {
2752                    TargetStatus::Staged => return StageStatus::Staged,
2753                    TargetStatus::Unstaged => return StageStatus::Unstaged,
2754                    TargetStatus::Reverted => continue,
2755                    TargetStatus::Unchanged => continue,
2756                }
2757            }
2758        }
2759        entry.staging
2760    }
2761
2762    pub(crate) fn has_staged_changes(&self) -> bool {
2763        self.tracked_staged_count > 0
2764            || self.new_staged_count > 0
2765            || self.conflicted_staged_count > 0
2766    }
2767
2768    pub(crate) fn has_unstaged_changes(&self) -> bool {
2769        self.tracked_count > self.tracked_staged_count
2770            || self.new_count > self.new_staged_count
2771            || self.conflicted_count > self.conflicted_staged_count
2772    }
2773
2774    fn has_tracked_changes(&self) -> bool {
2775        self.tracked_count > 0
2776    }
2777
2778    pub fn has_unstaged_conflicts(&self) -> bool {
2779        self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count
2780    }
2781
2782    fn show_error_toast(&self, action: impl Into<SharedString>, e: anyhow::Error, cx: &mut App) {
2783        let action = action.into();
2784        let Some(workspace) = self.workspace.upgrade() else {
2785            return;
2786        };
2787
2788        let message = e.to_string().trim().to_string();
2789        if message
2790            .matches(git::repository::REMOTE_CANCELLED_BY_USER)
2791            .next()
2792            .is_some()
2793        {
2794            return; // Hide the cancelled by user message
2795        } else {
2796            workspace.update(cx, |workspace, cx| {
2797                let workspace_weak = cx.weak_entity();
2798                let toast = StatusToast::new(format!("git {} failed", action), cx, |this, _cx| {
2799                    this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
2800                        .action("View Log", move |window, cx| {
2801                            let message = message.clone();
2802                            let action = action.clone();
2803                            workspace_weak
2804                                .update(cx, move |workspace, cx| {
2805                                    Self::open_output(action, workspace, &message, window, cx)
2806                                })
2807                                .ok();
2808                        })
2809                });
2810                workspace.toggle_status_toast(toast, cx)
2811            });
2812        }
2813    }
2814
2815    fn show_commit_message_error<E>(weak_this: &WeakEntity<Self>, err: &E, cx: &mut AsyncApp)
2816    where
2817        E: std::fmt::Debug + std::fmt::Display,
2818    {
2819        if let Ok(Some(workspace)) = weak_this.update(cx, |this, _cx| this.workspace.upgrade()) {
2820            let _ = workspace.update(cx, |workspace, cx| {
2821                struct CommitMessageError;
2822                let notification_id = NotificationId::unique::<CommitMessageError>();
2823                workspace.show_notification(notification_id, cx, |cx| {
2824                    cx.new(|cx| {
2825                        ErrorMessagePrompt::new(
2826                            format!("Failed to generate commit message: {err}"),
2827                            cx,
2828                        )
2829                    })
2830                });
2831            });
2832        }
2833    }
2834
2835    fn show_remote_output(&self, action: RemoteAction, info: RemoteCommandOutput, cx: &mut App) {
2836        let Some(workspace) = self.workspace.upgrade() else {
2837            return;
2838        };
2839
2840        workspace.update(cx, |workspace, cx| {
2841            let SuccessMessage { message, style } = remote_output::format_output(&action, info);
2842            let workspace_weak = cx.weak_entity();
2843            let operation = action.name();
2844
2845            let status_toast = StatusToast::new(message, cx, move |this, _cx| {
2846                use remote_output::SuccessStyle::*;
2847                match style {
2848                    Toast { .. } => this,
2849                    ToastWithLog { output } => this
2850                        .icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted))
2851                        .action("View Log", move |window, cx| {
2852                            let output = output.clone();
2853                            let output =
2854                                format!("stdout:\n{}\nstderr:\n{}", output.stdout, output.stderr);
2855                            workspace_weak
2856                                .update(cx, move |workspace, cx| {
2857                                    Self::open_output(operation, workspace, &output, window, cx)
2858                                })
2859                                .ok();
2860                        }),
2861                    PushPrLink { link } => this
2862                        .icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted))
2863                        .action("Open Pull Request", move |_, cx| cx.open_url(&link)),
2864                }
2865            });
2866            workspace.toggle_status_toast(status_toast, cx)
2867        });
2868    }
2869
2870    fn open_output(
2871        operation: impl Into<SharedString>,
2872        workspace: &mut Workspace,
2873        output: &str,
2874        window: &mut Window,
2875        cx: &mut Context<Workspace>,
2876    ) {
2877        let operation = operation.into();
2878        let buffer = cx.new(|cx| Buffer::local(output, cx));
2879        buffer.update(cx, |buffer, cx| {
2880            buffer.set_capability(language::Capability::ReadOnly, cx);
2881        });
2882        let editor = cx.new(|cx| {
2883            let mut editor = Editor::for_buffer(buffer, None, window, cx);
2884            editor.buffer().update(cx, |buffer, cx| {
2885                buffer.set_title(format!("Output from git {operation}"), cx);
2886            });
2887            editor.set_read_only(true);
2888            editor
2889        });
2890
2891        workspace.add_item_to_center(Box::new(editor), window, cx);
2892    }
2893
2894    pub fn can_commit(&self) -> bool {
2895        (self.has_staged_changes() || self.has_tracked_changes()) && !self.has_unstaged_conflicts()
2896    }
2897
2898    pub fn can_stage_all(&self) -> bool {
2899        self.has_unstaged_changes()
2900    }
2901
2902    pub fn can_unstage_all(&self) -> bool {
2903        self.has_staged_changes()
2904    }
2905
2906    // eventually we'll need to take depth into account here
2907    // if we add a tree view
2908    fn item_width_estimate(path: usize, file_name: usize) -> usize {
2909        path + file_name
2910    }
2911
2912    fn render_overflow_menu(&self, id: impl Into<ElementId>) -> impl IntoElement {
2913        let focus_handle = self.focus_handle.clone();
2914        let has_tracked_changes = self.has_tracked_changes();
2915        let has_staged_changes = self.has_staged_changes();
2916        let has_unstaged_changes = self.has_unstaged_changes();
2917        let has_new_changes = self.new_count > 0;
2918
2919        PopoverMenu::new(id.into())
2920            .trigger(
2921                IconButton::new("overflow-menu-trigger", IconName::Ellipsis)
2922                    .icon_size(IconSize::Small)
2923                    .icon_color(Color::Muted),
2924            )
2925            .menu(move |window, cx| {
2926                Some(git_panel_context_menu(
2927                    focus_handle.clone(),
2928                    GitMenuState {
2929                        has_tracked_changes,
2930                        has_staged_changes,
2931                        has_unstaged_changes,
2932                        has_new_changes,
2933                    },
2934                    window,
2935                    cx,
2936                ))
2937            })
2938            .anchor(Corner::TopRight)
2939    }
2940
2941    pub(crate) fn render_generate_commit_message_button(
2942        &self,
2943        cx: &Context<Self>,
2944    ) -> Option<AnyElement> {
2945        current_language_model(cx).is_some().then(|| {
2946            if self.generate_commit_message_task.is_some() {
2947                return h_flex()
2948                    .gap_1()
2949                    .child(
2950                        Icon::new(IconName::ArrowCircle)
2951                            .size(IconSize::XSmall)
2952                            .color(Color::Info)
2953                            .with_animation(
2954                                "arrow-circle",
2955                                Animation::new(Duration::from_secs(2)).repeat(),
2956                                |icon, delta| {
2957                                    icon.transform(Transformation::rotate(percentage(delta)))
2958                                },
2959                            ),
2960                    )
2961                    .child(
2962                        Label::new("Generating Commit...")
2963                            .size(LabelSize::Small)
2964                            .color(Color::Muted),
2965                    )
2966                    .into_any_element();
2967            }
2968
2969            let can_commit = self.can_commit();
2970            let editor_focus_handle = self.commit_editor.focus_handle(cx);
2971            IconButton::new("generate-commit-message", IconName::AiEdit)
2972                .shape(ui::IconButtonShape::Square)
2973                .icon_color(Color::Muted)
2974                .tooltip(move |window, cx| {
2975                    if can_commit {
2976                        Tooltip::for_action_in(
2977                            "Generate Commit Message",
2978                            &git::GenerateCommitMessage,
2979                            &editor_focus_handle,
2980                            window,
2981                            cx,
2982                        )
2983                    } else {
2984                        Tooltip::simple("No changes to commit", cx)
2985                    }
2986                })
2987                .disabled(!can_commit)
2988                .on_click(cx.listener(move |this, _event, _window, cx| {
2989                    this.generate_commit_message(cx);
2990                }))
2991                .into_any_element()
2992        })
2993    }
2994
2995    pub(crate) fn render_co_authors(&self, cx: &Context<Self>) -> Option<AnyElement> {
2996        let potential_co_authors = self.potential_co_authors(cx);
2997
2998        let (tooltip_label, icon) = if self.add_coauthors {
2999            ("Remove co-authored-by", IconName::Person)
3000        } else {
3001            ("Add co-authored-by", IconName::UserCheck)
3002        };
3003
3004        if potential_co_authors.is_empty() {
3005            None
3006        } else {
3007            Some(
3008                IconButton::new("co-authors", icon)
3009                    .shape(ui::IconButtonShape::Square)
3010                    .icon_color(Color::Disabled)
3011                    .selected_icon_color(Color::Selected)
3012                    .toggle_state(self.add_coauthors)
3013                    .tooltip(move |_, cx| {
3014                        let title = format!(
3015                            "{}:{}{}",
3016                            tooltip_label,
3017                            if potential_co_authors.len() == 1 {
3018                                ""
3019                            } else {
3020                                "\n"
3021                            },
3022                            potential_co_authors
3023                                .iter()
3024                                .map(|(name, email)| format!(" {} <{}>", name, email))
3025                                .join("\n")
3026                        );
3027                        Tooltip::simple(title, cx)
3028                    })
3029                    .on_click(cx.listener(|this, _, _, cx| {
3030                        this.add_coauthors = !this.add_coauthors;
3031                        cx.notify();
3032                    }))
3033                    .into_any_element(),
3034            )
3035        }
3036    }
3037
3038    fn render_git_commit_menu(
3039        &self,
3040        id: impl Into<ElementId>,
3041        keybinding_target: Option<FocusHandle>,
3042        cx: &mut Context<Self>,
3043    ) -> impl IntoElement {
3044        PopoverMenu::new(id.into())
3045            .trigger(
3046                ui::ButtonLike::new_rounded_right("commit-split-button-right")
3047                    .layer(ui::ElevationIndex::ModalSurface)
3048                    .size(ButtonSize::None)
3049                    .child(
3050                        h_flex()
3051                            .px_1()
3052                            .h_full()
3053                            .justify_center()
3054                            .border_l_1()
3055                            .border_color(cx.theme().colors().border)
3056                            .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)),
3057                    ),
3058            )
3059            .menu({
3060                let has_previous_commit = self.head_commit(cx).is_some();
3061                let amend = self.amend_pending();
3062                let signoff = self.signoff_enabled;
3063
3064                move |window, cx| {
3065                    Some(ContextMenu::build(window, cx, |context_menu, _, _| {
3066                        context_menu
3067                            .when_some(keybinding_target.clone(), |el, keybinding_target| {
3068                                el.context(keybinding_target.clone())
3069                            })
3070                            .when(has_previous_commit, |this| {
3071                                this.toggleable_entry(
3072                                    "Amend",
3073                                    amend,
3074                                    IconPosition::Start,
3075                                    Some(Box::new(Amend)),
3076                                    move |window, cx| window.dispatch_action(Box::new(Amend), cx),
3077                                )
3078                            })
3079                            .toggleable_entry(
3080                                "Signoff",
3081                                signoff,
3082                                IconPosition::Start,
3083                                Some(Box::new(Signoff)),
3084                                move |window, cx| window.dispatch_action(Box::new(Signoff), cx),
3085                            )
3086                    }))
3087                }
3088            })
3089            .anchor(Corner::TopRight)
3090    }
3091
3092    pub fn configure_commit_button(&self, cx: &mut Context<Self>) -> (bool, &'static str) {
3093        if self.has_unstaged_conflicts() {
3094            (false, "You must resolve conflicts before committing")
3095        } else if !self.has_staged_changes() && !self.has_tracked_changes() {
3096            (false, "No changes to commit")
3097        } else if self.pending_commit.is_some() {
3098            (false, "Commit in progress")
3099        } else if !self.has_commit_message(cx) {
3100            (false, "No commit message")
3101        } else if !self.has_write_access(cx) {
3102            (false, "You do not have write access to this project")
3103        } else {
3104            (true, self.commit_button_title())
3105        }
3106    }
3107
3108    pub fn commit_button_title(&self) -> &'static str {
3109        if self.amend_pending {
3110            if self.has_staged_changes() {
3111                "Amend"
3112            } else {
3113                "Amend Tracked"
3114            }
3115        } else {
3116            if self.has_staged_changes() {
3117                "Commit"
3118            } else {
3119                "Commit Tracked"
3120            }
3121        }
3122    }
3123
3124    fn expand_commit_editor(
3125        &mut self,
3126        _: &git::ExpandCommitEditor,
3127        window: &mut Window,
3128        cx: &mut Context<Self>,
3129    ) {
3130        let workspace = self.workspace.clone();
3131        window.defer(cx, move |window, cx| {
3132            workspace
3133                .update(cx, |workspace, cx| {
3134                    CommitModal::toggle(workspace, None, window, cx)
3135                })
3136                .ok();
3137        })
3138    }
3139
3140    fn render_panel_header(
3141        &self,
3142        window: &mut Window,
3143        cx: &mut Context<Self>,
3144    ) -> Option<impl IntoElement> {
3145        self.active_repository.as_ref()?;
3146
3147        let text;
3148        let action;
3149        let tooltip;
3150        if self.total_staged_count() == self.entry_count && self.entry_count > 0 {
3151            text = "Unstage All";
3152            action = git::UnstageAll.boxed_clone();
3153            tooltip = "git reset";
3154        } else {
3155            text = "Stage All";
3156            action = git::StageAll.boxed_clone();
3157            tooltip = "git add --all ."
3158        }
3159
3160        let change_string = match self.entry_count {
3161            0 => "No Changes".to_string(),
3162            1 => "1 Change".to_string(),
3163            _ => format!("{} Changes", self.entry_count),
3164        };
3165
3166        Some(
3167            self.panel_header_container(window, cx)
3168                .px_2()
3169                .justify_between()
3170                .child(
3171                    panel_button(change_string)
3172                        .color(Color::Muted)
3173                        .tooltip(Tooltip::for_action_title_in(
3174                            "Open Diff",
3175                            &Diff,
3176                            &self.focus_handle,
3177                        ))
3178                        .on_click(|_, _, cx| {
3179                            cx.defer(|cx| {
3180                                cx.dispatch_action(&Diff);
3181                            })
3182                        }),
3183                )
3184                .child(
3185                    h_flex()
3186                        .gap_1()
3187                        .child(self.render_overflow_menu("overflow_menu"))
3188                        .child(
3189                            panel_filled_button(text)
3190                                .tooltip(Tooltip::for_action_title_in(
3191                                    tooltip,
3192                                    action.as_ref(),
3193                                    &self.focus_handle,
3194                                ))
3195                                .disabled(self.entry_count == 0)
3196                                .on_click(move |_, _, cx| {
3197                                    let action = action.boxed_clone();
3198                                    cx.defer(move |cx| {
3199                                        cx.dispatch_action(action.as_ref());
3200                                    })
3201                                }),
3202                        ),
3203                ),
3204        )
3205    }
3206
3207    pub(crate) fn render_remote_button(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
3208        let branch = self.active_repository.as_ref()?.read(cx).branch.clone();
3209        if !self.can_push_and_pull(cx) {
3210            return None;
3211        }
3212        Some(
3213            h_flex()
3214                .gap_1()
3215                .flex_shrink_0()
3216                .when_some(branch, |this, branch| {
3217                    let focus_handle = Some(self.focus_handle(cx));
3218
3219                    this.children(render_remote_button(
3220                        "remote-button",
3221                        &branch,
3222                        focus_handle,
3223                        true,
3224                    ))
3225                })
3226                .into_any_element(),
3227        )
3228    }
3229
3230    pub fn render_footer(
3231        &self,
3232        window: &mut Window,
3233        cx: &mut Context<Self>,
3234    ) -> Option<impl IntoElement> {
3235        let active_repository = self.active_repository.clone()?;
3236        let panel_editor_style = panel_editor_style(true, window, cx);
3237
3238        let enable_coauthors = self.render_co_authors(cx);
3239
3240        let editor_focus_handle = self.commit_editor.focus_handle(cx);
3241        let expand_tooltip_focus_handle = editor_focus_handle.clone();
3242
3243        let branch = active_repository.read(cx).branch.clone();
3244        let head_commit = active_repository.read(cx).head_commit.clone();
3245
3246        let footer_size = px(32.);
3247        let gap = px(9.0);
3248        let max_height = panel_editor_style
3249            .text
3250            .line_height_in_pixels(window.rem_size())
3251            * MAX_PANEL_EDITOR_LINES
3252            + gap;
3253
3254        let git_panel = cx.entity().clone();
3255        let display_name = SharedString::from(Arc::from(
3256            active_repository
3257                .read(cx)
3258                .display_name()
3259                .trim_end_matches("/"),
3260        ));
3261        let editor_is_long = self.commit_editor.update(cx, |editor, cx| {
3262            editor.max_point(cx).row().0 >= MAX_PANEL_EDITOR_LINES as u32
3263        });
3264
3265        let footer = v_flex()
3266            .child(PanelRepoFooter::new(
3267                display_name,
3268                branch,
3269                head_commit,
3270                Some(git_panel.clone()),
3271            ))
3272            .child(
3273                panel_editor_container(window, cx)
3274                    .id("commit-editor-container")
3275                    .relative()
3276                    .w_full()
3277                    .h(max_height + footer_size)
3278                    .border_t_1()
3279                    .border_color(cx.theme().colors().border)
3280                    .cursor_text()
3281                    .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
3282                        window.focus(&this.commit_editor.focus_handle(cx));
3283                    }))
3284                    .child(
3285                        h_flex()
3286                            .id("commit-footer")
3287                            .border_t_1()
3288                            .when(editor_is_long, |el| {
3289                                el.border_color(cx.theme().colors().border_variant)
3290                            })
3291                            .absolute()
3292                            .bottom_0()
3293                            .left_0()
3294                            .w_full()
3295                            .px_2()
3296                            .h(footer_size)
3297                            .flex_none()
3298                            .justify_between()
3299                            .child(
3300                                self.render_generate_commit_message_button(cx)
3301                                    .unwrap_or_else(|| div().into_any_element()),
3302                            )
3303                            .child(
3304                                h_flex()
3305                                    .gap_0p5()
3306                                    .children(enable_coauthors)
3307                                    .child(self.render_commit_button(cx)),
3308                            ),
3309                    )
3310                    .child(
3311                        div()
3312                            .pr_2p5()
3313                            .on_action(|&editor::actions::MoveUp, _, cx| {
3314                                cx.stop_propagation();
3315                            })
3316                            .on_action(|&editor::actions::MoveDown, _, cx| {
3317                                cx.stop_propagation();
3318                            })
3319                            .child(EditorElement::new(&self.commit_editor, panel_editor_style)),
3320                    )
3321                    .child(
3322                        h_flex()
3323                            .absolute()
3324                            .top_2()
3325                            .right_2()
3326                            .opacity(0.5)
3327                            .hover(|this| this.opacity(1.0))
3328                            .child(
3329                                panel_icon_button("expand-commit-editor", IconName::Maximize)
3330                                    .icon_size(IconSize::Small)
3331                                    .size(ui::ButtonSize::Default)
3332                                    .tooltip(move |window, cx| {
3333                                        Tooltip::for_action_in(
3334                                            "Open Commit Modal",
3335                                            &git::ExpandCommitEditor,
3336                                            &expand_tooltip_focus_handle,
3337                                            window,
3338                                            cx,
3339                                        )
3340                                    })
3341                                    .on_click(cx.listener({
3342                                        move |_, _, window, cx| {
3343                                            window.dispatch_action(
3344                                                git::ExpandCommitEditor.boxed_clone(),
3345                                                cx,
3346                                            )
3347                                        }
3348                                    })),
3349                            ),
3350                    ),
3351            );
3352
3353        Some(footer)
3354    }
3355
3356    fn render_commit_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3357        let (can_commit, tooltip) = self.configure_commit_button(cx);
3358        let title = self.commit_button_title();
3359        let commit_tooltip_focus_handle = self.commit_editor.focus_handle(cx);
3360        let amend = self.amend_pending();
3361        let signoff = self.signoff_enabled;
3362
3363        div()
3364            .id("commit-wrapper")
3365            .on_hover(cx.listener(move |this, hovered, _, cx| {
3366                this.show_placeholders =
3367                    *hovered && !this.has_staged_changes() && !this.has_unstaged_conflicts();
3368                cx.notify()
3369            }))
3370            .child(SplitButton::new(
3371                ui::ButtonLike::new_rounded_left(ElementId::Name(
3372                    format!("split-button-left-{}", title).into(),
3373                ))
3374                .layer(ui::ElevationIndex::ModalSurface)
3375                .size(ui::ButtonSize::Compact)
3376                .child(
3377                    div()
3378                        .child(Label::new(title).size(LabelSize::Small))
3379                        .mr_0p5(),
3380                )
3381                .on_click({
3382                    let git_panel = cx.weak_entity();
3383                    move |_, window, cx| {
3384                        telemetry::event!("Git Committed", source = "Git Panel");
3385                        git_panel
3386                            .update(cx, |git_panel, cx| {
3387                                git_panel.set_amend_pending(false, cx);
3388                                git_panel.commit_changes(
3389                                    CommitOptions { amend, signoff },
3390                                    window,
3391                                    cx,
3392                                );
3393                            })
3394                            .ok();
3395                    }
3396                })
3397                .disabled(!can_commit || self.modal_open)
3398                .tooltip({
3399                    let handle = commit_tooltip_focus_handle.clone();
3400                    move |window, cx| {
3401                        if can_commit {
3402                            Tooltip::with_meta_in(
3403                                tooltip,
3404                                Some(&git::Commit),
3405                                format!(
3406                                    "git commit{}{}",
3407                                    if amend { " --amend" } else { "" },
3408                                    if signoff { " --signoff" } else { "" }
3409                                ),
3410                                &handle.clone(),
3411                                window,
3412                                cx,
3413                            )
3414                        } else {
3415                            Tooltip::simple(tooltip, cx)
3416                        }
3417                    }
3418                }),
3419                self.render_git_commit_menu(
3420                    ElementId::Name(format!("split-button-right-{}", title).into()),
3421                    Some(commit_tooltip_focus_handle.clone()),
3422                    cx,
3423                )
3424                .into_any_element(),
3425            ))
3426    }
3427
3428    fn render_pending_amend(&self, cx: &mut Context<Self>) -> impl IntoElement {
3429        h_flex()
3430            .py_1p5()
3431            .px_2()
3432            .gap_1p5()
3433            .justify_between()
3434            .border_t_1()
3435            .border_color(cx.theme().colors().border.opacity(0.8))
3436            .child(
3437                div()
3438                    .flex_grow()
3439                    .overflow_hidden()
3440                    .max_w(relative(0.85))
3441                    .child(
3442                        Label::new("This will update your most recent commit.")
3443                            .size(LabelSize::Small)
3444                            .truncate(),
3445                    ),
3446            )
3447            .child(panel_button("Cancel").size(ButtonSize::Default).on_click(
3448                cx.listener(|this, _, window, cx| this.toggle_amend_pending(&Amend, window, cx)),
3449            ))
3450    }
3451
3452    fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
3453        let active_repository = self.active_repository.as_ref()?;
3454        let branch = active_repository.read(cx).branch.as_ref()?;
3455        let commit = branch.most_recent_commit.as_ref()?.clone();
3456        let workspace = self.workspace.clone();
3457        let this = cx.entity();
3458
3459        Some(
3460            h_flex()
3461                .py_1p5()
3462                .px_2()
3463                .gap_1p5()
3464                .justify_between()
3465                .border_t_1()
3466                .border_color(cx.theme().colors().border.opacity(0.8))
3467                .child(
3468                    div()
3469                        .flex_grow()
3470                        .overflow_hidden()
3471                        .max_w(relative(0.85))
3472                        .child(
3473                            Label::new(commit.subject.clone())
3474                                .size(LabelSize::Small)
3475                                .truncate(),
3476                        )
3477                        .id("commit-msg-hover")
3478                        .on_click({
3479                            let commit = commit.clone();
3480                            let repo = active_repository.downgrade();
3481                            move |_, window, cx| {
3482                                CommitView::open(
3483                                    commit.clone(),
3484                                    repo.clone(),
3485                                    workspace.clone().clone(),
3486                                    window,
3487                                    cx,
3488                                );
3489                            }
3490                        })
3491                        .hoverable_tooltip({
3492                            let repo = active_repository.clone();
3493                            move |window, cx| {
3494                                GitPanelMessageTooltip::new(
3495                                    this.clone(),
3496                                    commit.sha.clone(),
3497                                    repo.clone(),
3498                                    window,
3499                                    cx,
3500                                )
3501                                .into()
3502                            }
3503                        }),
3504                )
3505                .when(commit.has_parent, |this| {
3506                    let has_unstaged = self.has_unstaged_changes();
3507                    this.child(
3508                        panel_icon_button("undo", IconName::Undo)
3509                            .icon_size(IconSize::XSmall)
3510                            .icon_color(Color::Muted)
3511                            .tooltip(move |window, cx| {
3512                                Tooltip::with_meta(
3513                                    "Uncommit",
3514                                    Some(&git::Uncommit),
3515                                    if has_unstaged {
3516                                        "git reset HEAD^ --soft"
3517                                    } else {
3518                                        "git reset HEAD^"
3519                                    },
3520                                    window,
3521                                    cx,
3522                                )
3523                            })
3524                            .on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))),
3525                    )
3526                }),
3527        )
3528    }
3529
3530    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
3531        h_flex().h_full().flex_grow().justify_center().child(
3532            v_flex()
3533                .gap_2()
3534                .child(h_flex().w_full().justify_around().child(
3535                    if self.active_repository.is_some() {
3536                        "No changes to commit"
3537                    } else {
3538                        "No Git repositories"
3539                    },
3540                ))
3541                .children({
3542                    let worktree_count = self.project.read(cx).visible_worktrees(cx).count();
3543                    (worktree_count > 0 && self.active_repository.is_none()).then(|| {
3544                        h_flex().w_full().justify_around().child(
3545                            panel_filled_button("Initialize Repository")
3546                                .tooltip(Tooltip::for_action_title_in(
3547                                    "git init",
3548                                    &git::Init,
3549                                    &self.focus_handle,
3550                                ))
3551                                .on_click(move |_, _, cx| {
3552                                    cx.defer(move |cx| {
3553                                        cx.dispatch_action(&git::Init);
3554                                    })
3555                                }),
3556                        )
3557                    })
3558                })
3559                .text_ui_sm(cx)
3560                .mx_auto()
3561                .text_color(Color::Placeholder.color(cx)),
3562        )
3563    }
3564
3565    fn render_vertical_scrollbar(
3566        &self,
3567        show_horizontal_scrollbar_container: bool,
3568        cx: &mut Context<Self>,
3569    ) -> impl IntoElement {
3570        div()
3571            .id("git-panel-vertical-scroll")
3572            .occlude()
3573            .flex_none()
3574            .h_full()
3575            .cursor_default()
3576            .absolute()
3577            .right_0()
3578            .top_0()
3579            .bottom_0()
3580            .w(px(12.))
3581            .when(show_horizontal_scrollbar_container, |this| {
3582                this.pb_neg_3p5()
3583            })
3584            .on_mouse_move(cx.listener(|_, _, _, cx| {
3585                cx.notify();
3586                cx.stop_propagation()
3587            }))
3588            .on_hover(|_, _, cx| {
3589                cx.stop_propagation();
3590            })
3591            .on_any_mouse_down(|_, _, cx| {
3592                cx.stop_propagation();
3593            })
3594            .on_mouse_up(
3595                MouseButton::Left,
3596                cx.listener(|this, _, window, cx| {
3597                    if !this.vertical_scrollbar.state.is_dragging()
3598                        && !this.focus_handle.contains_focused(window, cx)
3599                    {
3600                        this.vertical_scrollbar.hide(window, cx);
3601                        cx.notify();
3602                    }
3603
3604                    cx.stop_propagation();
3605                }),
3606            )
3607            .on_scroll_wheel(cx.listener(|_, _, _, cx| {
3608                cx.notify();
3609            }))
3610            .children(Scrollbar::vertical(
3611                // percentage as f32..end_offset as f32,
3612                self.vertical_scrollbar.state.clone(),
3613            ))
3614    }
3615
3616    /// Renders the horizontal scrollbar.
3617    ///
3618    /// The right offset is used to determine how far to the right the
3619    /// scrollbar should extend to, useful for ensuring it doesn't collide
3620    /// with the vertical scrollbar when visible.
3621    fn render_horizontal_scrollbar(
3622        &self,
3623        right_offset: Pixels,
3624        cx: &mut Context<Self>,
3625    ) -> impl IntoElement {
3626        div()
3627            .id("git-panel-horizontal-scroll")
3628            .occlude()
3629            .flex_none()
3630            .w_full()
3631            .cursor_default()
3632            .absolute()
3633            .bottom_neg_px()
3634            .left_0()
3635            .right_0()
3636            .pr(right_offset)
3637            .on_mouse_move(cx.listener(|_, _, _, cx| {
3638                cx.notify();
3639                cx.stop_propagation()
3640            }))
3641            .on_hover(|_, _, cx| {
3642                cx.stop_propagation();
3643            })
3644            .on_any_mouse_down(|_, _, cx| {
3645                cx.stop_propagation();
3646            })
3647            .on_mouse_up(
3648                MouseButton::Left,
3649                cx.listener(|this, _, window, cx| {
3650                    if !this.horizontal_scrollbar.state.is_dragging()
3651                        && !this.focus_handle.contains_focused(window, cx)
3652                    {
3653                        this.horizontal_scrollbar.hide(window, cx);
3654                        cx.notify();
3655                    }
3656
3657                    cx.stop_propagation();
3658                }),
3659            )
3660            .on_scroll_wheel(cx.listener(|_, _, _, cx| {
3661                cx.notify();
3662            }))
3663            .children(Scrollbar::horizontal(
3664                // percentage as f32..end_offset as f32,
3665                self.horizontal_scrollbar.state.clone(),
3666            ))
3667    }
3668
3669    fn render_buffer_header_controls(
3670        &self,
3671        entity: &Entity<Self>,
3672        file: &Arc<dyn File>,
3673        _: &Window,
3674        cx: &App,
3675    ) -> Option<AnyElement> {
3676        let repo = self.active_repository.as_ref()?.read(cx);
3677        let project_path = (file.worktree_id(cx), file.path()).into();
3678        let repo_path = repo.project_path_to_repo_path(&project_path, cx)?;
3679        let ix = self.entry_by_path(&repo_path, cx)?;
3680        let entry = self.entries.get(ix)?;
3681
3682        let entry_staging = self.entry_staging(entry.status_entry()?);
3683
3684        let checkbox = Checkbox::new("stage-file", entry_staging.as_bool().into())
3685            .disabled(!self.has_write_access(cx))
3686            .fill()
3687            .elevation(ElevationIndex::Surface)
3688            .on_click({
3689                let entry = entry.clone();
3690                let git_panel = entity.downgrade();
3691                move |_, window, cx| {
3692                    git_panel
3693                        .update(cx, |this, cx| {
3694                            this.toggle_staged_for_entry(&entry, window, cx);
3695                            cx.stop_propagation();
3696                        })
3697                        .ok();
3698                }
3699            });
3700        Some(
3701            h_flex()
3702                .id("start-slot")
3703                .text_lg()
3704                .child(checkbox)
3705                .on_mouse_down(MouseButton::Left, |_, _, cx| {
3706                    // prevent the list item active state triggering when toggling checkbox
3707                    cx.stop_propagation();
3708                })
3709                .into_any_element(),
3710        )
3711    }
3712
3713    fn render_entries(
3714        &self,
3715        has_write_access: bool,
3716        _: &Window,
3717        cx: &mut Context<Self>,
3718    ) -> impl IntoElement {
3719        let entry_count = self.entries.len();
3720
3721        let scroll_track_size = px(16.);
3722
3723        let h_scroll_offset = if self.vertical_scrollbar.show_scrollbar {
3724            // magic number
3725            px(3.)
3726        } else {
3727            px(0.)
3728        };
3729
3730        v_flex()
3731            .flex_1()
3732            .size_full()
3733            .overflow_hidden()
3734            .relative()
3735            // Show a border on the top and bottom of the container when
3736            // the vertical scrollbar container is visible so we don't have a
3737            // floating left border in the panel.
3738            .when(self.vertical_scrollbar.show_track, |this| {
3739                this.border_t_1()
3740                    .border_b_1()
3741                    .border_color(cx.theme().colors().border)
3742            })
3743            .child(
3744                h_flex()
3745                    .flex_1()
3746                    .size_full()
3747                    .relative()
3748                    .overflow_hidden()
3749                    .child(
3750                        uniform_list(
3751                            "entries",
3752                            entry_count,
3753                            cx.processor(move |this, range: Range<usize>, window, cx| {
3754                                let mut items = Vec::with_capacity(range.end - range.start);
3755
3756                                for ix in range {
3757                                    match &this.entries.get(ix) {
3758                                        Some(GitListEntry::Status(entry)) => {
3759                                            items.push(this.render_entry(
3760                                                ix,
3761                                                entry,
3762                                                has_write_access,
3763                                                window,
3764                                                cx,
3765                                            ));
3766                                        }
3767                                        Some(GitListEntry::Header(header)) => {
3768                                            items.push(this.render_list_header(
3769                                                ix,
3770                                                header,
3771                                                has_write_access,
3772                                                window,
3773                                                cx,
3774                                            ));
3775                                        }
3776                                        None => {}
3777                                    }
3778                                }
3779
3780                                items
3781                            }),
3782                        )
3783                        .when(
3784                            !self.horizontal_scrollbar.show_track
3785                                && self.horizontal_scrollbar.show_scrollbar,
3786                            |this| {
3787                                // when not showing the horizontal scrollbar track, make sure we don't
3788                                // obscure the last entry
3789                                this.pb(scroll_track_size)
3790                            },
3791                        )
3792                        .size_full()
3793                        .flex_grow()
3794                        .with_sizing_behavior(ListSizingBehavior::Auto)
3795                        .with_horizontal_sizing_behavior(
3796                            ListHorizontalSizingBehavior::Unconstrained,
3797                        )
3798                        .with_width_from_item(self.max_width_item_index)
3799                        .track_scroll(self.scroll_handle.clone()),
3800                    )
3801                    .on_mouse_down(
3802                        MouseButton::Right,
3803                        cx.listener(move |this, event: &MouseDownEvent, window, cx| {
3804                            this.deploy_panel_context_menu(event.position, window, cx)
3805                        }),
3806                    )
3807                    .when(self.vertical_scrollbar.show_track, |this| {
3808                        this.child(
3809                            v_flex()
3810                                .h_full()
3811                                .flex_none()
3812                                .w(scroll_track_size)
3813                                .bg(cx.theme().colors().panel_background)
3814                                .child(
3815                                    div()
3816                                        .size_full()
3817                                        .flex_1()
3818                                        .border_l_1()
3819                                        .border_color(cx.theme().colors().border),
3820                                ),
3821                        )
3822                    })
3823                    .when(self.vertical_scrollbar.show_scrollbar, |this| {
3824                        this.child(
3825                            self.render_vertical_scrollbar(
3826                                self.horizontal_scrollbar.show_track,
3827                                cx,
3828                            ),
3829                        )
3830                    }),
3831            )
3832            .when(self.horizontal_scrollbar.show_track, |this| {
3833                this.child(
3834                    h_flex()
3835                        .w_full()
3836                        .h(scroll_track_size)
3837                        .flex_none()
3838                        .relative()
3839                        .child(
3840                            div()
3841                                .w_full()
3842                                .flex_1()
3843                                // for some reason the horizontal scrollbar is 1px
3844                                // taller than the vertical scrollbar??
3845                                .h(scroll_track_size - px(1.))
3846                                .bg(cx.theme().colors().panel_background)
3847                                .border_t_1()
3848                                .border_color(cx.theme().colors().border),
3849                        )
3850                        .when(self.vertical_scrollbar.show_track, |this| {
3851                            this.child(
3852                                div()
3853                                    .flex_none()
3854                                    // -1px prevents a missing pixel between the two container borders
3855                                    .w(scroll_track_size - px(1.))
3856                                    .h_full(),
3857                            )
3858                            .child(
3859                                // HACK: Fill the missing 1px 🥲
3860                                div()
3861                                    .absolute()
3862                                    .right(scroll_track_size - px(1.))
3863                                    .bottom(scroll_track_size - px(1.))
3864                                    .size_px()
3865                                    .bg(cx.theme().colors().border),
3866                            )
3867                        }),
3868                )
3869            })
3870            .when(self.horizontal_scrollbar.show_scrollbar, |this| {
3871                this.child(self.render_horizontal_scrollbar(h_scroll_offset, cx))
3872            })
3873    }
3874
3875    fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
3876        Label::new(label.into()).color(color).single_line()
3877    }
3878
3879    fn list_item_height(&self) -> Rems {
3880        rems(1.75)
3881    }
3882
3883    fn render_list_header(
3884        &self,
3885        ix: usize,
3886        header: &GitHeaderEntry,
3887        _: bool,
3888        _: &Window,
3889        _: &Context<Self>,
3890    ) -> AnyElement {
3891        let id: ElementId = ElementId::Name(format!("header_{}", ix).into());
3892
3893        h_flex()
3894            .id(id)
3895            .h(self.list_item_height())
3896            .w_full()
3897            .items_end()
3898            .px(rems(0.75)) // ~12px
3899            .pb(rems(0.3125)) // ~ 5px
3900            .child(
3901                Label::new(header.title())
3902                    .color(Color::Muted)
3903                    .size(LabelSize::Small)
3904                    .line_height_style(LineHeightStyle::UiLabel)
3905                    .single_line(),
3906            )
3907            .into_any_element()
3908    }
3909
3910    pub fn load_commit_details(
3911        &self,
3912        sha: String,
3913        cx: &mut Context<Self>,
3914    ) -> Task<anyhow::Result<CommitDetails>> {
3915        let Some(repo) = self.active_repository.clone() else {
3916            return Task::ready(Err(anyhow::anyhow!("no active repo")));
3917        };
3918        repo.update(cx, |repo, cx| {
3919            let show = repo.show(sha);
3920            cx.spawn(async move |_, _| show.await?)
3921        })
3922    }
3923
3924    fn deploy_entry_context_menu(
3925        &mut self,
3926        position: Point<Pixels>,
3927        ix: usize,
3928        window: &mut Window,
3929        cx: &mut Context<Self>,
3930    ) {
3931        let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else {
3932            return;
3933        };
3934        let stage_title = if entry.status.staging().is_fully_staged() {
3935            "Unstage File"
3936        } else {
3937            "Stage File"
3938        };
3939        let restore_title = if entry.status.is_created() {
3940            "Trash File"
3941        } else {
3942            "Restore File"
3943        };
3944        let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
3945            context_menu
3946                .context(self.focus_handle.clone())
3947                .action(stage_title, ToggleStaged.boxed_clone())
3948                .action(restore_title, git::RestoreFile::default().boxed_clone())
3949                .separator()
3950                .action("Open Diff", Confirm.boxed_clone())
3951                .action("Open File", SecondaryConfirm.boxed_clone())
3952        });
3953        self.selected_entry = Some(ix);
3954        self.set_context_menu(context_menu, position, window, cx);
3955    }
3956
3957    fn deploy_panel_context_menu(
3958        &mut self,
3959        position: Point<Pixels>,
3960        window: &mut Window,
3961        cx: &mut Context<Self>,
3962    ) {
3963        let context_menu = git_panel_context_menu(
3964            self.focus_handle.clone(),
3965            GitMenuState {
3966                has_tracked_changes: self.has_tracked_changes(),
3967                has_staged_changes: self.has_staged_changes(),
3968                has_unstaged_changes: self.has_unstaged_changes(),
3969                has_new_changes: self.new_count > 0,
3970            },
3971            window,
3972            cx,
3973        );
3974        self.set_context_menu(context_menu, position, window, cx);
3975    }
3976
3977    fn set_context_menu(
3978        &mut self,
3979        context_menu: Entity<ContextMenu>,
3980        position: Point<Pixels>,
3981        window: &Window,
3982        cx: &mut Context<Self>,
3983    ) {
3984        let subscription = cx.subscribe_in(
3985            &context_menu,
3986            window,
3987            |this, _, _: &DismissEvent, window, cx| {
3988                if this.context_menu.as_ref().is_some_and(|context_menu| {
3989                    context_menu.0.focus_handle(cx).contains_focused(window, cx)
3990                }) {
3991                    cx.focus_self(window);
3992                }
3993                this.context_menu.take();
3994                cx.notify();
3995            },
3996        );
3997        self.context_menu = Some((context_menu, position, subscription));
3998        cx.notify();
3999    }
4000
4001    fn render_entry(
4002        &self,
4003        ix: usize,
4004        entry: &GitStatusEntry,
4005        has_write_access: bool,
4006        window: &Window,
4007        cx: &Context<Self>,
4008    ) -> AnyElement {
4009        let display_name = entry.display_name();
4010
4011        let selected = self.selected_entry == Some(ix);
4012        let marked = self.marked_entries.contains(&ix);
4013        let status_style = GitPanelSettings::get_global(cx).status_style;
4014        let status = entry.status;
4015
4016        let has_conflict = status.is_conflicted();
4017        let is_modified = status.is_modified();
4018        let is_deleted = status.is_deleted();
4019
4020        let label_color = if status_style == StatusStyle::LabelColor {
4021            if has_conflict {
4022                Color::VersionControlConflict
4023            } else if is_modified {
4024                Color::VersionControlModified
4025            } else if is_deleted {
4026                // We don't want a bunch of red labels in the list
4027                Color::Disabled
4028            } else {
4029                Color::VersionControlAdded
4030            }
4031        } else {
4032            Color::Default
4033        };
4034
4035        let path_color = if status.is_deleted() {
4036            Color::Disabled
4037        } else {
4038            Color::Muted
4039        };
4040
4041        let id: ElementId = ElementId::Name(format!("entry_{}_{}", display_name, ix).into());
4042        let checkbox_wrapper_id: ElementId =
4043            ElementId::Name(format!("entry_{}_{}_checkbox_wrapper", display_name, ix).into());
4044        let checkbox_id: ElementId =
4045            ElementId::Name(format!("entry_{}_{}_checkbox", display_name, ix).into());
4046
4047        let entry_staging = self.entry_staging(entry);
4048        let mut is_staged: ToggleState = self.entry_staging(entry).as_bool().into();
4049        if self.show_placeholders && !self.has_staged_changes() && !entry.status.is_created() {
4050            is_staged = ToggleState::Selected;
4051        }
4052
4053        let handle = cx.weak_entity();
4054
4055        let selected_bg_alpha = 0.08;
4056        let marked_bg_alpha = 0.12;
4057        let state_opacity_step = 0.04;
4058
4059        let base_bg = match (selected, marked) {
4060            (true, true) => cx
4061                .theme()
4062                .status()
4063                .info
4064                .alpha(selected_bg_alpha + marked_bg_alpha),
4065            (true, false) => cx.theme().status().info.alpha(selected_bg_alpha),
4066            (false, true) => cx.theme().status().info.alpha(marked_bg_alpha),
4067            _ => cx.theme().colors().ghost_element_background,
4068        };
4069
4070        let hover_bg = if selected {
4071            cx.theme()
4072                .status()
4073                .info
4074                .alpha(selected_bg_alpha + state_opacity_step)
4075        } else {
4076            cx.theme().colors().ghost_element_hover
4077        };
4078
4079        let active_bg = if selected {
4080            cx.theme()
4081                .status()
4082                .info
4083                .alpha(selected_bg_alpha + state_opacity_step * 2.0)
4084        } else {
4085            cx.theme().colors().ghost_element_active
4086        };
4087
4088        h_flex()
4089            .id(id)
4090            .h(self.list_item_height())
4091            .w_full()
4092            .items_center()
4093            .border_1()
4094            .when(selected && self.focus_handle.is_focused(window), |el| {
4095                el.border_color(cx.theme().colors().border_focused)
4096            })
4097            .px(rems(0.75)) // ~12px
4098            .overflow_hidden()
4099            .flex_none()
4100            .gap_1p5()
4101            .bg(base_bg)
4102            .hover(|this| this.bg(hover_bg))
4103            .active(|this| this.bg(active_bg))
4104            .on_click({
4105                cx.listener(move |this, event: &ClickEvent, window, cx| {
4106                    this.selected_entry = Some(ix);
4107                    cx.notify();
4108                    if event.modifiers().secondary() {
4109                        this.open_file(&Default::default(), window, cx)
4110                    } else {
4111                        this.open_diff(&Default::default(), window, cx);
4112                        this.focus_handle.focus(window);
4113                    }
4114                })
4115            })
4116            .on_mouse_down(
4117                MouseButton::Right,
4118                move |event: &MouseDownEvent, window, cx| {
4119                    // why isn't this happening automatically? we are passing MouseButton::Right to `on_mouse_down`?
4120                    if event.button != MouseButton::Right {
4121                        return;
4122                    }
4123
4124                    let Some(this) = handle.upgrade() else {
4125                        return;
4126                    };
4127                    this.update(cx, |this, cx| {
4128                        this.deploy_entry_context_menu(event.position, ix, window, cx);
4129                    });
4130                    cx.stop_propagation();
4131                },
4132            )
4133            .child(
4134                div()
4135                    .id(checkbox_wrapper_id)
4136                    .flex_none()
4137                    .occlude()
4138                    .cursor_pointer()
4139                    .child(
4140                        Checkbox::new(checkbox_id, is_staged)
4141                            .disabled(!has_write_access)
4142                            .fill()
4143                            .elevation(ElevationIndex::Surface)
4144                            .on_click_ext({
4145                                let entry = entry.clone();
4146                                let this = cx.weak_entity();
4147                                move |_, click, window, cx| {
4148                                    this.update(cx, |this, cx| {
4149                                        if !has_write_access {
4150                                            return;
4151                                        }
4152                                        if click.modifiers().shift {
4153                                            this.stage_bulk(ix, cx);
4154                                        } else {
4155                                            this.toggle_staged_for_entry(
4156                                                &GitListEntry::Status(entry.clone()),
4157                                                window,
4158                                                cx,
4159                                            );
4160                                        }
4161                                        cx.stop_propagation();
4162                                    })
4163                                    .ok();
4164                                }
4165                            })
4166                            .tooltip(move |window, cx| {
4167                                let is_staged = entry_staging.is_fully_staged();
4168
4169                                let action = if is_staged { "Unstage" } else { "Stage" };
4170                                let tooltip_name = action.to_string();
4171
4172                                Tooltip::for_action(tooltip_name, &ToggleStaged, window, cx)
4173                            }),
4174                    ),
4175            )
4176            .child(git_status_icon(status))
4177            .child(
4178                h_flex()
4179                    .items_center()
4180                    .flex_1()
4181                    // .overflow_hidden()
4182                    .when_some(entry.parent_dir(), |this, parent| {
4183                        if !parent.is_empty() {
4184                            this.child(
4185                                self.entry_label(format!("{}/", parent), path_color)
4186                                    .when(status.is_deleted(), |this| this.strikethrough()),
4187                            )
4188                        } else {
4189                            this
4190                        }
4191                    })
4192                    .child(
4193                        self.entry_label(display_name.clone(), label_color)
4194                            .when(status.is_deleted(), |this| this.strikethrough()),
4195                    ),
4196            )
4197            .into_any_element()
4198    }
4199
4200    fn has_write_access(&self, cx: &App) -> bool {
4201        !self.project.read(cx).is_read_only(cx)
4202    }
4203
4204    pub fn amend_pending(&self) -> bool {
4205        self.amend_pending
4206    }
4207
4208    pub fn set_amend_pending(&mut self, value: bool, cx: &mut Context<Self>) {
4209        self.amend_pending = value;
4210        cx.notify();
4211    }
4212
4213    pub fn toggle_amend_pending(
4214        &mut self,
4215        _: &Amend,
4216        _window: &mut Window,
4217        cx: &mut Context<Self>,
4218    ) {
4219        self.set_amend_pending(!self.amend_pending, cx);
4220        self.serialize(cx);
4221    }
4222
4223    pub fn signoff_enabled(&self) -> bool {
4224        self.signoff_enabled
4225    }
4226
4227    pub fn set_signoff_enabled(&mut self, value: bool, cx: &mut Context<Self>) {
4228        self.signoff_enabled = value;
4229        self.serialize(cx);
4230        cx.notify();
4231    }
4232
4233    pub fn toggle_signoff_enabled(
4234        &mut self,
4235        _: &Signoff,
4236        _window: &mut Window,
4237        cx: &mut Context<Self>,
4238    ) {
4239        self.set_signoff_enabled(!self.signoff_enabled, cx);
4240    }
4241
4242    pub async fn load(
4243        workspace: WeakEntity<Workspace>,
4244        mut cx: AsyncWindowContext,
4245    ) -> anyhow::Result<Entity<Self>> {
4246        let serialized_panel = match workspace
4247            .read_with(&cx, |workspace, _| Self::serialization_key(workspace))
4248            .ok()
4249            .flatten()
4250        {
4251            Some(serialization_key) => cx
4252                .background_spawn(async move { KEY_VALUE_STORE.read_kvp(&serialization_key) })
4253                .await
4254                .context("loading git panel")
4255                .log_err()
4256                .flatten()
4257                .map(|panel| serde_json::from_str::<SerializedGitPanel>(&panel))
4258                .transpose()
4259                .log_err()
4260                .flatten(),
4261            None => None,
4262        };
4263
4264        workspace.update_in(&mut cx, |workspace, window, cx| {
4265            let panel = GitPanel::new(workspace, window, cx);
4266
4267            if let Some(serialized_panel) = serialized_panel {
4268                panel.update(cx, |panel, cx| {
4269                    panel.width = serialized_panel.width;
4270                    panel.amend_pending = serialized_panel.amend_pending;
4271                    panel.signoff_enabled = serialized_panel.signoff_enabled;
4272                    cx.notify();
4273                })
4274            }
4275
4276            panel
4277        })
4278    }
4279
4280    fn stage_bulk(&mut self, mut index: usize, cx: &mut Context<'_, Self>) {
4281        let Some(op) = self.bulk_staging.as_ref() else {
4282            return;
4283        };
4284        let Some(mut anchor_index) = self.entry_by_path(&op.anchor, cx) else {
4285            return;
4286        };
4287        if let Some(entry) = self.entries.get(index)
4288            && let Some(entry) = entry.status_entry()
4289        {
4290            self.set_bulk_staging_anchor(entry.repo_path.clone(), cx);
4291        }
4292        if index < anchor_index {
4293            std::mem::swap(&mut index, &mut anchor_index);
4294        }
4295        let entries = self
4296            .entries
4297            .get(anchor_index..=index)
4298            .unwrap_or_default()
4299            .iter()
4300            .filter_map(|entry| entry.status_entry().cloned())
4301            .collect::<Vec<_>>();
4302        self.change_file_stage(true, entries, cx);
4303    }
4304
4305    fn set_bulk_staging_anchor(&mut self, path: RepoPath, cx: &mut Context<'_, GitPanel>) {
4306        let Some(repo) = self.active_repository.as_ref() else {
4307            return;
4308        };
4309        self.bulk_staging = Some(BulkStaging {
4310            repo_id: repo.read(cx).id,
4311            anchor: path,
4312        });
4313    }
4314}
4315
4316fn current_language_model(cx: &Context<'_, GitPanel>) -> Option<Arc<dyn LanguageModel>> {
4317    let is_enabled = agent_settings::AgentSettings::get_global(cx).enabled
4318        && !DisableAiSettings::get_global(cx).disable_ai;
4319
4320    is_enabled
4321        .then(|| {
4322            let ConfiguredModel { provider, model } =
4323                LanguageModelRegistry::read_global(cx).commit_message_model()?;
4324
4325            provider.is_authenticated(cx).then(|| model)
4326        })
4327        .flatten()
4328}
4329
4330impl Render for GitPanel {
4331    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4332        let project = self.project.read(cx);
4333        let has_entries = self.entries.len() > 0;
4334        let room = self
4335            .workspace
4336            .upgrade()
4337            .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
4338
4339        let has_write_access = self.has_write_access(cx);
4340
4341        let has_co_authors = room.map_or(false, |room| {
4342            self.load_local_committer(cx);
4343            let room = room.read(cx);
4344            room.remote_participants()
4345                .values()
4346                .any(|remote_participant| remote_participant.can_write())
4347        });
4348
4349        v_flex()
4350            .id("git_panel")
4351            .key_context(self.dispatch_context(window, cx))
4352            .track_focus(&self.focus_handle)
4353            .when(has_write_access && !project.is_read_only(cx), |this| {
4354                this.on_action(cx.listener(Self::toggle_staged_for_selected))
4355                    .on_action(cx.listener(Self::stage_range))
4356                    .on_action(cx.listener(GitPanel::commit))
4357                    .on_action(cx.listener(GitPanel::amend))
4358                    .on_action(cx.listener(GitPanel::toggle_amend_pending))
4359                    .on_action(cx.listener(GitPanel::toggle_signoff_enabled))
4360                    .on_action(cx.listener(Self::stage_all))
4361                    .on_action(cx.listener(Self::unstage_all))
4362                    .on_action(cx.listener(Self::stage_selected))
4363                    .on_action(cx.listener(Self::unstage_selected))
4364                    .on_action(cx.listener(Self::restore_tracked_files))
4365                    .on_action(cx.listener(Self::revert_selected))
4366                    .on_action(cx.listener(Self::clean_all))
4367                    .on_action(cx.listener(Self::generate_commit_message_action))
4368            })
4369            .on_action(cx.listener(Self::select_first))
4370            .on_action(cx.listener(Self::select_next))
4371            .on_action(cx.listener(Self::select_previous))
4372            .on_action(cx.listener(Self::select_last))
4373            .on_action(cx.listener(Self::close_panel))
4374            .on_action(cx.listener(Self::open_diff))
4375            .on_action(cx.listener(Self::open_file))
4376            .on_action(cx.listener(Self::focus_changes_list))
4377            .on_action(cx.listener(Self::focus_editor))
4378            .on_action(cx.listener(Self::expand_commit_editor))
4379            .when(has_write_access && has_co_authors, |git_panel| {
4380                git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
4381            })
4382            .on_hover(cx.listener(move |this, hovered, window, cx| {
4383                if *hovered {
4384                    this.horizontal_scrollbar.show(cx);
4385                    this.vertical_scrollbar.show(cx);
4386                    cx.notify();
4387                } else if !this.focus_handle.contains_focused(window, cx) {
4388                    this.hide_scrollbars(window, cx);
4389                }
4390            }))
4391            .size_full()
4392            .overflow_hidden()
4393            .bg(cx.theme().colors().panel_background)
4394            .child(
4395                v_flex()
4396                    .size_full()
4397                    .children(self.render_panel_header(window, cx))
4398                    .map(|this| {
4399                        if has_entries {
4400                            this.child(self.render_entries(has_write_access, window, cx))
4401                        } else {
4402                            this.child(self.render_empty_state(cx).into_any_element())
4403                        }
4404                    })
4405                    .children(self.render_footer(window, cx))
4406                    .when(self.amend_pending, |this| {
4407                        this.child(self.render_pending_amend(cx))
4408                    })
4409                    .when(!self.amend_pending, |this| {
4410                        this.children(self.render_previous_commit(cx))
4411                    })
4412                    .into_any_element(),
4413            )
4414            .children(self.context_menu.as_ref().map(|(menu, position, _)| {
4415                deferred(
4416                    anchored()
4417                        .position(*position)
4418                        .anchor(Corner::TopLeft)
4419                        .child(menu.clone()),
4420                )
4421                .with_priority(1)
4422            }))
4423    }
4424}
4425
4426impl Focusable for GitPanel {
4427    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
4428        if self.entries.is_empty() {
4429            self.commit_editor.focus_handle(cx)
4430        } else {
4431            self.focus_handle.clone()
4432        }
4433    }
4434}
4435
4436impl EventEmitter<Event> for GitPanel {}
4437
4438impl EventEmitter<PanelEvent> for GitPanel {}
4439
4440pub(crate) struct GitPanelAddon {
4441    pub(crate) workspace: WeakEntity<Workspace>,
4442}
4443
4444impl editor::Addon for GitPanelAddon {
4445    fn to_any(&self) -> &dyn std::any::Any {
4446        self
4447    }
4448
4449    fn render_buffer_header_controls(
4450        &self,
4451        excerpt_info: &ExcerptInfo,
4452        window: &Window,
4453        cx: &App,
4454    ) -> Option<AnyElement> {
4455        let file = excerpt_info.buffer.file()?;
4456        let git_panel = self.workspace.upgrade()?.read(cx).panel::<GitPanel>(cx)?;
4457
4458        git_panel
4459            .read(cx)
4460            .render_buffer_header_controls(&git_panel, &file, window, cx)
4461    }
4462}
4463
4464impl Panel for GitPanel {
4465    fn persistent_name() -> &'static str {
4466        "GitPanel"
4467    }
4468
4469    fn position(&self, _: &Window, cx: &App) -> DockPosition {
4470        GitPanelSettings::get_global(cx).dock
4471    }
4472
4473    fn position_is_valid(&self, position: DockPosition) -> bool {
4474        matches!(position, DockPosition::Left | DockPosition::Right)
4475    }
4476
4477    fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
4478        settings::update_settings_file::<GitPanelSettings>(
4479            self.fs.clone(),
4480            cx,
4481            move |settings, _| settings.dock = Some(position),
4482        );
4483    }
4484
4485    fn size(&self, _: &Window, cx: &App) -> Pixels {
4486        self.width
4487            .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
4488    }
4489
4490    fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
4491        self.width = size;
4492        self.serialize(cx);
4493        cx.notify();
4494    }
4495
4496    fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
4497        Some(ui::IconName::GitBranchSmall).filter(|_| GitPanelSettings::get_global(cx).button)
4498    }
4499
4500    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
4501        Some("Git Panel")
4502    }
4503
4504    fn toggle_action(&self) -> Box<dyn Action> {
4505        Box::new(ToggleFocus)
4506    }
4507
4508    fn activation_priority(&self) -> u32 {
4509        2
4510    }
4511}
4512
4513impl PanelHeader for GitPanel {}
4514
4515struct GitPanelMessageTooltip {
4516    commit_tooltip: Option<Entity<CommitTooltip>>,
4517}
4518
4519impl GitPanelMessageTooltip {
4520    fn new(
4521        git_panel: Entity<GitPanel>,
4522        sha: SharedString,
4523        repository: Entity<Repository>,
4524        window: &mut Window,
4525        cx: &mut App,
4526    ) -> Entity<Self> {
4527        cx.new(|cx| {
4528            cx.spawn_in(window, async move |this, cx| {
4529                let (details, workspace) = git_panel.update(cx, |git_panel, cx| {
4530                    (
4531                        git_panel.load_commit_details(sha.to_string(), cx),
4532                        git_panel.workspace.clone(),
4533                    )
4534                })?;
4535                let details = details.await?;
4536
4537                let commit_details = crate::commit_tooltip::CommitDetails {
4538                    sha: details.sha.clone(),
4539                    author_name: details.author_name.clone(),
4540                    author_email: details.author_email.clone(),
4541                    commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
4542                    message: Some(ParsedCommitMessage {
4543                        message: details.message.clone(),
4544                        ..Default::default()
4545                    }),
4546                };
4547
4548                this.update(cx, |this: &mut GitPanelMessageTooltip, cx| {
4549                    this.commit_tooltip = Some(cx.new(move |cx| {
4550                        CommitTooltip::new(commit_details, repository, workspace, cx)
4551                    }));
4552                    cx.notify();
4553                })
4554            })
4555            .detach();
4556
4557            Self {
4558                commit_tooltip: None,
4559            }
4560        })
4561    }
4562}
4563
4564impl Render for GitPanelMessageTooltip {
4565    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
4566        if let Some(commit_tooltip) = &self.commit_tooltip {
4567            commit_tooltip.clone().into_any_element()
4568        } else {
4569            gpui::Empty.into_any_element()
4570        }
4571    }
4572}
4573
4574#[derive(IntoElement, RegisterComponent)]
4575pub struct PanelRepoFooter {
4576    active_repository: SharedString,
4577    branch: Option<Branch>,
4578    head_commit: Option<CommitDetails>,
4579
4580    // Getting a GitPanel in previews will be difficult.
4581    //
4582    // For now just take an option here, and we won't bind handlers to buttons in previews.
4583    git_panel: Option<Entity<GitPanel>>,
4584}
4585
4586impl PanelRepoFooter {
4587    pub fn new(
4588        active_repository: SharedString,
4589        branch: Option<Branch>,
4590        head_commit: Option<CommitDetails>,
4591        git_panel: Option<Entity<GitPanel>>,
4592    ) -> Self {
4593        Self {
4594            active_repository,
4595            branch,
4596            head_commit,
4597            git_panel,
4598        }
4599    }
4600
4601    pub fn new_preview(active_repository: SharedString, branch: Option<Branch>) -> Self {
4602        Self {
4603            active_repository,
4604            branch,
4605            head_commit: None,
4606            git_panel: None,
4607        }
4608    }
4609}
4610
4611impl RenderOnce for PanelRepoFooter {
4612    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
4613        let project = self
4614            .git_panel
4615            .as_ref()
4616            .map(|panel| panel.read(cx).project.clone());
4617
4618        let repo = self
4619            .git_panel
4620            .as_ref()
4621            .and_then(|panel| panel.read(cx).active_repository.clone());
4622
4623        let single_repo = project
4624            .as_ref()
4625            .map(|project| project.read(cx).git_store().read(cx).repositories().len() == 1)
4626            .unwrap_or(true);
4627
4628        const MAX_BRANCH_LEN: usize = 16;
4629        const MAX_REPO_LEN: usize = 16;
4630        const LABEL_CHARACTER_BUDGET: usize = MAX_BRANCH_LEN + MAX_REPO_LEN;
4631        const MAX_SHORT_SHA_LEN: usize = 8;
4632
4633        let branch_name = self
4634            .branch
4635            .as_ref()
4636            .map(|branch| branch.name().to_owned())
4637            .or_else(|| {
4638                self.head_commit.as_ref().map(|commit| {
4639                    commit
4640                        .sha
4641                        .chars()
4642                        .take(MAX_SHORT_SHA_LEN)
4643                        .collect::<String>()
4644                })
4645            })
4646            .unwrap_or_else(|| " (no branch)".to_owned());
4647        let show_separator = self.branch.is_some() || self.head_commit.is_some();
4648
4649        let active_repo_name = self.active_repository.clone();
4650
4651        let branch_actual_len = branch_name.len();
4652        let repo_actual_len = active_repo_name.len();
4653
4654        // ideally, show the whole branch and repo names but
4655        // when we can't, use a budget to allocate space between the two
4656        let (repo_display_len, branch_display_len) = if branch_actual_len + repo_actual_len
4657            <= LABEL_CHARACTER_BUDGET
4658        {
4659            (repo_actual_len, branch_actual_len)
4660        } else {
4661            if branch_actual_len <= MAX_BRANCH_LEN {
4662                let repo_space = (LABEL_CHARACTER_BUDGET - branch_actual_len).min(MAX_REPO_LEN);
4663                (repo_space, branch_actual_len)
4664            } else if repo_actual_len <= MAX_REPO_LEN {
4665                let branch_space = (LABEL_CHARACTER_BUDGET - repo_actual_len).min(MAX_BRANCH_LEN);
4666                (repo_actual_len, branch_space)
4667            } else {
4668                (MAX_REPO_LEN, MAX_BRANCH_LEN)
4669            }
4670        };
4671
4672        let truncated_repo_name = if repo_actual_len <= repo_display_len {
4673            active_repo_name.to_string()
4674        } else {
4675            util::truncate_and_trailoff(active_repo_name.trim_ascii(), repo_display_len)
4676        };
4677
4678        let truncated_branch_name = if branch_actual_len <= branch_display_len {
4679            branch_name.to_string()
4680        } else {
4681            util::truncate_and_trailoff(branch_name.trim_ascii(), branch_display_len)
4682        };
4683
4684        let repo_selector_trigger = Button::new("repo-selector", truncated_repo_name)
4685            .style(ButtonStyle::Transparent)
4686            .size(ButtonSize::None)
4687            .label_size(LabelSize::Small)
4688            .color(Color::Muted);
4689
4690        let repo_selector = PopoverMenu::new("repository-switcher")
4691            .menu({
4692                let project = project.clone();
4693                move |window, cx| {
4694                    let project = project.clone()?;
4695                    Some(cx.new(|cx| RepositorySelector::new(project, rems(16.), window, cx)))
4696                }
4697            })
4698            .trigger_with_tooltip(
4699                repo_selector_trigger.disabled(single_repo).truncate(true),
4700                Tooltip::text("Switch Active Repository"),
4701            )
4702            .anchor(Corner::BottomLeft)
4703            .into_any_element();
4704
4705        let branch_selector_button = Button::new("branch-selector", truncated_branch_name)
4706            .style(ButtonStyle::Transparent)
4707            .size(ButtonSize::None)
4708            .label_size(LabelSize::Small)
4709            .truncate(true)
4710            .tooltip(Tooltip::for_action_title(
4711                "Switch Branch",
4712                &zed_actions::git::Switch,
4713            ))
4714            .on_click(|_, window, cx| {
4715                window.dispatch_action(zed_actions::git::Switch.boxed_clone(), cx);
4716            });
4717
4718        let branch_selector = PopoverMenu::new("popover-button")
4719            .menu(move |window, cx| Some(branch_picker::popover(repo.clone(), window, cx)))
4720            .trigger_with_tooltip(
4721                branch_selector_button,
4722                Tooltip::for_action_title("Switch Branch", &zed_actions::git::Switch),
4723            )
4724            .anchor(Corner::BottomLeft)
4725            .offset(gpui::Point {
4726                x: px(0.0),
4727                y: px(-2.0),
4728            });
4729
4730        h_flex()
4731            .w_full()
4732            .px_2()
4733            .h(px(36.))
4734            .items_center()
4735            .justify_between()
4736            .gap_1()
4737            .child(
4738                h_flex()
4739                    .flex_1()
4740                    .overflow_hidden()
4741                    .items_center()
4742                    .child(
4743                        div().child(
4744                            Icon::new(IconName::GitBranchSmall)
4745                                .size(IconSize::Small)
4746                                .color(if single_repo {
4747                                    Color::Disabled
4748                                } else {
4749                                    Color::Muted
4750                                }),
4751                        ),
4752                    )
4753                    .child(repo_selector)
4754                    .when(show_separator, |this| {
4755                        this.child(
4756                            div()
4757                                .text_color(cx.theme().colors().text_muted)
4758                                .text_sm()
4759                                .child("/"),
4760                        )
4761                    })
4762                    .child(branch_selector),
4763            )
4764            .children(if let Some(git_panel) = self.git_panel {
4765                git_panel.update(cx, |git_panel, cx| git_panel.render_remote_button(cx))
4766            } else {
4767                None
4768            })
4769    }
4770}
4771
4772impl Component for PanelRepoFooter {
4773    fn scope() -> ComponentScope {
4774        ComponentScope::VersionControl
4775    }
4776
4777    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
4778        let unknown_upstream = None;
4779        let no_remote_upstream = Some(UpstreamTracking::Gone);
4780        let ahead_of_upstream = Some(
4781            UpstreamTrackingStatus {
4782                ahead: 2,
4783                behind: 0,
4784            }
4785            .into(),
4786        );
4787        let behind_upstream = Some(
4788            UpstreamTrackingStatus {
4789                ahead: 0,
4790                behind: 2,
4791            }
4792            .into(),
4793        );
4794        let ahead_and_behind_upstream = Some(
4795            UpstreamTrackingStatus {
4796                ahead: 3,
4797                behind: 1,
4798            }
4799            .into(),
4800        );
4801
4802        let not_ahead_or_behind_upstream = Some(
4803            UpstreamTrackingStatus {
4804                ahead: 0,
4805                behind: 0,
4806            }
4807            .into(),
4808        );
4809
4810        fn branch(upstream: Option<UpstreamTracking>) -> Branch {
4811            Branch {
4812                is_head: true,
4813                ref_name: "some-branch".into(),
4814                upstream: upstream.map(|tracking| Upstream {
4815                    ref_name: "origin/some-branch".into(),
4816                    tracking,
4817                }),
4818                most_recent_commit: Some(CommitSummary {
4819                    sha: "abc123".into(),
4820                    subject: "Modify stuff".into(),
4821                    commit_timestamp: 1710932954,
4822                    has_parent: true,
4823                }),
4824            }
4825        }
4826
4827        fn custom(branch_name: &str, upstream: Option<UpstreamTracking>) -> Branch {
4828            Branch {
4829                is_head: true,
4830                ref_name: branch_name.to_string().into(),
4831                upstream: upstream.map(|tracking| Upstream {
4832                    ref_name: format!("zed/{}", branch_name).into(),
4833                    tracking,
4834                }),
4835                most_recent_commit: Some(CommitSummary {
4836                    sha: "abc123".into(),
4837                    subject: "Modify stuff".into(),
4838                    commit_timestamp: 1710932954,
4839                    has_parent: true,
4840                }),
4841            }
4842        }
4843
4844        fn active_repository(id: usize) -> SharedString {
4845            format!("repo-{}", id).into()
4846        }
4847
4848        let example_width = px(340.);
4849        Some(
4850            v_flex()
4851                .gap_6()
4852                .w_full()
4853                .flex_none()
4854                .children(vec![
4855                    example_group_with_title(
4856                        "Action Button States",
4857                        vec![
4858                            single_example(
4859                                "No Branch",
4860                                div()
4861                                    .w(example_width)
4862                                    .overflow_hidden()
4863                                    .child(PanelRepoFooter::new_preview(
4864                                        active_repository(1).clone(),
4865                                        None,
4866                                    ))
4867                                    .into_any_element(),
4868                            ),
4869                            single_example(
4870                                "Remote status unknown",
4871                                div()
4872                                    .w(example_width)
4873                                    .overflow_hidden()
4874                                    .child(PanelRepoFooter::new_preview(
4875                                        active_repository(2).clone(),
4876                                        Some(branch(unknown_upstream)),
4877                                    ))
4878                                    .into_any_element(),
4879                            ),
4880                            single_example(
4881                                "No Remote Upstream",
4882                                div()
4883                                    .w(example_width)
4884                                    .overflow_hidden()
4885                                    .child(PanelRepoFooter::new_preview(
4886                                        active_repository(3).clone(),
4887                                        Some(branch(no_remote_upstream)),
4888                                    ))
4889                                    .into_any_element(),
4890                            ),
4891                            single_example(
4892                                "Not Ahead or Behind",
4893                                div()
4894                                    .w(example_width)
4895                                    .overflow_hidden()
4896                                    .child(PanelRepoFooter::new_preview(
4897                                        active_repository(4).clone(),
4898                                        Some(branch(not_ahead_or_behind_upstream)),
4899                                    ))
4900                                    .into_any_element(),
4901                            ),
4902                            single_example(
4903                                "Behind remote",
4904                                div()
4905                                    .w(example_width)
4906                                    .overflow_hidden()
4907                                    .child(PanelRepoFooter::new_preview(
4908                                        active_repository(5).clone(),
4909                                        Some(branch(behind_upstream)),
4910                                    ))
4911                                    .into_any_element(),
4912                            ),
4913                            single_example(
4914                                "Ahead of remote",
4915                                div()
4916                                    .w(example_width)
4917                                    .overflow_hidden()
4918                                    .child(PanelRepoFooter::new_preview(
4919                                        active_repository(6).clone(),
4920                                        Some(branch(ahead_of_upstream)),
4921                                    ))
4922                                    .into_any_element(),
4923                            ),
4924                            single_example(
4925                                "Ahead and behind remote",
4926                                div()
4927                                    .w(example_width)
4928                                    .overflow_hidden()
4929                                    .child(PanelRepoFooter::new_preview(
4930                                        active_repository(7).clone(),
4931                                        Some(branch(ahead_and_behind_upstream)),
4932                                    ))
4933                                    .into_any_element(),
4934                            ),
4935                        ],
4936                    )
4937                    .grow()
4938                    .vertical(),
4939                ])
4940                .children(vec![
4941                    example_group_with_title(
4942                        "Labels",
4943                        vec![
4944                            single_example(
4945                                "Short Branch & Repo",
4946                                div()
4947                                    .w(example_width)
4948                                    .overflow_hidden()
4949                                    .child(PanelRepoFooter::new_preview(
4950                                        SharedString::from("zed"),
4951                                        Some(custom("main", behind_upstream)),
4952                                    ))
4953                                    .into_any_element(),
4954                            ),
4955                            single_example(
4956                                "Long Branch",
4957                                div()
4958                                    .w(example_width)
4959                                    .overflow_hidden()
4960                                    .child(PanelRepoFooter::new_preview(
4961                                        SharedString::from("zed"),
4962                                        Some(custom(
4963                                            "redesign-and-update-git-ui-list-entry-style",
4964                                            behind_upstream,
4965                                        )),
4966                                    ))
4967                                    .into_any_element(),
4968                            ),
4969                            single_example(
4970                                "Long Repo",
4971                                div()
4972                                    .w(example_width)
4973                                    .overflow_hidden()
4974                                    .child(PanelRepoFooter::new_preview(
4975                                        SharedString::from("zed-industries-community-examples"),
4976                                        Some(custom("gpui", ahead_of_upstream)),
4977                                    ))
4978                                    .into_any_element(),
4979                            ),
4980                            single_example(
4981                                "Long Repo & Branch",
4982                                div()
4983                                    .w(example_width)
4984                                    .overflow_hidden()
4985                                    .child(PanelRepoFooter::new_preview(
4986                                        SharedString::from("zed-industries-community-examples"),
4987                                        Some(custom(
4988                                            "redesign-and-update-git-ui-list-entry-style",
4989                                            behind_upstream,
4990                                        )),
4991                                    ))
4992                                    .into_any_element(),
4993                            ),
4994                            single_example(
4995                                "Uppercase Repo",
4996                                div()
4997                                    .w(example_width)
4998                                    .overflow_hidden()
4999                                    .child(PanelRepoFooter::new_preview(
5000                                        SharedString::from("LICENSES"),
5001                                        Some(custom("main", ahead_of_upstream)),
5002                                    ))
5003                                    .into_any_element(),
5004                            ),
5005                            single_example(
5006                                "Uppercase Branch",
5007                                div()
5008                                    .w(example_width)
5009                                    .overflow_hidden()
5010                                    .child(PanelRepoFooter::new_preview(
5011                                        SharedString::from("zed"),
5012                                        Some(custom("update-README", behind_upstream)),
5013                                    ))
5014                                    .into_any_element(),
5015                            ),
5016                        ],
5017                    )
5018                    .grow()
5019                    .vertical(),
5020                ])
5021                .into_any_element(),
5022        )
5023    }
5024}
5025
5026#[cfg(test)]
5027mod tests {
5028    use git::status::{StatusCode, UnmergedStatus, UnmergedStatusCode};
5029    use gpui::{TestAppContext, VisualTestContext};
5030    use project::{FakeFs, WorktreeSettings};
5031    use serde_json::json;
5032    use settings::SettingsStore;
5033    use theme::LoadThemes;
5034    use util::path;
5035
5036    use super::*;
5037
5038    fn init_test(cx: &mut gpui::TestAppContext) {
5039        zlog::init_test();
5040
5041        cx.update(|cx| {
5042            let settings_store = SettingsStore::test(cx);
5043            cx.set_global(settings_store);
5044            AgentSettings::register(cx);
5045            WorktreeSettings::register(cx);
5046            workspace::init_settings(cx);
5047            theme::init(LoadThemes::JustBase, cx);
5048            language::init(cx);
5049            editor::init(cx);
5050            Project::init_settings(cx);
5051            client::DisableAiSettings::register(cx);
5052            crate::init(cx);
5053        });
5054    }
5055
5056    #[gpui::test]
5057    async fn test_entry_worktree_paths(cx: &mut TestAppContext) {
5058        init_test(cx);
5059        let fs = FakeFs::new(cx.background_executor.clone());
5060        fs.insert_tree(
5061            "/root",
5062            json!({
5063                "zed": {
5064                    ".git": {},
5065                    "crates": {
5066                        "gpui": {
5067                            "gpui.rs": "fn main() {}"
5068                        },
5069                        "util": {
5070                            "util.rs": "fn do_it() {}"
5071                        }
5072                    }
5073                },
5074            }),
5075        )
5076        .await;
5077
5078        fs.set_status_for_repo(
5079            Path::new(path!("/root/zed/.git")),
5080            &[
5081                (
5082                    Path::new("crates/gpui/gpui.rs"),
5083                    StatusCode::Modified.worktree(),
5084                ),
5085                (
5086                    Path::new("crates/util/util.rs"),
5087                    StatusCode::Modified.worktree(),
5088                ),
5089            ],
5090        );
5091
5092        let project =
5093            Project::test(fs.clone(), [path!("/root/zed/crates/gpui").as_ref()], cx).await;
5094        let workspace =
5095            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5096        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5097
5098        cx.read(|cx| {
5099            project
5100                .read(cx)
5101                .worktrees(cx)
5102                .nth(0)
5103                .unwrap()
5104                .read(cx)
5105                .as_local()
5106                .unwrap()
5107                .scan_complete()
5108        })
5109        .await;
5110
5111        cx.executor().run_until_parked();
5112
5113        let panel = workspace.update(cx, GitPanel::new).unwrap();
5114
5115        let handle = cx.update_window_entity(&panel, |panel, _, _| {
5116            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5117        });
5118        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5119        handle.await;
5120
5121        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5122        pretty_assertions::assert_eq!(
5123            entries,
5124            [
5125                GitListEntry::Header(GitHeaderEntry {
5126                    header: Section::Tracked
5127                }),
5128                GitListEntry::Status(GitStatusEntry {
5129                    abs_path: path!("/root/zed/crates/gpui/gpui.rs").into(),
5130                    repo_path: "crates/gpui/gpui.rs".into(),
5131                    status: StatusCode::Modified.worktree(),
5132                    staging: StageStatus::Unstaged,
5133                }),
5134                GitListEntry::Status(GitStatusEntry {
5135                    abs_path: path!("/root/zed/crates/util/util.rs").into(),
5136                    repo_path: "crates/util/util.rs".into(),
5137                    status: StatusCode::Modified.worktree(),
5138                    staging: StageStatus::Unstaged,
5139                },),
5140            ],
5141        );
5142
5143        let handle = cx.update_window_entity(&panel, |panel, _, _| {
5144            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5145        });
5146        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5147        handle.await;
5148        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5149        pretty_assertions::assert_eq!(
5150            entries,
5151            [
5152                GitListEntry::Header(GitHeaderEntry {
5153                    header: Section::Tracked
5154                }),
5155                GitListEntry::Status(GitStatusEntry {
5156                    abs_path: path!("/root/zed/crates/gpui/gpui.rs").into(),
5157                    repo_path: "crates/gpui/gpui.rs".into(),
5158                    status: StatusCode::Modified.worktree(),
5159                    staging: StageStatus::Unstaged,
5160                }),
5161                GitListEntry::Status(GitStatusEntry {
5162                    abs_path: path!("/root/zed/crates/util/util.rs").into(),
5163                    repo_path: "crates/util/util.rs".into(),
5164                    status: StatusCode::Modified.worktree(),
5165                    staging: StageStatus::Unstaged,
5166                },),
5167            ],
5168        );
5169    }
5170
5171    #[gpui::test]
5172    async fn test_bulk_staging(cx: &mut TestAppContext) {
5173        use GitListEntry::*;
5174
5175        init_test(cx);
5176        let fs = FakeFs::new(cx.background_executor.clone());
5177        fs.insert_tree(
5178            "/root",
5179            json!({
5180                "project": {
5181                    ".git": {},
5182                    "src": {
5183                        "main.rs": "fn main() {}",
5184                        "lib.rs": "pub fn hello() {}",
5185                        "utils.rs": "pub fn util() {}"
5186                    },
5187                    "tests": {
5188                        "test.rs": "fn test() {}"
5189                    },
5190                    "new_file.txt": "new content",
5191                    "another_new.rs": "// new file",
5192                    "conflict.txt": "conflicted content"
5193                }
5194            }),
5195        )
5196        .await;
5197
5198        fs.set_status_for_repo(
5199            Path::new(path!("/root/project/.git")),
5200            &[
5201                (Path::new("src/main.rs"), StatusCode::Modified.worktree()),
5202                (Path::new("src/lib.rs"), StatusCode::Modified.worktree()),
5203                (Path::new("tests/test.rs"), StatusCode::Modified.worktree()),
5204                (Path::new("new_file.txt"), FileStatus::Untracked),
5205                (Path::new("another_new.rs"), FileStatus::Untracked),
5206                (Path::new("src/utils.rs"), FileStatus::Untracked),
5207                (
5208                    Path::new("conflict.txt"),
5209                    UnmergedStatus {
5210                        first_head: UnmergedStatusCode::Updated,
5211                        second_head: UnmergedStatusCode::Updated,
5212                    }
5213                    .into(),
5214                ),
5215            ],
5216        );
5217
5218        let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
5219        let workspace =
5220            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5221        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5222
5223        cx.read(|cx| {
5224            project
5225                .read(cx)
5226                .worktrees(cx)
5227                .nth(0)
5228                .unwrap()
5229                .read(cx)
5230                .as_local()
5231                .unwrap()
5232                .scan_complete()
5233        })
5234        .await;
5235
5236        cx.executor().run_until_parked();
5237
5238        let panel = workspace.update(cx, GitPanel::new).unwrap();
5239
5240        let handle = cx.update_window_entity(&panel, |panel, _, _| {
5241            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5242        });
5243        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5244        handle.await;
5245
5246        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5247        #[rustfmt::skip]
5248        pretty_assertions::assert_matches!(
5249            entries.as_slice(),
5250            &[
5251                Header(GitHeaderEntry { header: Section::Conflict }),
5252                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5253                Header(GitHeaderEntry { header: Section::Tracked }),
5254                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5255                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5256                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5257                Header(GitHeaderEntry { header: Section::New }),
5258                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5259                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5260                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5261            ],
5262        );
5263
5264        let second_status_entry = entries[3].clone();
5265        panel.update_in(cx, |panel, window, cx| {
5266            panel.toggle_staged_for_entry(&second_status_entry, window, cx);
5267        });
5268
5269        panel.update_in(cx, |panel, window, cx| {
5270            panel.selected_entry = Some(7);
5271            panel.stage_range(&git::StageRange, window, cx);
5272        });
5273
5274        cx.read(|cx| {
5275            project
5276                .read(cx)
5277                .worktrees(cx)
5278                .nth(0)
5279                .unwrap()
5280                .read(cx)
5281                .as_local()
5282                .unwrap()
5283                .scan_complete()
5284        })
5285        .await;
5286
5287        cx.executor().run_until_parked();
5288
5289        let handle = cx.update_window_entity(&panel, |panel, _, _| {
5290            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5291        });
5292        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5293        handle.await;
5294
5295        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5296        #[rustfmt::skip]
5297        pretty_assertions::assert_matches!(
5298            entries.as_slice(),
5299            &[
5300                Header(GitHeaderEntry { header: Section::Conflict }),
5301                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5302                Header(GitHeaderEntry { header: Section::Tracked }),
5303                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5304                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5305                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5306                Header(GitHeaderEntry { header: Section::New }),
5307                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5308                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5309                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5310            ],
5311        );
5312
5313        let third_status_entry = entries[4].clone();
5314        panel.update_in(cx, |panel, window, cx| {
5315            panel.toggle_staged_for_entry(&third_status_entry, window, cx);
5316        });
5317
5318        panel.update_in(cx, |panel, window, cx| {
5319            panel.selected_entry = Some(9);
5320            panel.stage_range(&git::StageRange, window, cx);
5321        });
5322
5323        cx.read(|cx| {
5324            project
5325                .read(cx)
5326                .worktrees(cx)
5327                .nth(0)
5328                .unwrap()
5329                .read(cx)
5330                .as_local()
5331                .unwrap()
5332                .scan_complete()
5333        })
5334        .await;
5335
5336        cx.executor().run_until_parked();
5337
5338        let handle = cx.update_window_entity(&panel, |panel, _, _| {
5339            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5340        });
5341        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5342        handle.await;
5343
5344        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5345        #[rustfmt::skip]
5346        pretty_assertions::assert_matches!(
5347            entries.as_slice(),
5348            &[
5349                Header(GitHeaderEntry { header: Section::Conflict }),
5350                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5351                Header(GitHeaderEntry { header: Section::Tracked }),
5352                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5353                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5354                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5355                Header(GitHeaderEntry { header: Section::New }),
5356                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5357                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5358                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5359            ],
5360        );
5361    }
5362}