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