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, PathBuf};
  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::{ResultExt, TryFutureExt, maybe};
  65use workspace::SERIALIZATION_THROTTLE_TIME;
  66
  67use cloud_llm_client::CompletionIntent;
  68use workspace::{
  69    Workspace,
  70    dock::{DockPosition, Panel, PanelEvent},
  71    notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId},
  72};
  73
  74actions!(
  75    git_panel,
  76    [
  77        /// Closes the git panel.
  78        Close,
  79        /// Toggles focus on the git panel.
  80        ToggleFocus,
  81        /// Opens the git panel menu.
  82        OpenMenu,
  83        /// Focuses on the commit message editor.
  84        FocusEditor,
  85        /// Focuses on the changes list.
  86        FocusChanges,
  87        /// Toggles automatic co-author suggestions.
  88        ToggleFillCoAuthors,
  89        /// Toggles sorting entries by path vs status.
  90        ToggleSortByPath,
  91    ]
  92);
  93
  94fn prompt<T>(
  95    msg: &str,
  96    detail: Option<&str>,
  97    window: &mut Window,
  98    cx: &mut App,
  99) -> Task<anyhow::Result<T>>
 100where
 101    T: IntoEnumIterator + VariantNames + 'static,
 102{
 103    let rx = window.prompt(PromptLevel::Info, msg, detail, T::VARIANTS, cx);
 104    cx.spawn(async move |_| Ok(T::iter().nth(rx.await?).unwrap()))
 105}
 106
 107#[derive(strum::EnumIter, strum::VariantNames)]
 108#[strum(serialize_all = "title_case")]
 109enum TrashCancel {
 110    Trash,
 111    Cancel,
 112}
 113
 114struct GitMenuState {
 115    has_tracked_changes: bool,
 116    has_staged_changes: bool,
 117    has_unstaged_changes: bool,
 118    has_new_changes: bool,
 119    sort_by_path: bool,
 120    has_stash_items: bool,
 121}
 122
 123fn git_panel_context_menu(
 124    focus_handle: FocusHandle,
 125    state: GitMenuState,
 126    window: &mut Window,
 127    cx: &mut App,
 128) -> Entity<ContextMenu> {
 129    ContextMenu::build(window, cx, move |context_menu, _, _| {
 130        context_menu
 131            .context(focus_handle)
 132            .action_disabled_when(
 133                !state.has_unstaged_changes,
 134                "Stage All",
 135                StageAll.boxed_clone(),
 136            )
 137            .action_disabled_when(
 138                !state.has_staged_changes,
 139                "Unstage All",
 140                UnstageAll.boxed_clone(),
 141            )
 142            .separator()
 143            .action_disabled_when(
 144                !(state.has_new_changes || state.has_tracked_changes),
 145                "Stash All",
 146                StashAll.boxed_clone(),
 147            )
 148            .action_disabled_when(!state.has_stash_items, "Stash Pop", StashPop.boxed_clone())
 149            .action("View Stash", zed_actions::git::ViewStash.boxed_clone())
 150            .separator()
 151            .action("Open Diff", project_diff::Diff.boxed_clone())
 152            .separator()
 153            .action_disabled_when(
 154                !state.has_tracked_changes,
 155                "Discard Tracked Changes",
 156                RestoreTrackedFiles.boxed_clone(),
 157            )
 158            .action_disabled_when(
 159                !state.has_new_changes,
 160                "Trash Untracked Files",
 161                TrashUntrackedFiles.boxed_clone(),
 162            )
 163            .separator()
 164            .entry(
 165                if state.sort_by_path {
 166                    "Sort by Status"
 167                } else {
 168                    "Sort by Path"
 169                },
 170                Some(Box::new(ToggleSortByPath)),
 171                move |window, cx| window.dispatch_action(Box::new(ToggleSortByPath), cx),
 172            )
 173    })
 174}
 175
 176const GIT_PANEL_KEY: &str = "GitPanel";
 177
 178const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
 179
 180pub fn register(workspace: &mut Workspace) {
 181    workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
 182        workspace.toggle_panel_focus::<GitPanel>(window, cx);
 183    });
 184    workspace.register_action(|workspace, _: &ExpandCommitEditor, window, cx| {
 185        CommitModal::toggle(workspace, None, window, cx)
 186    });
 187}
 188
 189#[derive(Debug, Clone)]
 190pub enum Event {
 191    Focus,
 192}
 193
 194#[derive(Serialize, Deserialize)]
 195struct SerializedGitPanel {
 196    width: Option<Pixels>,
 197    #[serde(default)]
 198    amend_pending: bool,
 199    #[serde(default)]
 200    signoff_enabled: bool,
 201}
 202
 203#[derive(Debug, PartialEq, Eq, Clone, Copy)]
 204enum Section {
 205    Conflict,
 206    Tracked,
 207    New,
 208}
 209
 210#[derive(Debug, PartialEq, Eq, Clone)]
 211struct GitHeaderEntry {
 212    header: Section,
 213}
 214
 215impl GitHeaderEntry {
 216    pub fn contains(&self, status_entry: &GitStatusEntry, repo: &Repository) -> bool {
 217        let this = &self.header;
 218        let status = status_entry.status;
 219        match this {
 220            Section::Conflict => {
 221                repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path)
 222            }
 223            Section::Tracked => !status.is_created(),
 224            Section::New => status.is_created(),
 225        }
 226    }
 227    pub fn title(&self) -> &'static str {
 228        match self.header {
 229            Section::Conflict => "Conflicts",
 230            Section::Tracked => "Tracked",
 231            Section::New => "Untracked",
 232        }
 233    }
 234}
 235
 236#[derive(Debug, PartialEq, Eq, Clone)]
 237enum GitListEntry {
 238    Status(GitStatusEntry),
 239    Header(GitHeaderEntry),
 240}
 241
 242impl GitListEntry {
 243    fn status_entry(&self) -> Option<&GitStatusEntry> {
 244        match self {
 245            GitListEntry::Status(entry) => Some(entry),
 246            _ => None,
 247        }
 248    }
 249}
 250
 251#[derive(Debug, PartialEq, Eq, Clone)]
 252pub struct GitStatusEntry {
 253    pub(crate) repo_path: RepoPath,
 254    pub(crate) abs_path: PathBuf,
 255    pub(crate) status: FileStatus,
 256    pub(crate) staging: StageStatus,
 257}
 258
 259impl GitStatusEntry {
 260    fn display_name(&self) -> String {
 261        self.repo_path
 262            .file_name()
 263            .map(|name| name.to_string_lossy().into_owned())
 264            .unwrap_or_else(|| self.repo_path.to_string_lossy().into_owned())
 265    }
 266
 267    fn parent_dir(&self) -> Option<String> {
 268        self.repo_path
 269            .parent()
 270            .map(|parent| parent.to_string_lossy().into_owned())
 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        maybe!({
 830            let list_entry = self.entries.get(self.selected_entry?)?.clone();
 831            let entry = list_entry.status_entry()?.to_owned();
 832            let skip_prompt = action.skip_prompt || entry.status.is_created();
 833
 834            let prompt = if skip_prompt {
 835                Task::ready(Ok(0))
 836            } else {
 837                let prompt = window.prompt(
 838                    PromptLevel::Warning,
 839                    &format!(
 840                        "Are you sure you want to restore {}?",
 841                        entry
 842                            .repo_path
 843                            .file_name()
 844                            .unwrap_or(entry.repo_path.as_os_str())
 845                            .to_string_lossy()
 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_lossy();
 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_lossy())
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_lossy())
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_lossy();
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
1977                .split(std::path::MAIN_SEPARATOR_STR)
1978                .last()?
1979                .strip_suffix(".git")?
1980                .to_owned();
1981
1982            let fs = this.read_with(cx, |this, _| this.fs.clone()).ok()?;
1983
1984            let prompt_answer = match fs.git_clone(&repo, path.as_path()).await {
1985                Ok(_) => cx.update(|window, cx| {
1986                    window.prompt(
1987                        PromptLevel::Info,
1988                        &format!("Git Clone: {}", repo_name),
1989                        None,
1990                        &["Add repo to project", "Open repo in new project"],
1991                        cx,
1992                    )
1993                }),
1994                Err(e) => {
1995                    this.update(cx, |this: &mut GitPanel, cx| {
1996                        let toast = StatusToast::new(e.to_string(), cx, |this, _| {
1997                            this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
1998                                .dismiss_button(true)
1999                        });
2000
2001                        this.workspace
2002                            .update(cx, |workspace, cx| {
2003                                workspace.toggle_status_toast(toast, cx);
2004                            })
2005                            .ok();
2006                    })
2007                    .ok()?;
2008
2009                    return None;
2010                }
2011            }
2012            .ok()?;
2013
2014            path.push(repo_name);
2015            match prompt_answer.await.ok()? {
2016                0 => {
2017                    workspace
2018                        .update(cx, |workspace, cx| {
2019                            workspace
2020                                .project()
2021                                .update(cx, |project, cx| {
2022                                    project.create_worktree(path.as_path(), true, cx)
2023                                })
2024                                .detach();
2025                        })
2026                        .ok();
2027                }
2028                1 => {
2029                    workspace
2030                        .update(cx, move |workspace, cx| {
2031                            workspace::open_new(
2032                                Default::default(),
2033                                workspace.app_state().clone(),
2034                                cx,
2035                                move |workspace, _, cx| {
2036                                    cx.activate(true);
2037                                    workspace
2038                                        .project()
2039                                        .update(cx, |project, cx| {
2040                                            project.create_worktree(&path, true, cx)
2041                                        })
2042                                        .detach();
2043                                },
2044                            )
2045                            .detach();
2046                        })
2047                        .ok();
2048                }
2049                _ => {}
2050            }
2051
2052            Some(())
2053        })
2054        .detach();
2055    }
2056
2057    pub(crate) fn git_init(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2058        let worktrees = self
2059            .project
2060            .read(cx)
2061            .visible_worktrees(cx)
2062            .collect::<Vec<_>>();
2063
2064        let worktree = if worktrees.len() == 1 {
2065            Task::ready(Some(worktrees.first().unwrap().clone()))
2066        } else if worktrees.is_empty() {
2067            let result = window.prompt(
2068                PromptLevel::Warning,
2069                "Unable to initialize a git repository",
2070                Some("Open a directory first"),
2071                &["Ok"],
2072                cx,
2073            );
2074            cx.background_executor()
2075                .spawn(async move {
2076                    result.await.ok();
2077                })
2078                .detach();
2079            return;
2080        } else {
2081            let worktree_directories = worktrees
2082                .iter()
2083                .map(|worktree| worktree.read(cx).abs_path())
2084                .map(|worktree_abs_path| {
2085                    if let Ok(path) = worktree_abs_path.strip_prefix(util::paths::home_dir()) {
2086                        Path::new("~")
2087                            .join(path)
2088                            .to_string_lossy()
2089                            .to_string()
2090                            .into()
2091                    } else {
2092                        worktree_abs_path.to_string_lossy().to_string().into()
2093                    }
2094                })
2095                .collect_vec();
2096            let prompt = picker_prompt::prompt(
2097                "Where would you like to initialize this git repository?",
2098                worktree_directories,
2099                self.workspace.clone(),
2100                window,
2101                cx,
2102            );
2103
2104            cx.spawn(async move |_, _| prompt.await.map(|ix| worktrees[ix].clone()))
2105        };
2106
2107        cx.spawn_in(window, async move |this, cx| {
2108            let worktree = match worktree.await {
2109                Some(worktree) => worktree,
2110                None => {
2111                    return;
2112                }
2113            };
2114
2115            let Ok(result) = this.update(cx, |this, cx| {
2116                let fallback_branch_name = GitPanelSettings::get_global(cx)
2117                    .fallback_branch_name
2118                    .clone();
2119                this.project.read(cx).git_init(
2120                    worktree.read(cx).abs_path(),
2121                    fallback_branch_name,
2122                    cx,
2123                )
2124            }) else {
2125                return;
2126            };
2127
2128            let result = result.await;
2129
2130            this.update_in(cx, |this, _, cx| match result {
2131                Ok(()) => {}
2132                Err(e) => this.show_error_toast("init", e, cx),
2133            })
2134            .ok();
2135        })
2136        .detach();
2137    }
2138
2139    pub(crate) fn pull(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2140        if !self.can_push_and_pull(cx) {
2141            return;
2142        }
2143        let Some(repo) = self.active_repository.clone() else {
2144            return;
2145        };
2146        let Some(branch) = repo.read(cx).branch.as_ref() else {
2147            return;
2148        };
2149        telemetry::event!("Git Pulled");
2150        let branch = branch.clone();
2151        let remote = self.get_remote(false, window, cx);
2152        cx.spawn_in(window, async move |this, cx| {
2153            let remote = match remote.await {
2154                Ok(Some(remote)) => remote,
2155                Ok(None) => {
2156                    return Ok(());
2157                }
2158                Err(e) => {
2159                    log::error!("Failed to get current remote: {}", e);
2160                    this.update(cx, |this, cx| this.show_error_toast("pull", e, cx))
2161                        .ok();
2162                    return Ok(());
2163                }
2164            };
2165
2166            let askpass = this.update_in(cx, |this, window, cx| {
2167                this.askpass_delegate(format!("git pull {}", remote.name), window, cx)
2168            })?;
2169
2170            let pull = repo.update(cx, |repo, cx| {
2171                repo.pull(
2172                    branch.name().to_owned().into(),
2173                    remote.name.clone(),
2174                    askpass,
2175                    cx,
2176                )
2177            })?;
2178
2179            let remote_message = pull.await?;
2180
2181            let action = RemoteAction::Pull(remote);
2182            this.update(cx, |this, cx| match remote_message {
2183                Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
2184                Err(e) => {
2185                    log::error!("Error while pulling {:?}", e);
2186                    this.show_error_toast(action.name(), e, cx)
2187                }
2188            })
2189            .ok();
2190
2191            anyhow::Ok(())
2192        })
2193        .detach_and_log_err(cx);
2194    }
2195
2196    pub(crate) fn push(
2197        &mut self,
2198        force_push: bool,
2199        select_remote: bool,
2200        window: &mut Window,
2201        cx: &mut Context<Self>,
2202    ) {
2203        if !self.can_push_and_pull(cx) {
2204            return;
2205        }
2206        let Some(repo) = self.active_repository.clone() else {
2207            return;
2208        };
2209        let Some(branch) = repo.read(cx).branch.as_ref() else {
2210            return;
2211        };
2212        telemetry::event!("Git Pushed");
2213        let branch = branch.clone();
2214
2215        let options = if force_push {
2216            Some(PushOptions::Force)
2217        } else {
2218            match branch.upstream {
2219                Some(Upstream {
2220                    tracking: UpstreamTracking::Gone,
2221                    ..
2222                })
2223                | None => Some(PushOptions::SetUpstream),
2224                _ => None,
2225            }
2226        };
2227        let remote = self.get_remote(select_remote, window, cx);
2228
2229        cx.spawn_in(window, async move |this, cx| {
2230            let remote = match remote.await {
2231                Ok(Some(remote)) => remote,
2232                Ok(None) => {
2233                    return Ok(());
2234                }
2235                Err(e) => {
2236                    log::error!("Failed to get current remote: {}", e);
2237                    this.update(cx, |this, cx| this.show_error_toast("push", e, cx))
2238                        .ok();
2239                    return Ok(());
2240                }
2241            };
2242
2243            let askpass_delegate = this.update_in(cx, |this, window, cx| {
2244                this.askpass_delegate(format!("git push {}", remote.name), window, cx)
2245            })?;
2246
2247            let push = repo.update(cx, |repo, cx| {
2248                repo.push(
2249                    branch.name().to_owned().into(),
2250                    remote.name.clone(),
2251                    options,
2252                    askpass_delegate,
2253                    cx,
2254                )
2255            })?;
2256
2257            let remote_output = push.await?;
2258
2259            let action = RemoteAction::Push(branch.name().to_owned().into(), remote);
2260            this.update(cx, |this, cx| match remote_output {
2261                Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
2262                Err(e) => {
2263                    log::error!("Error while pushing {:?}", e);
2264                    this.show_error_toast(action.name(), e, cx)
2265                }
2266            })?;
2267
2268            anyhow::Ok(())
2269        })
2270        .detach_and_log_err(cx);
2271    }
2272
2273    fn askpass_delegate(
2274        &self,
2275        operation: impl Into<SharedString>,
2276        window: &mut Window,
2277        cx: &mut Context<Self>,
2278    ) -> AskPassDelegate {
2279        let this = cx.weak_entity();
2280        let operation = operation.into();
2281        let window = window.window_handle();
2282        AskPassDelegate::new(&mut cx.to_async(), move |prompt, tx, cx| {
2283            window
2284                .update(cx, |_, window, cx| {
2285                    this.update(cx, |this, cx| {
2286                        this.workspace.update(cx, |workspace, cx| {
2287                            workspace.toggle_modal(window, cx, |window, cx| {
2288                                AskPassModal::new(operation.clone(), prompt.into(), tx, window, cx)
2289                            });
2290                        })
2291                    })
2292                })
2293                .ok();
2294        })
2295    }
2296
2297    fn can_push_and_pull(&self, cx: &App) -> bool {
2298        !self.project.read(cx).is_via_collab()
2299    }
2300
2301    fn get_remote(
2302        &mut self,
2303        always_select: bool,
2304        window: &mut Window,
2305        cx: &mut Context<Self>,
2306    ) -> impl Future<Output = anyhow::Result<Option<Remote>>> + use<> {
2307        let repo = self.active_repository.clone();
2308        let workspace = self.workspace.clone();
2309        let mut cx = window.to_async(cx);
2310
2311        async move {
2312            let repo = repo.context("No active repository")?;
2313            let current_remotes: Vec<Remote> = repo
2314                .update(&mut cx, |repo, _| {
2315                    let current_branch = if always_select {
2316                        None
2317                    } else {
2318                        let current_branch = repo.branch.as_ref().context("No active branch")?;
2319                        Some(current_branch.name().to_string())
2320                    };
2321                    anyhow::Ok(repo.get_remotes(current_branch))
2322                })??
2323                .await??;
2324
2325            let current_remotes: Vec<_> = current_remotes
2326                .into_iter()
2327                .map(|remotes| remotes.name)
2328                .collect();
2329            let selection = cx
2330                .update(|window, cx| {
2331                    picker_prompt::prompt(
2332                        "Pick which remote to push to",
2333                        current_remotes.clone(),
2334                        workspace,
2335                        window,
2336                        cx,
2337                    )
2338                })?
2339                .await;
2340
2341            Ok(selection.map(|selection| Remote {
2342                name: current_remotes[selection].clone(),
2343            }))
2344        }
2345    }
2346
2347    pub fn load_local_committer(&mut self, cx: &Context<Self>) {
2348        if self.local_committer_task.is_none() {
2349            self.local_committer_task = Some(cx.spawn(async move |this, cx| {
2350                let committer = get_git_committer(cx).await;
2351                this.update(cx, |this, cx| {
2352                    this.local_committer = Some(committer);
2353                    cx.notify()
2354                })
2355                .ok();
2356            }));
2357        }
2358    }
2359
2360    fn potential_co_authors(&self, cx: &App) -> Vec<(String, String)> {
2361        let mut new_co_authors = Vec::new();
2362        let project = self.project.read(cx);
2363
2364        let Some(room) = self
2365            .workspace
2366            .upgrade()
2367            .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned())
2368        else {
2369            return Vec::default();
2370        };
2371
2372        let room = room.read(cx);
2373
2374        for (peer_id, collaborator) in project.collaborators() {
2375            if collaborator.is_host {
2376                continue;
2377            }
2378
2379            let Some(participant) = room.remote_participant_for_peer_id(*peer_id) else {
2380                continue;
2381            };
2382            if !participant.can_write() {
2383                continue;
2384            }
2385            if let Some(email) = &collaborator.committer_email {
2386                let name = collaborator
2387                    .committer_name
2388                    .clone()
2389                    .or_else(|| participant.user.name.clone())
2390                    .unwrap_or_else(|| participant.user.github_login.clone().to_string());
2391                new_co_authors.push((name.clone(), email.clone()))
2392            }
2393        }
2394        if !project.is_local()
2395            && !project.is_read_only(cx)
2396            && let Some(local_committer) = self.local_committer(room, cx)
2397        {
2398            new_co_authors.push(local_committer);
2399        }
2400        new_co_authors
2401    }
2402
2403    fn local_committer(&self, room: &call::Room, cx: &App) -> Option<(String, String)> {
2404        let user = room.local_participant_user(cx)?;
2405        let committer = self.local_committer.as_ref()?;
2406        let email = committer.email.clone()?;
2407        let name = committer
2408            .name
2409            .clone()
2410            .or_else(|| user.name.clone())
2411            .unwrap_or_else(|| user.github_login.clone().to_string());
2412        Some((name, email))
2413    }
2414
2415    fn toggle_fill_co_authors(
2416        &mut self,
2417        _: &ToggleFillCoAuthors,
2418        _: &mut Window,
2419        cx: &mut Context<Self>,
2420    ) {
2421        self.add_coauthors = !self.add_coauthors;
2422        cx.notify();
2423    }
2424
2425    fn toggle_sort_by_path(
2426        &mut self,
2427        _: &ToggleSortByPath,
2428        _: &mut Window,
2429        cx: &mut Context<Self>,
2430    ) {
2431        let current_setting = GitPanelSettings::get_global(cx).sort_by_path;
2432        if let Some(workspace) = self.workspace.upgrade() {
2433            let workspace = workspace.read(cx);
2434            let fs = workspace.app_state().fs.clone();
2435            cx.update_global::<SettingsStore, _>(|store, _cx| {
2436                store.update_settings_file(fs, move |settings, _cx| {
2437                    settings.git_panel.get_or_insert_default().sort_by_path =
2438                        Some(!current_setting);
2439                });
2440            });
2441        }
2442    }
2443
2444    fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context<Self>) {
2445        const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
2446
2447        let existing_text = message.to_ascii_lowercase();
2448        let lowercase_co_author_prefix = CO_AUTHOR_PREFIX.to_lowercase();
2449        let mut ends_with_co_authors = false;
2450        let existing_co_authors = existing_text
2451            .lines()
2452            .filter_map(|line| {
2453                let line = line.trim();
2454                if line.starts_with(&lowercase_co_author_prefix) {
2455                    ends_with_co_authors = true;
2456                    Some(line)
2457                } else {
2458                    ends_with_co_authors = false;
2459                    None
2460                }
2461            })
2462            .collect::<HashSet<_>>();
2463
2464        let new_co_authors = self
2465            .potential_co_authors(cx)
2466            .into_iter()
2467            .filter(|(_, email)| {
2468                !existing_co_authors
2469                    .iter()
2470                    .any(|existing| existing.contains(email.as_str()))
2471            })
2472            .collect::<Vec<_>>();
2473
2474        if new_co_authors.is_empty() {
2475            return;
2476        }
2477
2478        if !ends_with_co_authors {
2479            message.push('\n');
2480        }
2481        for (name, email) in new_co_authors {
2482            message.push('\n');
2483            message.push_str(CO_AUTHOR_PREFIX);
2484            message.push_str(&name);
2485            message.push_str(" <");
2486            message.push_str(&email);
2487            message.push('>');
2488        }
2489        message.push('\n');
2490    }
2491
2492    fn schedule_update(
2493        &mut self,
2494        clear_pending: bool,
2495        window: &mut Window,
2496        cx: &mut Context<Self>,
2497    ) {
2498        let handle = cx.entity().downgrade();
2499        self.reopen_commit_buffer(window, cx);
2500        self.update_visible_entries_task = cx.spawn_in(window, async move |_, cx| {
2501            cx.background_executor().timer(UPDATE_DEBOUNCE).await;
2502            if let Some(git_panel) = handle.upgrade() {
2503                git_panel
2504                    .update_in(cx, |git_panel, window, cx| {
2505                        if clear_pending {
2506                            git_panel.clear_pending();
2507                        }
2508                        git_panel.update_visible_entries(window, cx);
2509                    })
2510                    .ok();
2511            }
2512        });
2513    }
2514
2515    fn reopen_commit_buffer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2516        let Some(active_repo) = self.active_repository.as_ref() else {
2517            return;
2518        };
2519        let load_buffer = active_repo.update(cx, |active_repo, cx| {
2520            let project = self.project.read(cx);
2521            active_repo.open_commit_buffer(
2522                Some(project.languages().clone()),
2523                project.buffer_store().clone(),
2524                cx,
2525            )
2526        });
2527
2528        cx.spawn_in(window, async move |git_panel, cx| {
2529            let buffer = load_buffer.await?;
2530            git_panel.update_in(cx, |git_panel, window, cx| {
2531                if git_panel
2532                    .commit_editor
2533                    .read(cx)
2534                    .buffer()
2535                    .read(cx)
2536                    .as_singleton()
2537                    .as_ref()
2538                    != Some(&buffer)
2539                {
2540                    git_panel.commit_editor = cx.new(|cx| {
2541                        commit_message_editor(
2542                            buffer,
2543                            git_panel.suggest_commit_message(cx).map(SharedString::from),
2544                            git_panel.project.clone(),
2545                            true,
2546                            window,
2547                            cx,
2548                        )
2549                    });
2550                }
2551            })
2552        })
2553        .detach_and_log_err(cx);
2554    }
2555
2556    fn clear_pending(&mut self) {
2557        self.pending.retain(|v| !v.finished)
2558    }
2559
2560    fn update_visible_entries(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2561        let bulk_staging = self.bulk_staging.take();
2562        let last_staged_path_prev_index = bulk_staging
2563            .as_ref()
2564            .and_then(|op| self.entry_by_path(&op.anchor, cx));
2565
2566        self.entries.clear();
2567        self.single_staged_entry.take();
2568        self.single_tracked_entry.take();
2569        self.conflicted_count = 0;
2570        self.conflicted_staged_count = 0;
2571        self.new_count = 0;
2572        self.tracked_count = 0;
2573        self.new_staged_count = 0;
2574        self.tracked_staged_count = 0;
2575        self.entry_count = 0;
2576
2577        let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
2578
2579        let mut changed_entries = Vec::new();
2580        let mut new_entries = Vec::new();
2581        let mut conflict_entries = Vec::new();
2582        let mut single_staged_entry = None;
2583        let mut staged_count = 0;
2584        let mut max_width_item: Option<(RepoPath, usize)> = None;
2585
2586        let Some(repo) = self.active_repository.as_ref() else {
2587            // Just clear entries if no repository is active.
2588            cx.notify();
2589            return;
2590        };
2591
2592        let repo = repo.read(cx);
2593
2594        self.stash_entries = repo.cached_stash();
2595
2596        for entry in repo.cached_status() {
2597            let is_conflict = repo.had_conflict_on_last_merge_head_change(&entry.repo_path);
2598            let is_new = entry.status.is_created();
2599            let staging = entry.status.staging();
2600
2601            if self.pending.iter().any(|pending| {
2602                pending.target_status == TargetStatus::Reverted
2603                    && !pending.finished
2604                    && pending
2605                        .entries
2606                        .iter()
2607                        .any(|pending| pending.repo_path == entry.repo_path)
2608            }) {
2609                continue;
2610            }
2611
2612            let abs_path = repo.work_directory_abs_path.join(&entry.repo_path.0);
2613            let entry = GitStatusEntry {
2614                repo_path: entry.repo_path.clone(),
2615                abs_path,
2616                status: entry.status,
2617                staging,
2618            };
2619
2620            if staging.has_staged() {
2621                staged_count += 1;
2622                single_staged_entry = Some(entry.clone());
2623            }
2624
2625            let width_estimate = Self::item_width_estimate(
2626                entry.parent_dir().map(|s| s.len()).unwrap_or(0),
2627                entry.display_name().len(),
2628            );
2629
2630            match max_width_item.as_mut() {
2631                Some((repo_path, estimate)) => {
2632                    if width_estimate > *estimate {
2633                        *repo_path = entry.repo_path.clone();
2634                        *estimate = width_estimate;
2635                    }
2636                }
2637                None => max_width_item = Some((entry.repo_path.clone(), width_estimate)),
2638            }
2639
2640            if sort_by_path {
2641                changed_entries.push(entry);
2642            } else if is_conflict {
2643                conflict_entries.push(entry);
2644            } else if is_new {
2645                new_entries.push(entry);
2646            } else {
2647                changed_entries.push(entry);
2648            }
2649        }
2650
2651        let mut pending_staged_count = 0;
2652        let mut last_pending_staged = None;
2653        let mut pending_status_for_single_staged = None;
2654        for pending in self.pending.iter() {
2655            if pending.target_status == TargetStatus::Staged {
2656                pending_staged_count += pending.entries.len();
2657                last_pending_staged = pending.entries.first().cloned();
2658            }
2659            if let Some(single_staged) = &single_staged_entry
2660                && pending
2661                    .entries
2662                    .iter()
2663                    .any(|entry| entry.repo_path == single_staged.repo_path)
2664            {
2665                pending_status_for_single_staged = Some(pending.target_status);
2666            }
2667        }
2668
2669        if conflict_entries.is_empty() && staged_count == 1 && pending_staged_count == 0 {
2670            match pending_status_for_single_staged {
2671                Some(TargetStatus::Staged) | None => {
2672                    self.single_staged_entry = single_staged_entry;
2673                }
2674                _ => {}
2675            }
2676        } else if conflict_entries.is_empty() && pending_staged_count == 1 {
2677            self.single_staged_entry = last_pending_staged;
2678        }
2679
2680        if conflict_entries.is_empty() && changed_entries.len() == 1 {
2681            self.single_tracked_entry = changed_entries.first().cloned();
2682        }
2683
2684        if !conflict_entries.is_empty() {
2685            self.entries.push(GitListEntry::Header(GitHeaderEntry {
2686                header: Section::Conflict,
2687            }));
2688            self.entries
2689                .extend(conflict_entries.into_iter().map(GitListEntry::Status));
2690        }
2691
2692        if !changed_entries.is_empty() {
2693            if !sort_by_path {
2694                self.entries.push(GitListEntry::Header(GitHeaderEntry {
2695                    header: Section::Tracked,
2696                }));
2697            }
2698            self.entries
2699                .extend(changed_entries.into_iter().map(GitListEntry::Status));
2700        }
2701        if !new_entries.is_empty() {
2702            self.entries.push(GitListEntry::Header(GitHeaderEntry {
2703                header: Section::New,
2704            }));
2705            self.entries
2706                .extend(new_entries.into_iter().map(GitListEntry::Status));
2707        }
2708
2709        if let Some((repo_path, _)) = max_width_item {
2710            self.max_width_item_index = self.entries.iter().position(|entry| match entry {
2711                GitListEntry::Status(git_status_entry) => git_status_entry.repo_path == repo_path,
2712                GitListEntry::Header(_) => false,
2713            });
2714        }
2715
2716        self.update_counts(repo);
2717
2718        let bulk_staging_anchor_new_index = bulk_staging
2719            .as_ref()
2720            .filter(|op| op.repo_id == repo.id)
2721            .and_then(|op| self.entry_by_path(&op.anchor, cx));
2722        if bulk_staging_anchor_new_index == last_staged_path_prev_index
2723            && let Some(index) = bulk_staging_anchor_new_index
2724            && let Some(entry) = self.entries.get(index)
2725            && let Some(entry) = entry.status_entry()
2726            && self.entry_staging(entry) == StageStatus::Staged
2727        {
2728            self.bulk_staging = bulk_staging;
2729        }
2730
2731        self.select_first_entry_if_none(cx);
2732
2733        let suggested_commit_message = self.suggest_commit_message(cx);
2734        let placeholder_text = suggested_commit_message.unwrap_or("Enter commit message".into());
2735
2736        self.commit_editor.update(cx, |editor, cx| {
2737            editor.set_placeholder_text(&placeholder_text, window, cx)
2738        });
2739
2740        cx.notify();
2741    }
2742
2743    fn header_state(&self, header_type: Section) -> ToggleState {
2744        let (staged_count, count) = match header_type {
2745            Section::New => (self.new_staged_count, self.new_count),
2746            Section::Tracked => (self.tracked_staged_count, self.tracked_count),
2747            Section::Conflict => (self.conflicted_staged_count, self.conflicted_count),
2748        };
2749        if staged_count == 0 {
2750            ToggleState::Unselected
2751        } else if count == staged_count {
2752            ToggleState::Selected
2753        } else {
2754            ToggleState::Indeterminate
2755        }
2756    }
2757
2758    fn update_counts(&mut self, repo: &Repository) {
2759        self.show_placeholders = false;
2760        self.conflicted_count = 0;
2761        self.conflicted_staged_count = 0;
2762        self.new_count = 0;
2763        self.tracked_count = 0;
2764        self.new_staged_count = 0;
2765        self.tracked_staged_count = 0;
2766        self.entry_count = 0;
2767        for entry in &self.entries {
2768            let Some(status_entry) = entry.status_entry() else {
2769                continue;
2770            };
2771            self.entry_count += 1;
2772            if repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path) {
2773                self.conflicted_count += 1;
2774                if self.entry_staging(status_entry).has_staged() {
2775                    self.conflicted_staged_count += 1;
2776                }
2777            } else if status_entry.status.is_created() {
2778                self.new_count += 1;
2779                if self.entry_staging(status_entry).has_staged() {
2780                    self.new_staged_count += 1;
2781                }
2782            } else {
2783                self.tracked_count += 1;
2784                if self.entry_staging(status_entry).has_staged() {
2785                    self.tracked_staged_count += 1;
2786                }
2787            }
2788        }
2789    }
2790
2791    fn entry_staging(&self, entry: &GitStatusEntry) -> StageStatus {
2792        for pending in self.pending.iter().rev() {
2793            if pending
2794                .entries
2795                .iter()
2796                .any(|pending_entry| pending_entry.repo_path == entry.repo_path)
2797            {
2798                match pending.target_status {
2799                    TargetStatus::Staged => return StageStatus::Staged,
2800                    TargetStatus::Unstaged => return StageStatus::Unstaged,
2801                    TargetStatus::Reverted => continue,
2802                    TargetStatus::Unchanged => continue,
2803                }
2804            }
2805        }
2806        entry.staging
2807    }
2808
2809    pub(crate) fn has_staged_changes(&self) -> bool {
2810        self.tracked_staged_count > 0
2811            || self.new_staged_count > 0
2812            || self.conflicted_staged_count > 0
2813    }
2814
2815    pub(crate) fn has_unstaged_changes(&self) -> bool {
2816        self.tracked_count > self.tracked_staged_count
2817            || self.new_count > self.new_staged_count
2818            || self.conflicted_count > self.conflicted_staged_count
2819    }
2820
2821    fn has_tracked_changes(&self) -> bool {
2822        self.tracked_count > 0
2823    }
2824
2825    pub fn has_unstaged_conflicts(&self) -> bool {
2826        self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count
2827    }
2828
2829    fn show_error_toast(&self, action: impl Into<SharedString>, e: anyhow::Error, cx: &mut App) {
2830        let action = action.into();
2831        let Some(workspace) = self.workspace.upgrade() else {
2832            return;
2833        };
2834
2835        let message = e.to_string().trim().to_string();
2836        if message
2837            .matches(git::repository::REMOTE_CANCELLED_BY_USER)
2838            .next()
2839            .is_some()
2840        { // Hide the cancelled by user message
2841        } else {
2842            workspace.update(cx, |workspace, cx| {
2843                let workspace_weak = cx.weak_entity();
2844                let toast = StatusToast::new(format!("git {} failed", action), cx, |this, _cx| {
2845                    this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
2846                        .action("View Log", move |window, cx| {
2847                            let message = message.clone();
2848                            let action = action.clone();
2849                            workspace_weak
2850                                .update(cx, move |workspace, cx| {
2851                                    Self::open_output(action, workspace, &message, window, cx)
2852                                })
2853                                .ok();
2854                        })
2855                });
2856                workspace.toggle_status_toast(toast, cx)
2857            });
2858        }
2859    }
2860
2861    fn show_commit_message_error<E>(weak_this: &WeakEntity<Self>, err: &E, cx: &mut AsyncApp)
2862    where
2863        E: std::fmt::Debug + std::fmt::Display,
2864    {
2865        if let Ok(Some(workspace)) = weak_this.update(cx, |this, _cx| this.workspace.upgrade()) {
2866            let _ = workspace.update(cx, |workspace, cx| {
2867                struct CommitMessageError;
2868                let notification_id = NotificationId::unique::<CommitMessageError>();
2869                workspace.show_notification(notification_id, cx, |cx| {
2870                    cx.new(|cx| {
2871                        ErrorMessagePrompt::new(
2872                            format!("Failed to generate commit message: {err}"),
2873                            cx,
2874                        )
2875                    })
2876                });
2877            });
2878        }
2879    }
2880
2881    fn show_remote_output(&self, action: RemoteAction, info: RemoteCommandOutput, cx: &mut App) {
2882        let Some(workspace) = self.workspace.upgrade() else {
2883            return;
2884        };
2885
2886        workspace.update(cx, |workspace, cx| {
2887            let SuccessMessage { message, style } = remote_output::format_output(&action, info);
2888            let workspace_weak = cx.weak_entity();
2889            let operation = action.name();
2890
2891            let status_toast = StatusToast::new(message, cx, move |this, _cx| {
2892                use remote_output::SuccessStyle::*;
2893                match style {
2894                    Toast => this.icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted)),
2895                    ToastWithLog { output } => this
2896                        .icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted))
2897                        .action("View Log", move |window, cx| {
2898                            let output = output.clone();
2899                            let output =
2900                                format!("stdout:\n{}\nstderr:\n{}", output.stdout, output.stderr);
2901                            workspace_weak
2902                                .update(cx, move |workspace, cx| {
2903                                    Self::open_output(operation, workspace, &output, window, cx)
2904                                })
2905                                .ok();
2906                        }),
2907                    PushPrLink { text, link } => this
2908                        .icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted))
2909                        .action(text, move |_, cx| cx.open_url(&link)),
2910                }
2911            });
2912            workspace.toggle_status_toast(status_toast, cx)
2913        });
2914    }
2915
2916    fn open_output(
2917        operation: impl Into<SharedString>,
2918        workspace: &mut Workspace,
2919        output: &str,
2920        window: &mut Window,
2921        cx: &mut Context<Workspace>,
2922    ) {
2923        let operation = operation.into();
2924        let buffer = cx.new(|cx| Buffer::local(output, cx));
2925        buffer.update(cx, |buffer, cx| {
2926            buffer.set_capability(language::Capability::ReadOnly, cx);
2927        });
2928        let editor = cx.new(|cx| {
2929            let mut editor = Editor::for_buffer(buffer, None, window, cx);
2930            editor.buffer().update(cx, |buffer, cx| {
2931                buffer.set_title(format!("Output from git {operation}"), cx);
2932            });
2933            editor.set_read_only(true);
2934            editor
2935        });
2936
2937        workspace.add_item_to_center(Box::new(editor), window, cx);
2938    }
2939
2940    pub fn can_commit(&self) -> bool {
2941        (self.has_staged_changes() || self.has_tracked_changes()) && !self.has_unstaged_conflicts()
2942    }
2943
2944    pub fn can_stage_all(&self) -> bool {
2945        self.has_unstaged_changes()
2946    }
2947
2948    pub fn can_unstage_all(&self) -> bool {
2949        self.has_staged_changes()
2950    }
2951
2952    // eventually we'll need to take depth into account here
2953    // if we add a tree view
2954    fn item_width_estimate(path: usize, file_name: usize) -> usize {
2955        path + file_name
2956    }
2957
2958    fn render_overflow_menu(&self, id: impl Into<ElementId>) -> impl IntoElement {
2959        let focus_handle = self.focus_handle.clone();
2960        let has_tracked_changes = self.has_tracked_changes();
2961        let has_staged_changes = self.has_staged_changes();
2962        let has_unstaged_changes = self.has_unstaged_changes();
2963        let has_new_changes = self.new_count > 0;
2964        let has_stash_items = self.stash_entries.entries.len() > 0;
2965
2966        PopoverMenu::new(id.into())
2967            .trigger(
2968                IconButton::new("overflow-menu-trigger", IconName::Ellipsis)
2969                    .icon_size(IconSize::Small)
2970                    .icon_color(Color::Muted),
2971            )
2972            .menu(move |window, cx| {
2973                Some(git_panel_context_menu(
2974                    focus_handle.clone(),
2975                    GitMenuState {
2976                        has_tracked_changes,
2977                        has_staged_changes,
2978                        has_unstaged_changes,
2979                        has_new_changes,
2980                        sort_by_path: GitPanelSettings::get_global(cx).sort_by_path,
2981                        has_stash_items,
2982                    },
2983                    window,
2984                    cx,
2985                ))
2986            })
2987            .anchor(Corner::TopRight)
2988    }
2989
2990    pub(crate) fn render_generate_commit_message_button(
2991        &self,
2992        cx: &Context<Self>,
2993    ) -> Option<AnyElement> {
2994        if !agent_settings::AgentSettings::get_global(cx).enabled(cx)
2995            || LanguageModelRegistry::read_global(cx)
2996                .commit_message_model()
2997                .is_none()
2998        {
2999            return None;
3000        }
3001
3002        if self.generate_commit_message_task.is_some() {
3003            return Some(
3004                h_flex()
3005                    .gap_1()
3006                    .child(
3007                        Icon::new(IconName::ArrowCircle)
3008                            .size(IconSize::XSmall)
3009                            .color(Color::Info)
3010                            .with_rotate_animation(2),
3011                    )
3012                    .child(
3013                        Label::new("Generating Commit...")
3014                            .size(LabelSize::Small)
3015                            .color(Color::Muted),
3016                    )
3017                    .into_any_element(),
3018            );
3019        }
3020
3021        let can_commit = self.can_commit();
3022        let editor_focus_handle = self.commit_editor.focus_handle(cx);
3023        Some(
3024            IconButton::new("generate-commit-message", IconName::AiEdit)
3025                .shape(ui::IconButtonShape::Square)
3026                .icon_color(Color::Muted)
3027                .tooltip(move |window, cx| {
3028                    if can_commit {
3029                        Tooltip::for_action_in(
3030                            "Generate Commit Message",
3031                            &git::GenerateCommitMessage,
3032                            &editor_focus_handle,
3033                            window,
3034                            cx,
3035                        )
3036                    } else {
3037                        Tooltip::simple("No changes to commit", cx)
3038                    }
3039                })
3040                .disabled(!can_commit)
3041                .on_click(cx.listener(move |this, _event, _window, cx| {
3042                    this.generate_commit_message(cx);
3043                }))
3044                .into_any_element(),
3045        )
3046    }
3047
3048    pub(crate) fn render_co_authors(&self, cx: &Context<Self>) -> Option<AnyElement> {
3049        let potential_co_authors = self.potential_co_authors(cx);
3050
3051        let (tooltip_label, icon) = if self.add_coauthors {
3052            ("Remove co-authored-by", IconName::Person)
3053        } else {
3054            ("Add co-authored-by", IconName::UserCheck)
3055        };
3056
3057        if potential_co_authors.is_empty() {
3058            None
3059        } else {
3060            Some(
3061                IconButton::new("co-authors", icon)
3062                    .shape(ui::IconButtonShape::Square)
3063                    .icon_color(Color::Disabled)
3064                    .selected_icon_color(Color::Selected)
3065                    .toggle_state(self.add_coauthors)
3066                    .tooltip(move |_, cx| {
3067                        let title = format!(
3068                            "{}:{}{}",
3069                            tooltip_label,
3070                            if potential_co_authors.len() == 1 {
3071                                ""
3072                            } else {
3073                                "\n"
3074                            },
3075                            potential_co_authors
3076                                .iter()
3077                                .map(|(name, email)| format!(" {} <{}>", name, email))
3078                                .join("\n")
3079                        );
3080                        Tooltip::simple(title, cx)
3081                    })
3082                    .on_click(cx.listener(|this, _, _, cx| {
3083                        this.add_coauthors = !this.add_coauthors;
3084                        cx.notify();
3085                    }))
3086                    .into_any_element(),
3087            )
3088        }
3089    }
3090
3091    fn render_git_commit_menu(
3092        &self,
3093        id: impl Into<ElementId>,
3094        keybinding_target: Option<FocusHandle>,
3095        cx: &mut Context<Self>,
3096    ) -> impl IntoElement {
3097        PopoverMenu::new(id.into())
3098            .trigger(
3099                ui::ButtonLike::new_rounded_right("commit-split-button-right")
3100                    .layer(ui::ElevationIndex::ModalSurface)
3101                    .size(ButtonSize::None)
3102                    .child(
3103                        h_flex()
3104                            .px_1()
3105                            .h_full()
3106                            .justify_center()
3107                            .border_l_1()
3108                            .border_color(cx.theme().colors().border)
3109                            .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
3110                    ),
3111            )
3112            .menu({
3113                let git_panel = cx.entity();
3114                let has_previous_commit = self.head_commit(cx).is_some();
3115                let amend = self.amend_pending();
3116                let signoff = self.signoff_enabled;
3117
3118                move |window, cx| {
3119                    Some(ContextMenu::build(window, cx, |context_menu, _, _| {
3120                        context_menu
3121                            .when_some(keybinding_target.clone(), |el, keybinding_target| {
3122                                el.context(keybinding_target)
3123                            })
3124                            .when(has_previous_commit, |this| {
3125                                this.toggleable_entry(
3126                                    "Amend",
3127                                    amend,
3128                                    IconPosition::Start,
3129                                    Some(Box::new(Amend)),
3130                                    {
3131                                        let git_panel = git_panel.downgrade();
3132                                        move |_, cx| {
3133                                            git_panel
3134                                                .update(cx, |git_panel, cx| {
3135                                                    git_panel.toggle_amend_pending(cx);
3136                                                })
3137                                                .ok();
3138                                        }
3139                                    },
3140                                )
3141                            })
3142                            .toggleable_entry(
3143                                "Signoff",
3144                                signoff,
3145                                IconPosition::Start,
3146                                Some(Box::new(Signoff)),
3147                                move |window, cx| window.dispatch_action(Box::new(Signoff), cx),
3148                            )
3149                    }))
3150                }
3151            })
3152            .anchor(Corner::TopRight)
3153    }
3154
3155    pub fn configure_commit_button(&self, cx: &mut Context<Self>) -> (bool, &'static str) {
3156        if self.has_unstaged_conflicts() {
3157            (false, "You must resolve conflicts before committing")
3158        } else if !self.has_staged_changes() && !self.has_tracked_changes() && !self.amend_pending {
3159            (false, "No changes to commit")
3160        } else if self.pending_commit.is_some() {
3161            (false, "Commit in progress")
3162        } else if !self.has_commit_message(cx) {
3163            (false, "No commit message")
3164        } else if !self.has_write_access(cx) {
3165            (false, "You do not have write access to this project")
3166        } else {
3167            (true, self.commit_button_title())
3168        }
3169    }
3170
3171    pub fn commit_button_title(&self) -> &'static str {
3172        if self.amend_pending {
3173            if self.has_staged_changes() {
3174                "Amend"
3175            } else if self.has_tracked_changes() {
3176                "Amend Tracked"
3177            } else {
3178                "Amend"
3179            }
3180        } else if self.has_staged_changes() {
3181            "Commit"
3182        } else {
3183            "Commit Tracked"
3184        }
3185    }
3186
3187    fn expand_commit_editor(
3188        &mut self,
3189        _: &git::ExpandCommitEditor,
3190        window: &mut Window,
3191        cx: &mut Context<Self>,
3192    ) {
3193        let workspace = self.workspace.clone();
3194        window.defer(cx, move |window, cx| {
3195            workspace
3196                .update(cx, |workspace, cx| {
3197                    CommitModal::toggle(workspace, None, window, cx)
3198                })
3199                .ok();
3200        })
3201    }
3202
3203    fn render_panel_header(
3204        &self,
3205        window: &mut Window,
3206        cx: &mut Context<Self>,
3207    ) -> Option<impl IntoElement> {
3208        self.active_repository.as_ref()?;
3209
3210        let text;
3211        let action;
3212        let tooltip;
3213        if self.total_staged_count() == self.entry_count && self.entry_count > 0 {
3214            text = "Unstage All";
3215            action = git::UnstageAll.boxed_clone();
3216            tooltip = "git reset";
3217        } else {
3218            text = "Stage All";
3219            action = git::StageAll.boxed_clone();
3220            tooltip = "git add --all ."
3221        }
3222
3223        let change_string = match self.entry_count {
3224            0 => "No Changes".to_string(),
3225            1 => "1 Change".to_string(),
3226            _ => format!("{} Changes", self.entry_count),
3227        };
3228
3229        Some(
3230            self.panel_header_container(window, cx)
3231                .px_2()
3232                .justify_between()
3233                .child(
3234                    panel_button(change_string)
3235                        .color(Color::Muted)
3236                        .tooltip(Tooltip::for_action_title_in(
3237                            "Open Diff",
3238                            &Diff,
3239                            &self.focus_handle,
3240                        ))
3241                        .on_click(|_, _, cx| {
3242                            cx.defer(|cx| {
3243                                cx.dispatch_action(&Diff);
3244                            })
3245                        }),
3246                )
3247                .child(
3248                    h_flex()
3249                        .gap_1()
3250                        .child(self.render_overflow_menu("overflow_menu"))
3251                        .child(
3252                            panel_filled_button(text)
3253                                .tooltip(Tooltip::for_action_title_in(
3254                                    tooltip,
3255                                    action.as_ref(),
3256                                    &self.focus_handle,
3257                                ))
3258                                .disabled(self.entry_count == 0)
3259                                .on_click(move |_, _, cx| {
3260                                    let action = action.boxed_clone();
3261                                    cx.defer(move |cx| {
3262                                        cx.dispatch_action(action.as_ref());
3263                                    })
3264                                }),
3265                        ),
3266                ),
3267        )
3268    }
3269
3270    pub(crate) fn render_remote_button(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
3271        let branch = self.active_repository.as_ref()?.read(cx).branch.clone();
3272        if !self.can_push_and_pull(cx) {
3273            return None;
3274        }
3275        Some(
3276            h_flex()
3277                .gap_1()
3278                .flex_shrink_0()
3279                .when_some(branch, |this, branch| {
3280                    let focus_handle = Some(self.focus_handle(cx));
3281
3282                    this.children(render_remote_button(
3283                        "remote-button",
3284                        &branch,
3285                        focus_handle,
3286                        true,
3287                    ))
3288                })
3289                .into_any_element(),
3290        )
3291    }
3292
3293    pub fn render_footer(
3294        &self,
3295        window: &mut Window,
3296        cx: &mut Context<Self>,
3297    ) -> Option<impl IntoElement> {
3298        let active_repository = self.active_repository.clone()?;
3299        let panel_editor_style = panel_editor_style(true, window, cx);
3300
3301        let enable_coauthors = self.render_co_authors(cx);
3302
3303        let editor_focus_handle = self.commit_editor.focus_handle(cx);
3304        let expand_tooltip_focus_handle = editor_focus_handle;
3305
3306        let branch = active_repository.read(cx).branch.clone();
3307        let head_commit = active_repository.read(cx).head_commit.clone();
3308
3309        let footer_size = px(32.);
3310        let gap = px(9.0);
3311        let max_height = panel_editor_style
3312            .text
3313            .line_height_in_pixels(window.rem_size())
3314            * MAX_PANEL_EDITOR_LINES
3315            + gap;
3316
3317        let git_panel = cx.entity();
3318        let display_name = SharedString::from(Arc::from(
3319            active_repository
3320                .read(cx)
3321                .display_name()
3322                .trim_end_matches("/"),
3323        ));
3324        let editor_is_long = self.commit_editor.update(cx, |editor, cx| {
3325            editor.max_point(cx).row().0 >= MAX_PANEL_EDITOR_LINES as u32
3326        });
3327
3328        let footer = v_flex()
3329            .child(PanelRepoFooter::new(
3330                display_name,
3331                branch,
3332                head_commit,
3333                Some(git_panel),
3334            ))
3335            .child(
3336                panel_editor_container(window, cx)
3337                    .id("commit-editor-container")
3338                    .relative()
3339                    .w_full()
3340                    .h(max_height + footer_size)
3341                    .border_t_1()
3342                    .border_color(cx.theme().colors().border)
3343                    .cursor_text()
3344                    .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
3345                        window.focus(&this.commit_editor.focus_handle(cx));
3346                    }))
3347                    .child(
3348                        h_flex()
3349                            .id("commit-footer")
3350                            .border_t_1()
3351                            .when(editor_is_long, |el| {
3352                                el.border_color(cx.theme().colors().border_variant)
3353                            })
3354                            .absolute()
3355                            .bottom_0()
3356                            .left_0()
3357                            .w_full()
3358                            .px_2()
3359                            .h(footer_size)
3360                            .flex_none()
3361                            .justify_between()
3362                            .child(
3363                                self.render_generate_commit_message_button(cx)
3364                                    .unwrap_or_else(|| div().into_any_element()),
3365                            )
3366                            .child(
3367                                h_flex()
3368                                    .gap_0p5()
3369                                    .children(enable_coauthors)
3370                                    .child(self.render_commit_button(cx)),
3371                            ),
3372                    )
3373                    .child(
3374                        div()
3375                            .pr_2p5()
3376                            .on_action(|&editor::actions::MoveUp, _, cx| {
3377                                cx.stop_propagation();
3378                            })
3379                            .on_action(|&editor::actions::MoveDown, _, cx| {
3380                                cx.stop_propagation();
3381                            })
3382                            .child(EditorElement::new(&self.commit_editor, panel_editor_style)),
3383                    )
3384                    .child(
3385                        h_flex()
3386                            .absolute()
3387                            .top_2()
3388                            .right_2()
3389                            .opacity(0.5)
3390                            .hover(|this| this.opacity(1.0))
3391                            .child(
3392                                panel_icon_button("expand-commit-editor", IconName::Maximize)
3393                                    .icon_size(IconSize::Small)
3394                                    .size(ui::ButtonSize::Default)
3395                                    .tooltip(move |window, cx| {
3396                                        Tooltip::for_action_in(
3397                                            "Open Commit Modal",
3398                                            &git::ExpandCommitEditor,
3399                                            &expand_tooltip_focus_handle,
3400                                            window,
3401                                            cx,
3402                                        )
3403                                    })
3404                                    .on_click(cx.listener({
3405                                        move |_, _, window, cx| {
3406                                            window.dispatch_action(
3407                                                git::ExpandCommitEditor.boxed_clone(),
3408                                                cx,
3409                                            )
3410                                        }
3411                                    })),
3412                            ),
3413                    ),
3414            );
3415
3416        Some(footer)
3417    }
3418
3419    fn render_commit_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3420        let (can_commit, tooltip) = self.configure_commit_button(cx);
3421        let title = self.commit_button_title();
3422        let commit_tooltip_focus_handle = self.commit_editor.focus_handle(cx);
3423        let amend = self.amend_pending();
3424        let signoff = self.signoff_enabled;
3425
3426        div()
3427            .id("commit-wrapper")
3428            .on_hover(cx.listener(move |this, hovered, _, cx| {
3429                this.show_placeholders =
3430                    *hovered && !this.has_staged_changes() && !this.has_unstaged_conflicts();
3431                cx.notify()
3432            }))
3433            .child(SplitButton::new(
3434                ui::ButtonLike::new_rounded_left(ElementId::Name(
3435                    format!("split-button-left-{}", title).into(),
3436                ))
3437                .layer(ui::ElevationIndex::ModalSurface)
3438                .size(ui::ButtonSize::Compact)
3439                .child(
3440                    div()
3441                        .child(Label::new(title).size(LabelSize::Small))
3442                        .mr_0p5(),
3443                )
3444                .on_click({
3445                    let git_panel = cx.weak_entity();
3446                    move |_, window, cx| {
3447                        telemetry::event!("Git Committed", source = "Git Panel");
3448                        git_panel
3449                            .update(cx, |git_panel, cx| {
3450                                git_panel.commit_changes(
3451                                    CommitOptions { amend, signoff },
3452                                    window,
3453                                    cx,
3454                                );
3455                            })
3456                            .ok();
3457                    }
3458                })
3459                .disabled(!can_commit || self.modal_open)
3460                .tooltip({
3461                    let handle = commit_tooltip_focus_handle.clone();
3462                    move |window, cx| {
3463                        if can_commit {
3464                            Tooltip::with_meta_in(
3465                                tooltip,
3466                                Some(&git::Commit),
3467                                format!(
3468                                    "git commit{}{}",
3469                                    if amend { " --amend" } else { "" },
3470                                    if signoff { " --signoff" } else { "" }
3471                                ),
3472                                &handle.clone(),
3473                                window,
3474                                cx,
3475                            )
3476                        } else {
3477                            Tooltip::simple(tooltip, cx)
3478                        }
3479                    }
3480                }),
3481                self.render_git_commit_menu(
3482                    ElementId::Name(format!("split-button-right-{}", title).into()),
3483                    Some(commit_tooltip_focus_handle),
3484                    cx,
3485                )
3486                .into_any_element(),
3487            ))
3488    }
3489
3490    fn render_pending_amend(&self, cx: &mut Context<Self>) -> impl IntoElement {
3491        h_flex()
3492            .py_1p5()
3493            .px_2()
3494            .gap_1p5()
3495            .justify_between()
3496            .border_t_1()
3497            .border_color(cx.theme().colors().border.opacity(0.8))
3498            .child(
3499                div()
3500                    .flex_grow()
3501                    .overflow_hidden()
3502                    .max_w(relative(0.85))
3503                    .child(
3504                        Label::new("This will update your most recent commit.")
3505                            .size(LabelSize::Small)
3506                            .truncate(),
3507                    ),
3508            )
3509            .child(
3510                panel_button("Cancel")
3511                    .size(ButtonSize::Default)
3512                    .on_click(cx.listener(|this, _, _, cx| this.set_amend_pending(false, cx))),
3513            )
3514    }
3515
3516    fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
3517        let active_repository = self.active_repository.as_ref()?;
3518        let branch = active_repository.read(cx).branch.as_ref()?;
3519        let commit = branch.most_recent_commit.as_ref()?.clone();
3520        let workspace = self.workspace.clone();
3521        let this = cx.entity();
3522
3523        Some(
3524            h_flex()
3525                .py_1p5()
3526                .px_2()
3527                .gap_1p5()
3528                .justify_between()
3529                .border_t_1()
3530                .border_color(cx.theme().colors().border.opacity(0.8))
3531                .child(
3532                    div()
3533                        .flex_grow()
3534                        .overflow_hidden()
3535                        .max_w(relative(0.85))
3536                        .child(
3537                            Label::new(commit.subject.clone())
3538                                .size(LabelSize::Small)
3539                                .truncate(),
3540                        )
3541                        .id("commit-msg-hover")
3542                        .on_click({
3543                            let commit = commit.clone();
3544                            let repo = active_repository.downgrade();
3545                            move |_, window, cx| {
3546                                CommitView::open(
3547                                    commit.clone(),
3548                                    repo.clone(),
3549                                    workspace.clone(),
3550                                    window,
3551                                    cx,
3552                                );
3553                            }
3554                        })
3555                        .hoverable_tooltip({
3556                            let repo = active_repository.clone();
3557                            move |window, cx| {
3558                                GitPanelMessageTooltip::new(
3559                                    this.clone(),
3560                                    commit.sha.clone(),
3561                                    repo.clone(),
3562                                    window,
3563                                    cx,
3564                                )
3565                                .into()
3566                            }
3567                        }),
3568                )
3569                .when(commit.has_parent, |this| {
3570                    let has_unstaged = self.has_unstaged_changes();
3571                    this.child(
3572                        panel_icon_button("undo", IconName::Undo)
3573                            .icon_size(IconSize::XSmall)
3574                            .icon_color(Color::Muted)
3575                            .tooltip(move |window, cx| {
3576                                Tooltip::with_meta(
3577                                    "Uncommit",
3578                                    Some(&git::Uncommit),
3579                                    if has_unstaged {
3580                                        "git reset HEAD^ --soft"
3581                                    } else {
3582                                        "git reset HEAD^"
3583                                    },
3584                                    window,
3585                                    cx,
3586                                )
3587                            })
3588                            .on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))),
3589                    )
3590                }),
3591        )
3592    }
3593
3594    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
3595        h_flex().h_full().flex_grow().justify_center().child(
3596            v_flex()
3597                .gap_2()
3598                .child(h_flex().w_full().justify_around().child(
3599                    if self.active_repository.is_some() {
3600                        "No changes to commit"
3601                    } else {
3602                        "No Git repositories"
3603                    },
3604                ))
3605                .children({
3606                    let worktree_count = self.project.read(cx).visible_worktrees(cx).count();
3607                    (worktree_count > 0 && self.active_repository.is_none()).then(|| {
3608                        h_flex().w_full().justify_around().child(
3609                            panel_filled_button("Initialize Repository")
3610                                .tooltip(Tooltip::for_action_title_in(
3611                                    "git init",
3612                                    &git::Init,
3613                                    &self.focus_handle,
3614                                ))
3615                                .on_click(move |_, _, cx| {
3616                                    cx.defer(move |cx| {
3617                                        cx.dispatch_action(&git::Init);
3618                                    })
3619                                }),
3620                        )
3621                    })
3622                })
3623                .text_ui_sm(cx)
3624                .mx_auto()
3625                .text_color(Color::Placeholder.color(cx)),
3626        )
3627    }
3628
3629    fn render_buffer_header_controls(
3630        &self,
3631        entity: &Entity<Self>,
3632        file: &Arc<dyn File>,
3633        _: &Window,
3634        cx: &App,
3635    ) -> Option<AnyElement> {
3636        let repo = self.active_repository.as_ref()?.read(cx);
3637        let project_path = (file.worktree_id(cx), file.path()).into();
3638        let repo_path = repo.project_path_to_repo_path(&project_path, cx)?;
3639        let ix = self.entry_by_path(&repo_path, cx)?;
3640        let entry = self.entries.get(ix)?;
3641
3642        let entry_staging = self.entry_staging(entry.status_entry()?);
3643
3644        let checkbox = Checkbox::new("stage-file", entry_staging.as_bool().into())
3645            .disabled(!self.has_write_access(cx))
3646            .fill()
3647            .elevation(ElevationIndex::Surface)
3648            .on_click({
3649                let entry = entry.clone();
3650                let git_panel = entity.downgrade();
3651                move |_, window, cx| {
3652                    git_panel
3653                        .update(cx, |this, cx| {
3654                            this.toggle_staged_for_entry(&entry, window, cx);
3655                            cx.stop_propagation();
3656                        })
3657                        .ok();
3658                }
3659            });
3660        Some(
3661            h_flex()
3662                .id("start-slot")
3663                .text_lg()
3664                .child(checkbox)
3665                .on_mouse_down(MouseButton::Left, |_, _, cx| {
3666                    // prevent the list item active state triggering when toggling checkbox
3667                    cx.stop_propagation();
3668                })
3669                .into_any_element(),
3670        )
3671    }
3672
3673    fn render_entries(
3674        &self,
3675        has_write_access: bool,
3676        window: &mut Window,
3677        cx: &mut Context<Self>,
3678    ) -> impl IntoElement {
3679        let entry_count = self.entries.len();
3680
3681        v_flex()
3682            .flex_1()
3683            .size_full()
3684            .overflow_hidden()
3685            .relative()
3686            .child(
3687                h_flex()
3688                    .flex_1()
3689                    .size_full()
3690                    .relative()
3691                    .overflow_hidden()
3692                    .child(
3693                        uniform_list(
3694                            "entries",
3695                            entry_count,
3696                            cx.processor(move |this, range: Range<usize>, window, cx| {
3697                                let mut items = Vec::with_capacity(range.end - range.start);
3698
3699                                for ix in range {
3700                                    match &this.entries.get(ix) {
3701                                        Some(GitListEntry::Status(entry)) => {
3702                                            items.push(this.render_entry(
3703                                                ix,
3704                                                entry,
3705                                                has_write_access,
3706                                                window,
3707                                                cx,
3708                                            ));
3709                                        }
3710                                        Some(GitListEntry::Header(header)) => {
3711                                            items.push(this.render_list_header(
3712                                                ix,
3713                                                header,
3714                                                has_write_access,
3715                                                window,
3716                                                cx,
3717                                            ));
3718                                        }
3719                                        None => {}
3720                                    }
3721                                }
3722
3723                                items
3724                            }),
3725                        )
3726                        .size_full()
3727                        .flex_grow()
3728                        .with_sizing_behavior(ListSizingBehavior::Auto)
3729                        .with_horizontal_sizing_behavior(
3730                            ListHorizontalSizingBehavior::Unconstrained,
3731                        )
3732                        .with_width_from_item(self.max_width_item_index)
3733                        .track_scroll(self.scroll_handle.clone()),
3734                    )
3735                    .on_mouse_down(
3736                        MouseButton::Right,
3737                        cx.listener(move |this, event: &MouseDownEvent, window, cx| {
3738                            this.deploy_panel_context_menu(event.position, window, cx)
3739                        }),
3740                    )
3741                    .custom_scrollbars(
3742                        Scrollbars::for_settings::<GitPanelSettings>()
3743                            .tracked_scroll_handle(self.scroll_handle.clone())
3744                            .with_track_along(
3745                                ScrollAxes::Horizontal,
3746                                cx.theme().colors().panel_background,
3747                            ),
3748                        window,
3749                        cx,
3750                    ),
3751            )
3752    }
3753
3754    fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
3755        Label::new(label.into()).color(color).single_line()
3756    }
3757
3758    fn list_item_height(&self) -> Rems {
3759        rems(1.75)
3760    }
3761
3762    fn render_list_header(
3763        &self,
3764        ix: usize,
3765        header: &GitHeaderEntry,
3766        _: bool,
3767        _: &Window,
3768        _: &Context<Self>,
3769    ) -> AnyElement {
3770        let id: ElementId = ElementId::Name(format!("header_{}", ix).into());
3771
3772        h_flex()
3773            .id(id)
3774            .h(self.list_item_height())
3775            .w_full()
3776            .items_end()
3777            .px(rems(0.75)) // ~12px
3778            .pb(rems(0.3125)) // ~ 5px
3779            .child(
3780                Label::new(header.title())
3781                    .color(Color::Muted)
3782                    .size(LabelSize::Small)
3783                    .line_height_style(LineHeightStyle::UiLabel)
3784                    .single_line(),
3785            )
3786            .into_any_element()
3787    }
3788
3789    pub fn load_commit_details(
3790        &self,
3791        sha: String,
3792        cx: &mut Context<Self>,
3793    ) -> Task<anyhow::Result<CommitDetails>> {
3794        let Some(repo) = self.active_repository.clone() else {
3795            return Task::ready(Err(anyhow::anyhow!("no active repo")));
3796        };
3797        repo.update(cx, |repo, cx| {
3798            let show = repo.show(sha);
3799            cx.spawn(async move |_, _| show.await?)
3800        })
3801    }
3802
3803    fn deploy_entry_context_menu(
3804        &mut self,
3805        position: Point<Pixels>,
3806        ix: usize,
3807        window: &mut Window,
3808        cx: &mut Context<Self>,
3809    ) {
3810        let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else {
3811            return;
3812        };
3813        let stage_title = if entry.status.staging().is_fully_staged() {
3814            "Unstage File"
3815        } else {
3816            "Stage File"
3817        };
3818        let restore_title = if entry.status.is_created() {
3819            "Trash File"
3820        } else {
3821            "Restore File"
3822        };
3823        let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
3824            context_menu
3825                .context(self.focus_handle.clone())
3826                .action(stage_title, ToggleStaged.boxed_clone())
3827                .action(restore_title, git::RestoreFile::default().boxed_clone())
3828                .separator()
3829                .action("Open Diff", Confirm.boxed_clone())
3830                .action("Open File", SecondaryConfirm.boxed_clone())
3831        });
3832        self.selected_entry = Some(ix);
3833        self.set_context_menu(context_menu, position, window, cx);
3834    }
3835
3836    fn deploy_panel_context_menu(
3837        &mut self,
3838        position: Point<Pixels>,
3839        window: &mut Window,
3840        cx: &mut Context<Self>,
3841    ) {
3842        let context_menu = git_panel_context_menu(
3843            self.focus_handle.clone(),
3844            GitMenuState {
3845                has_tracked_changes: self.has_tracked_changes(),
3846                has_staged_changes: self.has_staged_changes(),
3847                has_unstaged_changes: self.has_unstaged_changes(),
3848                has_new_changes: self.new_count > 0,
3849                sort_by_path: GitPanelSettings::get_global(cx).sort_by_path,
3850                has_stash_items: self.stash_entries.entries.len() > 0,
3851            },
3852            window,
3853            cx,
3854        );
3855        self.set_context_menu(context_menu, position, window, cx);
3856    }
3857
3858    fn set_context_menu(
3859        &mut self,
3860        context_menu: Entity<ContextMenu>,
3861        position: Point<Pixels>,
3862        window: &Window,
3863        cx: &mut Context<Self>,
3864    ) {
3865        let subscription = cx.subscribe_in(
3866            &context_menu,
3867            window,
3868            |this, _, _: &DismissEvent, window, cx| {
3869                if this.context_menu.as_ref().is_some_and(|context_menu| {
3870                    context_menu.0.focus_handle(cx).contains_focused(window, cx)
3871                }) {
3872                    cx.focus_self(window);
3873                }
3874                this.context_menu.take();
3875                cx.notify();
3876            },
3877        );
3878        self.context_menu = Some((context_menu, position, subscription));
3879        cx.notify();
3880    }
3881
3882    fn render_entry(
3883        &self,
3884        ix: usize,
3885        entry: &GitStatusEntry,
3886        has_write_access: bool,
3887        window: &Window,
3888        cx: &Context<Self>,
3889    ) -> AnyElement {
3890        let display_name = entry.display_name();
3891
3892        let selected = self.selected_entry == Some(ix);
3893        let marked = self.marked_entries.contains(&ix);
3894        let status_style = GitPanelSettings::get_global(cx).status_style;
3895        let status = entry.status;
3896
3897        let has_conflict = status.is_conflicted();
3898        let is_modified = status.is_modified();
3899        let is_deleted = status.is_deleted();
3900
3901        let label_color = if status_style == StatusStyle::LabelColor {
3902            if has_conflict {
3903                Color::VersionControlConflict
3904            } else if is_modified {
3905                Color::VersionControlModified
3906            } else if is_deleted {
3907                // We don't want a bunch of red labels in the list
3908                Color::Disabled
3909            } else {
3910                Color::VersionControlAdded
3911            }
3912        } else {
3913            Color::Default
3914        };
3915
3916        let path_color = if status.is_deleted() {
3917            Color::Disabled
3918        } else {
3919            Color::Muted
3920        };
3921
3922        let id: ElementId = ElementId::Name(format!("entry_{}_{}", display_name, ix).into());
3923        let checkbox_wrapper_id: ElementId =
3924            ElementId::Name(format!("entry_{}_{}_checkbox_wrapper", display_name, ix).into());
3925        let checkbox_id: ElementId =
3926            ElementId::Name(format!("entry_{}_{}_checkbox", display_name, ix).into());
3927
3928        let entry_staging = self.entry_staging(entry);
3929        let mut is_staged: ToggleState = self.entry_staging(entry).as_bool().into();
3930        if self.show_placeholders && !self.has_staged_changes() && !entry.status.is_created() {
3931            is_staged = ToggleState::Selected;
3932        }
3933
3934        let handle = cx.weak_entity();
3935
3936        let selected_bg_alpha = 0.08;
3937        let marked_bg_alpha = 0.12;
3938        let state_opacity_step = 0.04;
3939
3940        let base_bg = match (selected, marked) {
3941            (true, true) => cx
3942                .theme()
3943                .status()
3944                .info
3945                .alpha(selected_bg_alpha + marked_bg_alpha),
3946            (true, false) => cx.theme().status().info.alpha(selected_bg_alpha),
3947            (false, true) => cx.theme().status().info.alpha(marked_bg_alpha),
3948            _ => cx.theme().colors().ghost_element_background,
3949        };
3950
3951        let hover_bg = if selected {
3952            cx.theme()
3953                .status()
3954                .info
3955                .alpha(selected_bg_alpha + state_opacity_step)
3956        } else {
3957            cx.theme().colors().ghost_element_hover
3958        };
3959
3960        let active_bg = if selected {
3961            cx.theme()
3962                .status()
3963                .info
3964                .alpha(selected_bg_alpha + state_opacity_step * 2.0)
3965        } else {
3966            cx.theme().colors().ghost_element_active
3967        };
3968
3969        h_flex()
3970            .id(id)
3971            .h(self.list_item_height())
3972            .w_full()
3973            .items_center()
3974            .border_1()
3975            .when(selected && self.focus_handle.is_focused(window), |el| {
3976                el.border_color(cx.theme().colors().border_focused)
3977            })
3978            .px(rems(0.75)) // ~12px
3979            .overflow_hidden()
3980            .flex_none()
3981            .gap_1p5()
3982            .bg(base_bg)
3983            .hover(|this| this.bg(hover_bg))
3984            .active(|this| this.bg(active_bg))
3985            .on_click({
3986                cx.listener(move |this, event: &ClickEvent, window, cx| {
3987                    this.selected_entry = Some(ix);
3988                    cx.notify();
3989                    if event.modifiers().secondary() {
3990                        this.open_file(&Default::default(), window, cx)
3991                    } else {
3992                        this.open_diff(&Default::default(), window, cx);
3993                        this.focus_handle.focus(window);
3994                    }
3995                })
3996            })
3997            .on_mouse_down(
3998                MouseButton::Right,
3999                move |event: &MouseDownEvent, window, cx| {
4000                    // why isn't this happening automatically? we are passing MouseButton::Right to `on_mouse_down`?
4001                    if event.button != MouseButton::Right {
4002                        return;
4003                    }
4004
4005                    let Some(this) = handle.upgrade() else {
4006                        return;
4007                    };
4008                    this.update(cx, |this, cx| {
4009                        this.deploy_entry_context_menu(event.position, ix, window, cx);
4010                    });
4011                    cx.stop_propagation();
4012                },
4013            )
4014            .child(
4015                div()
4016                    .id(checkbox_wrapper_id)
4017                    .flex_none()
4018                    .occlude()
4019                    .cursor_pointer()
4020                    .child(
4021                        Checkbox::new(checkbox_id, is_staged)
4022                            .disabled(!has_write_access)
4023                            .fill()
4024                            .elevation(ElevationIndex::Surface)
4025                            .on_click_ext({
4026                                let entry = entry.clone();
4027                                let this = cx.weak_entity();
4028                                move |_, click, window, cx| {
4029                                    this.update(cx, |this, cx| {
4030                                        if !has_write_access {
4031                                            return;
4032                                        }
4033                                        if click.modifiers().shift {
4034                                            this.stage_bulk(ix, cx);
4035                                        } else {
4036                                            this.toggle_staged_for_entry(
4037                                                &GitListEntry::Status(entry.clone()),
4038                                                window,
4039                                                cx,
4040                                            );
4041                                        }
4042                                        cx.stop_propagation();
4043                                    })
4044                                    .ok();
4045                                }
4046                            })
4047                            .tooltip(move |window, cx| {
4048                                let is_staged = entry_staging.is_fully_staged();
4049
4050                                let action = if is_staged { "Unstage" } else { "Stage" };
4051                                let tooltip_name = action.to_string();
4052
4053                                Tooltip::for_action(tooltip_name, &ToggleStaged, window, cx)
4054                            }),
4055                    ),
4056            )
4057            .child(git_status_icon(status))
4058            .child(
4059                h_flex()
4060                    .items_center()
4061                    .flex_1()
4062                    // .overflow_hidden()
4063                    .when_some(entry.parent_dir(), |this, parent| {
4064                        if !parent.is_empty() {
4065                            this.child(
4066                                self.entry_label(format!("{}/", parent), path_color)
4067                                    .when(status.is_deleted(), |this| this.strikethrough()),
4068                            )
4069                        } else {
4070                            this
4071                        }
4072                    })
4073                    .child(
4074                        self.entry_label(display_name, label_color)
4075                            .when(status.is_deleted(), |this| this.strikethrough()),
4076                    ),
4077            )
4078            .into_any_element()
4079    }
4080
4081    fn has_write_access(&self, cx: &App) -> bool {
4082        !self.project.read(cx).is_read_only(cx)
4083    }
4084
4085    pub fn amend_pending(&self) -> bool {
4086        self.amend_pending
4087    }
4088
4089    pub fn set_amend_pending(&mut self, value: bool, cx: &mut Context<Self>) {
4090        if value && !self.amend_pending {
4091            let current_message = self.commit_message_buffer(cx).read(cx).text();
4092            self.original_commit_message = if current_message.trim().is_empty() {
4093                None
4094            } else {
4095                Some(current_message)
4096            };
4097        } else if !value && self.amend_pending {
4098            let message = self.original_commit_message.take().unwrap_or_default();
4099            self.commit_message_buffer(cx).update(cx, |buffer, cx| {
4100                let start = buffer.anchor_before(0);
4101                let end = buffer.anchor_after(buffer.len());
4102                buffer.edit([(start..end, message)], None, cx);
4103            });
4104        }
4105
4106        self.amend_pending = value;
4107        self.serialize(cx);
4108        cx.notify();
4109    }
4110
4111    pub fn signoff_enabled(&self) -> bool {
4112        self.signoff_enabled
4113    }
4114
4115    pub fn set_signoff_enabled(&mut self, value: bool, cx: &mut Context<Self>) {
4116        self.signoff_enabled = value;
4117        self.serialize(cx);
4118        cx.notify();
4119    }
4120
4121    pub fn toggle_signoff_enabled(
4122        &mut self,
4123        _: &Signoff,
4124        _window: &mut Window,
4125        cx: &mut Context<Self>,
4126    ) {
4127        self.set_signoff_enabled(!self.signoff_enabled, cx);
4128    }
4129
4130    pub async fn load(
4131        workspace: WeakEntity<Workspace>,
4132        mut cx: AsyncWindowContext,
4133    ) -> anyhow::Result<Entity<Self>> {
4134        let serialized_panel = match workspace
4135            .read_with(&cx, |workspace, _| Self::serialization_key(workspace))
4136            .ok()
4137            .flatten()
4138        {
4139            Some(serialization_key) => cx
4140                .background_spawn(async move { KEY_VALUE_STORE.read_kvp(&serialization_key) })
4141                .await
4142                .context("loading git panel")
4143                .log_err()
4144                .flatten()
4145                .map(|panel| serde_json::from_str::<SerializedGitPanel>(&panel))
4146                .transpose()
4147                .log_err()
4148                .flatten(),
4149            None => None,
4150        };
4151
4152        workspace.update_in(&mut cx, |workspace, window, cx| {
4153            let panel = GitPanel::new(workspace, window, cx);
4154
4155            if let Some(serialized_panel) = serialized_panel {
4156                panel.update(cx, |panel, cx| {
4157                    panel.width = serialized_panel.width;
4158                    panel.amend_pending = serialized_panel.amend_pending;
4159                    panel.signoff_enabled = serialized_panel.signoff_enabled;
4160                    cx.notify();
4161                })
4162            }
4163
4164            panel
4165        })
4166    }
4167
4168    fn stage_bulk(&mut self, mut index: usize, cx: &mut Context<'_, Self>) {
4169        let Some(op) = self.bulk_staging.as_ref() else {
4170            return;
4171        };
4172        let Some(mut anchor_index) = self.entry_by_path(&op.anchor, cx) else {
4173            return;
4174        };
4175        if let Some(entry) = self.entries.get(index)
4176            && let Some(entry) = entry.status_entry()
4177        {
4178            self.set_bulk_staging_anchor(entry.repo_path.clone(), cx);
4179        }
4180        if index < anchor_index {
4181            std::mem::swap(&mut index, &mut anchor_index);
4182        }
4183        let entries = self
4184            .entries
4185            .get(anchor_index..=index)
4186            .unwrap_or_default()
4187            .iter()
4188            .filter_map(|entry| entry.status_entry().cloned())
4189            .collect::<Vec<_>>();
4190        self.change_file_stage(true, entries, cx);
4191    }
4192
4193    fn set_bulk_staging_anchor(&mut self, path: RepoPath, cx: &mut Context<'_, GitPanel>) {
4194        let Some(repo) = self.active_repository.as_ref() else {
4195            return;
4196        };
4197        self.bulk_staging = Some(BulkStaging {
4198            repo_id: repo.read(cx).id,
4199            anchor: path,
4200        });
4201    }
4202
4203    pub(crate) fn toggle_amend_pending(&mut self, cx: &mut Context<Self>) {
4204        self.set_amend_pending(!self.amend_pending, cx);
4205        if self.amend_pending {
4206            self.load_last_commit_message_if_empty(cx);
4207        }
4208    }
4209}
4210
4211impl Render for GitPanel {
4212    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4213        let project = self.project.read(cx);
4214        let has_entries = !self.entries.is_empty();
4215        let room = self
4216            .workspace
4217            .upgrade()
4218            .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
4219
4220        let has_write_access = self.has_write_access(cx);
4221
4222        let has_co_authors = room.is_some_and(|room| {
4223            self.load_local_committer(cx);
4224            let room = room.read(cx);
4225            room.remote_participants()
4226                .values()
4227                .any(|remote_participant| remote_participant.can_write())
4228        });
4229
4230        v_flex()
4231            .id("git_panel")
4232            .key_context(self.dispatch_context(window, cx))
4233            .track_focus(&self.focus_handle)
4234            .when(has_write_access && !project.is_read_only(cx), |this| {
4235                this.on_action(cx.listener(Self::toggle_staged_for_selected))
4236                    .on_action(cx.listener(Self::stage_range))
4237                    .on_action(cx.listener(GitPanel::commit))
4238                    .on_action(cx.listener(GitPanel::amend))
4239                    .on_action(cx.listener(GitPanel::toggle_signoff_enabled))
4240                    .on_action(cx.listener(Self::stage_all))
4241                    .on_action(cx.listener(Self::unstage_all))
4242                    .on_action(cx.listener(Self::stage_selected))
4243                    .on_action(cx.listener(Self::unstage_selected))
4244                    .on_action(cx.listener(Self::restore_tracked_files))
4245                    .on_action(cx.listener(Self::revert_selected))
4246                    .on_action(cx.listener(Self::clean_all))
4247                    .on_action(cx.listener(Self::generate_commit_message_action))
4248                    .on_action(cx.listener(Self::stash_all))
4249                    .on_action(cx.listener(Self::stash_pop))
4250            })
4251            .on_action(cx.listener(Self::select_first))
4252            .on_action(cx.listener(Self::select_next))
4253            .on_action(cx.listener(Self::select_previous))
4254            .on_action(cx.listener(Self::select_last))
4255            .on_action(cx.listener(Self::close_panel))
4256            .on_action(cx.listener(Self::open_diff))
4257            .on_action(cx.listener(Self::open_file))
4258            .on_action(cx.listener(Self::focus_changes_list))
4259            .on_action(cx.listener(Self::focus_editor))
4260            .on_action(cx.listener(Self::expand_commit_editor))
4261            .when(has_write_access && has_co_authors, |git_panel| {
4262                git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
4263            })
4264            .on_action(cx.listener(Self::toggle_sort_by_path))
4265            .size_full()
4266            .overflow_hidden()
4267            .bg(cx.theme().colors().panel_background)
4268            .child(
4269                v_flex()
4270                    .size_full()
4271                    .children(self.render_panel_header(window, cx))
4272                    .map(|this| {
4273                        if has_entries {
4274                            this.child(self.render_entries(has_write_access, window, cx))
4275                        } else {
4276                            this.child(self.render_empty_state(cx).into_any_element())
4277                        }
4278                    })
4279                    .children(self.render_footer(window, cx))
4280                    .when(self.amend_pending, |this| {
4281                        this.child(self.render_pending_amend(cx))
4282                    })
4283                    .when(!self.amend_pending, |this| {
4284                        this.children(self.render_previous_commit(cx))
4285                    })
4286                    .into_any_element(),
4287            )
4288            .children(self.context_menu.as_ref().map(|(menu, position, _)| {
4289                deferred(
4290                    anchored()
4291                        .position(*position)
4292                        .anchor(Corner::TopLeft)
4293                        .child(menu.clone()),
4294                )
4295                .with_priority(1)
4296            }))
4297    }
4298}
4299
4300impl Focusable for GitPanel {
4301    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
4302        if self.entries.is_empty() {
4303            self.commit_editor.focus_handle(cx)
4304        } else {
4305            self.focus_handle.clone()
4306        }
4307    }
4308}
4309
4310impl EventEmitter<Event> for GitPanel {}
4311
4312impl EventEmitter<PanelEvent> for GitPanel {}
4313
4314pub(crate) struct GitPanelAddon {
4315    pub(crate) workspace: WeakEntity<Workspace>,
4316}
4317
4318impl editor::Addon for GitPanelAddon {
4319    fn to_any(&self) -> &dyn std::any::Any {
4320        self
4321    }
4322
4323    fn render_buffer_header_controls(
4324        &self,
4325        excerpt_info: &ExcerptInfo,
4326        window: &Window,
4327        cx: &App,
4328    ) -> Option<AnyElement> {
4329        let file = excerpt_info.buffer.file()?;
4330        let git_panel = self.workspace.upgrade()?.read(cx).panel::<GitPanel>(cx)?;
4331
4332        git_panel
4333            .read(cx)
4334            .render_buffer_header_controls(&git_panel, file, window, cx)
4335    }
4336}
4337
4338impl Panel for GitPanel {
4339    fn persistent_name() -> &'static str {
4340        "GitPanel"
4341    }
4342
4343    fn position(&self, _: &Window, cx: &App) -> DockPosition {
4344        GitPanelSettings::get_global(cx).dock
4345    }
4346
4347    fn position_is_valid(&self, position: DockPosition) -> bool {
4348        matches!(position, DockPosition::Left | DockPosition::Right)
4349    }
4350
4351    fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
4352        settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
4353            settings.git_panel.get_or_insert_default().dock = Some(position.into())
4354        });
4355    }
4356
4357    fn size(&self, _: &Window, cx: &App) -> Pixels {
4358        self.width
4359            .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
4360    }
4361
4362    fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
4363        self.width = size;
4364        self.serialize(cx);
4365        cx.notify();
4366    }
4367
4368    fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
4369        Some(ui::IconName::GitBranchAlt).filter(|_| GitPanelSettings::get_global(cx).button)
4370    }
4371
4372    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
4373        Some("Git Panel")
4374    }
4375
4376    fn toggle_action(&self) -> Box<dyn Action> {
4377        Box::new(ToggleFocus)
4378    }
4379
4380    fn activation_priority(&self) -> u32 {
4381        2
4382    }
4383}
4384
4385impl PanelHeader for GitPanel {}
4386
4387struct GitPanelMessageTooltip {
4388    commit_tooltip: Option<Entity<CommitTooltip>>,
4389}
4390
4391impl GitPanelMessageTooltip {
4392    fn new(
4393        git_panel: Entity<GitPanel>,
4394        sha: SharedString,
4395        repository: Entity<Repository>,
4396        window: &mut Window,
4397        cx: &mut App,
4398    ) -> Entity<Self> {
4399        cx.new(|cx| {
4400            cx.spawn_in(window, async move |this, cx| {
4401                let (details, workspace) = git_panel.update(cx, |git_panel, cx| {
4402                    (
4403                        git_panel.load_commit_details(sha.to_string(), cx),
4404                        git_panel.workspace.clone(),
4405                    )
4406                })?;
4407                let details = details.await?;
4408
4409                let commit_details = crate::commit_tooltip::CommitDetails {
4410                    sha: details.sha.clone(),
4411                    author_name: details.author_name.clone(),
4412                    author_email: details.author_email.clone(),
4413                    commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
4414                    message: Some(ParsedCommitMessage {
4415                        message: details.message,
4416                        ..Default::default()
4417                    }),
4418                };
4419
4420                this.update(cx, |this: &mut GitPanelMessageTooltip, cx| {
4421                    this.commit_tooltip = Some(cx.new(move |cx| {
4422                        CommitTooltip::new(commit_details, repository, workspace, cx)
4423                    }));
4424                    cx.notify();
4425                })
4426            })
4427            .detach();
4428
4429            Self {
4430                commit_tooltip: None,
4431            }
4432        })
4433    }
4434}
4435
4436impl Render for GitPanelMessageTooltip {
4437    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
4438        if let Some(commit_tooltip) = &self.commit_tooltip {
4439            commit_tooltip.clone().into_any_element()
4440        } else {
4441            gpui::Empty.into_any_element()
4442        }
4443    }
4444}
4445
4446#[derive(IntoElement, RegisterComponent)]
4447pub struct PanelRepoFooter {
4448    active_repository: SharedString,
4449    branch: Option<Branch>,
4450    head_commit: Option<CommitDetails>,
4451
4452    // Getting a GitPanel in previews will be difficult.
4453    //
4454    // For now just take an option here, and we won't bind handlers to buttons in previews.
4455    git_panel: Option<Entity<GitPanel>>,
4456}
4457
4458impl PanelRepoFooter {
4459    pub fn new(
4460        active_repository: SharedString,
4461        branch: Option<Branch>,
4462        head_commit: Option<CommitDetails>,
4463        git_panel: Option<Entity<GitPanel>>,
4464    ) -> Self {
4465        Self {
4466            active_repository,
4467            branch,
4468            head_commit,
4469            git_panel,
4470        }
4471    }
4472
4473    pub fn new_preview(active_repository: SharedString, branch: Option<Branch>) -> Self {
4474        Self {
4475            active_repository,
4476            branch,
4477            head_commit: None,
4478            git_panel: None,
4479        }
4480    }
4481}
4482
4483impl RenderOnce for PanelRepoFooter {
4484    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
4485        let project = self
4486            .git_panel
4487            .as_ref()
4488            .map(|panel| panel.read(cx).project.clone());
4489
4490        let repo = self
4491            .git_panel
4492            .as_ref()
4493            .and_then(|panel| panel.read(cx).active_repository.clone());
4494
4495        let single_repo = project
4496            .as_ref()
4497            .map(|project| project.read(cx).git_store().read(cx).repositories().len() == 1)
4498            .unwrap_or(true);
4499
4500        const MAX_BRANCH_LEN: usize = 16;
4501        const MAX_REPO_LEN: usize = 16;
4502        const LABEL_CHARACTER_BUDGET: usize = MAX_BRANCH_LEN + MAX_REPO_LEN;
4503        const MAX_SHORT_SHA_LEN: usize = 8;
4504
4505        let branch_name = self
4506            .branch
4507            .as_ref()
4508            .map(|branch| branch.name().to_owned())
4509            .or_else(|| {
4510                self.head_commit.as_ref().map(|commit| {
4511                    commit
4512                        .sha
4513                        .chars()
4514                        .take(MAX_SHORT_SHA_LEN)
4515                        .collect::<String>()
4516                })
4517            })
4518            .unwrap_or_else(|| " (no branch)".to_owned());
4519        let show_separator = self.branch.is_some() || self.head_commit.is_some();
4520
4521        let active_repo_name = self.active_repository.clone();
4522
4523        let branch_actual_len = branch_name.len();
4524        let repo_actual_len = active_repo_name.len();
4525
4526        // ideally, show the whole branch and repo names but
4527        // when we can't, use a budget to allocate space between the two
4528        let (repo_display_len, branch_display_len) =
4529            if branch_actual_len + repo_actual_len <= LABEL_CHARACTER_BUDGET {
4530                (repo_actual_len, branch_actual_len)
4531            } else if branch_actual_len <= MAX_BRANCH_LEN {
4532                let repo_space = (LABEL_CHARACTER_BUDGET - branch_actual_len).min(MAX_REPO_LEN);
4533                (repo_space, branch_actual_len)
4534            } else if repo_actual_len <= MAX_REPO_LEN {
4535                let branch_space = (LABEL_CHARACTER_BUDGET - repo_actual_len).min(MAX_BRANCH_LEN);
4536                (repo_actual_len, branch_space)
4537            } else {
4538                (MAX_REPO_LEN, MAX_BRANCH_LEN)
4539            };
4540
4541        let truncated_repo_name = if repo_actual_len <= repo_display_len {
4542            active_repo_name.to_string()
4543        } else {
4544            util::truncate_and_trailoff(active_repo_name.trim_ascii(), repo_display_len)
4545        };
4546
4547        let truncated_branch_name = if branch_actual_len <= branch_display_len {
4548            branch_name
4549        } else {
4550            util::truncate_and_trailoff(branch_name.trim_ascii(), branch_display_len)
4551        };
4552
4553        let repo_selector_trigger = Button::new("repo-selector", truncated_repo_name)
4554            .style(ButtonStyle::Transparent)
4555            .size(ButtonSize::None)
4556            .label_size(LabelSize::Small)
4557            .color(Color::Muted);
4558
4559        let repo_selector = PopoverMenu::new("repository-switcher")
4560            .menu({
4561                let project = project;
4562                move |window, cx| {
4563                    let project = project.clone()?;
4564                    Some(cx.new(|cx| RepositorySelector::new(project, rems(16.), window, cx)))
4565                }
4566            })
4567            .trigger_with_tooltip(
4568                repo_selector_trigger.disabled(single_repo).truncate(true),
4569                Tooltip::text("Switch Active Repository"),
4570            )
4571            .anchor(Corner::BottomLeft)
4572            .into_any_element();
4573
4574        let branch_selector_button = Button::new("branch-selector", truncated_branch_name)
4575            .style(ButtonStyle::Transparent)
4576            .size(ButtonSize::None)
4577            .label_size(LabelSize::Small)
4578            .truncate(true)
4579            .on_click(|_, window, cx| {
4580                window.dispatch_action(zed_actions::git::Switch.boxed_clone(), cx);
4581            });
4582
4583        let branch_selector = PopoverMenu::new("popover-button")
4584            .menu(move |window, cx| Some(branch_picker::popover(repo.clone(), window, cx)))
4585            .trigger_with_tooltip(
4586                branch_selector_button,
4587                Tooltip::for_action_title("Switch Branch", &zed_actions::git::Switch),
4588            )
4589            .anchor(Corner::BottomLeft)
4590            .offset(gpui::Point {
4591                x: px(0.0),
4592                y: px(-2.0),
4593            });
4594
4595        h_flex()
4596            .w_full()
4597            .px_2()
4598            .h(px(36.))
4599            .items_center()
4600            .justify_between()
4601            .gap_1()
4602            .child(
4603                h_flex()
4604                    .flex_1()
4605                    .overflow_hidden()
4606                    .items_center()
4607                    .child(
4608                        div().child(
4609                            Icon::new(IconName::GitBranchAlt)
4610                                .size(IconSize::Small)
4611                                .color(if single_repo {
4612                                    Color::Disabled
4613                                } else {
4614                                    Color::Muted
4615                                }),
4616                        ),
4617                    )
4618                    .child(repo_selector)
4619                    .when(show_separator, |this| {
4620                        this.child(
4621                            div()
4622                                .text_color(cx.theme().colors().text_muted)
4623                                .text_sm()
4624                                .child("/"),
4625                        )
4626                    })
4627                    .child(branch_selector),
4628            )
4629            .children(if let Some(git_panel) = self.git_panel {
4630                git_panel.update(cx, |git_panel, cx| git_panel.render_remote_button(cx))
4631            } else {
4632                None
4633            })
4634    }
4635}
4636
4637impl Component for PanelRepoFooter {
4638    fn scope() -> ComponentScope {
4639        ComponentScope::VersionControl
4640    }
4641
4642    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
4643        let unknown_upstream = None;
4644        let no_remote_upstream = Some(UpstreamTracking::Gone);
4645        let ahead_of_upstream = Some(
4646            UpstreamTrackingStatus {
4647                ahead: 2,
4648                behind: 0,
4649            }
4650            .into(),
4651        );
4652        let behind_upstream = Some(
4653            UpstreamTrackingStatus {
4654                ahead: 0,
4655                behind: 2,
4656            }
4657            .into(),
4658        );
4659        let ahead_and_behind_upstream = Some(
4660            UpstreamTrackingStatus {
4661                ahead: 3,
4662                behind: 1,
4663            }
4664            .into(),
4665        );
4666
4667        let not_ahead_or_behind_upstream = Some(
4668            UpstreamTrackingStatus {
4669                ahead: 0,
4670                behind: 0,
4671            }
4672            .into(),
4673        );
4674
4675        fn branch(upstream: Option<UpstreamTracking>) -> Branch {
4676            Branch {
4677                is_head: true,
4678                ref_name: "some-branch".into(),
4679                upstream: upstream.map(|tracking| Upstream {
4680                    ref_name: "origin/some-branch".into(),
4681                    tracking,
4682                }),
4683                most_recent_commit: Some(CommitSummary {
4684                    sha: "abc123".into(),
4685                    subject: "Modify stuff".into(),
4686                    commit_timestamp: 1710932954,
4687                    author_name: "John Doe".into(),
4688                    has_parent: true,
4689                }),
4690            }
4691        }
4692
4693        fn custom(branch_name: &str, upstream: Option<UpstreamTracking>) -> Branch {
4694            Branch {
4695                is_head: true,
4696                ref_name: branch_name.to_string().into(),
4697                upstream: upstream.map(|tracking| Upstream {
4698                    ref_name: format!("zed/{}", branch_name).into(),
4699                    tracking,
4700                }),
4701                most_recent_commit: Some(CommitSummary {
4702                    sha: "abc123".into(),
4703                    subject: "Modify stuff".into(),
4704                    commit_timestamp: 1710932954,
4705                    author_name: "John Doe".into(),
4706                    has_parent: true,
4707                }),
4708            }
4709        }
4710
4711        fn active_repository(id: usize) -> SharedString {
4712            format!("repo-{}", id).into()
4713        }
4714
4715        let example_width = px(340.);
4716        Some(
4717            v_flex()
4718                .gap_6()
4719                .w_full()
4720                .flex_none()
4721                .children(vec![
4722                    example_group_with_title(
4723                        "Action Button States",
4724                        vec![
4725                            single_example(
4726                                "No Branch",
4727                                div()
4728                                    .w(example_width)
4729                                    .overflow_hidden()
4730                                    .child(PanelRepoFooter::new_preview(active_repository(1), None))
4731                                    .into_any_element(),
4732                            ),
4733                            single_example(
4734                                "Remote status unknown",
4735                                div()
4736                                    .w(example_width)
4737                                    .overflow_hidden()
4738                                    .child(PanelRepoFooter::new_preview(
4739                                        active_repository(2),
4740                                        Some(branch(unknown_upstream)),
4741                                    ))
4742                                    .into_any_element(),
4743                            ),
4744                            single_example(
4745                                "No Remote Upstream",
4746                                div()
4747                                    .w(example_width)
4748                                    .overflow_hidden()
4749                                    .child(PanelRepoFooter::new_preview(
4750                                        active_repository(3),
4751                                        Some(branch(no_remote_upstream)),
4752                                    ))
4753                                    .into_any_element(),
4754                            ),
4755                            single_example(
4756                                "Not Ahead or Behind",
4757                                div()
4758                                    .w(example_width)
4759                                    .overflow_hidden()
4760                                    .child(PanelRepoFooter::new_preview(
4761                                        active_repository(4),
4762                                        Some(branch(not_ahead_or_behind_upstream)),
4763                                    ))
4764                                    .into_any_element(),
4765                            ),
4766                            single_example(
4767                                "Behind remote",
4768                                div()
4769                                    .w(example_width)
4770                                    .overflow_hidden()
4771                                    .child(PanelRepoFooter::new_preview(
4772                                        active_repository(5),
4773                                        Some(branch(behind_upstream)),
4774                                    ))
4775                                    .into_any_element(),
4776                            ),
4777                            single_example(
4778                                "Ahead of remote",
4779                                div()
4780                                    .w(example_width)
4781                                    .overflow_hidden()
4782                                    .child(PanelRepoFooter::new_preview(
4783                                        active_repository(6),
4784                                        Some(branch(ahead_of_upstream)),
4785                                    ))
4786                                    .into_any_element(),
4787                            ),
4788                            single_example(
4789                                "Ahead and behind remote",
4790                                div()
4791                                    .w(example_width)
4792                                    .overflow_hidden()
4793                                    .child(PanelRepoFooter::new_preview(
4794                                        active_repository(7),
4795                                        Some(branch(ahead_and_behind_upstream)),
4796                                    ))
4797                                    .into_any_element(),
4798                            ),
4799                        ],
4800                    )
4801                    .grow()
4802                    .vertical(),
4803                ])
4804                .children(vec![
4805                    example_group_with_title(
4806                        "Labels",
4807                        vec![
4808                            single_example(
4809                                "Short Branch & Repo",
4810                                div()
4811                                    .w(example_width)
4812                                    .overflow_hidden()
4813                                    .child(PanelRepoFooter::new_preview(
4814                                        SharedString::from("zed"),
4815                                        Some(custom("main", behind_upstream)),
4816                                    ))
4817                                    .into_any_element(),
4818                            ),
4819                            single_example(
4820                                "Long Branch",
4821                                div()
4822                                    .w(example_width)
4823                                    .overflow_hidden()
4824                                    .child(PanelRepoFooter::new_preview(
4825                                        SharedString::from("zed"),
4826                                        Some(custom(
4827                                            "redesign-and-update-git-ui-list-entry-style",
4828                                            behind_upstream,
4829                                        )),
4830                                    ))
4831                                    .into_any_element(),
4832                            ),
4833                            single_example(
4834                                "Long Repo",
4835                                div()
4836                                    .w(example_width)
4837                                    .overflow_hidden()
4838                                    .child(PanelRepoFooter::new_preview(
4839                                        SharedString::from("zed-industries-community-examples"),
4840                                        Some(custom("gpui", ahead_of_upstream)),
4841                                    ))
4842                                    .into_any_element(),
4843                            ),
4844                            single_example(
4845                                "Long Repo & Branch",
4846                                div()
4847                                    .w(example_width)
4848                                    .overflow_hidden()
4849                                    .child(PanelRepoFooter::new_preview(
4850                                        SharedString::from("zed-industries-community-examples"),
4851                                        Some(custom(
4852                                            "redesign-and-update-git-ui-list-entry-style",
4853                                            behind_upstream,
4854                                        )),
4855                                    ))
4856                                    .into_any_element(),
4857                            ),
4858                            single_example(
4859                                "Uppercase Repo",
4860                                div()
4861                                    .w(example_width)
4862                                    .overflow_hidden()
4863                                    .child(PanelRepoFooter::new_preview(
4864                                        SharedString::from("LICENSES"),
4865                                        Some(custom("main", ahead_of_upstream)),
4866                                    ))
4867                                    .into_any_element(),
4868                            ),
4869                            single_example(
4870                                "Uppercase Branch",
4871                                div()
4872                                    .w(example_width)
4873                                    .overflow_hidden()
4874                                    .child(PanelRepoFooter::new_preview(
4875                                        SharedString::from("zed"),
4876                                        Some(custom("update-README", behind_upstream)),
4877                                    ))
4878                                    .into_any_element(),
4879                            ),
4880                        ],
4881                    )
4882                    .grow()
4883                    .vertical(),
4884                ])
4885                .into_any_element(),
4886        )
4887    }
4888}
4889
4890#[cfg(test)]
4891mod tests {
4892    use git::status::{StatusCode, UnmergedStatus, UnmergedStatusCode};
4893    use gpui::{TestAppContext, VisualTestContext};
4894    use project::{FakeFs, WorktreeSettings};
4895    use serde_json::json;
4896    use settings::SettingsStore;
4897    use theme::LoadThemes;
4898    use util::path;
4899
4900    use super::*;
4901
4902    fn init_test(cx: &mut gpui::TestAppContext) {
4903        zlog::init_test();
4904
4905        cx.update(|cx| {
4906            let settings_store = SettingsStore::test(cx);
4907            cx.set_global(settings_store);
4908            AgentSettings::register(cx);
4909            WorktreeSettings::register(cx);
4910            workspace::init_settings(cx);
4911            theme::init(LoadThemes::JustBase, cx);
4912            language::init(cx);
4913            editor::init(cx);
4914            Project::init_settings(cx);
4915            crate::init(cx);
4916        });
4917    }
4918
4919    #[gpui::test]
4920    async fn test_entry_worktree_paths(cx: &mut TestAppContext) {
4921        init_test(cx);
4922        let fs = FakeFs::new(cx.background_executor.clone());
4923        fs.insert_tree(
4924            "/root",
4925            json!({
4926                "zed": {
4927                    ".git": {},
4928                    "crates": {
4929                        "gpui": {
4930                            "gpui.rs": "fn main() {}"
4931                        },
4932                        "util": {
4933                            "util.rs": "fn do_it() {}"
4934                        }
4935                    }
4936                },
4937            }),
4938        )
4939        .await;
4940
4941        fs.set_status_for_repo(
4942            Path::new(path!("/root/zed/.git")),
4943            &[
4944                (
4945                    Path::new("crates/gpui/gpui.rs"),
4946                    StatusCode::Modified.worktree(),
4947                ),
4948                (
4949                    Path::new("crates/util/util.rs"),
4950                    StatusCode::Modified.worktree(),
4951                ),
4952            ],
4953        );
4954
4955        let project =
4956            Project::test(fs.clone(), [path!("/root/zed/crates/gpui").as_ref()], cx).await;
4957        let workspace =
4958            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4959        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4960
4961        cx.read(|cx| {
4962            project
4963                .read(cx)
4964                .worktrees(cx)
4965                .next()
4966                .unwrap()
4967                .read(cx)
4968                .as_local()
4969                .unwrap()
4970                .scan_complete()
4971        })
4972        .await;
4973
4974        cx.executor().run_until_parked();
4975
4976        let panel = workspace.update(cx, GitPanel::new).unwrap();
4977
4978        let handle = cx.update_window_entity(&panel, |panel, _, _| {
4979            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
4980        });
4981        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
4982        handle.await;
4983
4984        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
4985        pretty_assertions::assert_eq!(
4986            entries,
4987            [
4988                GitListEntry::Header(GitHeaderEntry {
4989                    header: Section::Tracked
4990                }),
4991                GitListEntry::Status(GitStatusEntry {
4992                    abs_path: path!("/root/zed/crates/gpui/gpui.rs").into(),
4993                    repo_path: "crates/gpui/gpui.rs".into(),
4994                    status: StatusCode::Modified.worktree(),
4995                    staging: StageStatus::Unstaged,
4996                }),
4997                GitListEntry::Status(GitStatusEntry {
4998                    abs_path: path!("/root/zed/crates/util/util.rs").into(),
4999                    repo_path: "crates/util/util.rs".into(),
5000                    status: StatusCode::Modified.worktree(),
5001                    staging: StageStatus::Unstaged,
5002                },),
5003            ],
5004        );
5005
5006        let handle = cx.update_window_entity(&panel, |panel, _, _| {
5007            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5008        });
5009        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5010        handle.await;
5011        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5012        pretty_assertions::assert_eq!(
5013            entries,
5014            [
5015                GitListEntry::Header(GitHeaderEntry {
5016                    header: Section::Tracked
5017                }),
5018                GitListEntry::Status(GitStatusEntry {
5019                    abs_path: path!("/root/zed/crates/gpui/gpui.rs").into(),
5020                    repo_path: "crates/gpui/gpui.rs".into(),
5021                    status: StatusCode::Modified.worktree(),
5022                    staging: StageStatus::Unstaged,
5023                }),
5024                GitListEntry::Status(GitStatusEntry {
5025                    abs_path: path!("/root/zed/crates/util/util.rs").into(),
5026                    repo_path: "crates/util/util.rs".into(),
5027                    status: StatusCode::Modified.worktree(),
5028                    staging: StageStatus::Unstaged,
5029                },),
5030            ],
5031        );
5032    }
5033
5034    #[gpui::test]
5035    async fn test_bulk_staging(cx: &mut TestAppContext) {
5036        use GitListEntry::*;
5037
5038        init_test(cx);
5039        let fs = FakeFs::new(cx.background_executor.clone());
5040        fs.insert_tree(
5041            "/root",
5042            json!({
5043                "project": {
5044                    ".git": {},
5045                    "src": {
5046                        "main.rs": "fn main() {}",
5047                        "lib.rs": "pub fn hello() {}",
5048                        "utils.rs": "pub fn util() {}"
5049                    },
5050                    "tests": {
5051                        "test.rs": "fn test() {}"
5052                    },
5053                    "new_file.txt": "new content",
5054                    "another_new.rs": "// new file",
5055                    "conflict.txt": "conflicted content"
5056                }
5057            }),
5058        )
5059        .await;
5060
5061        fs.set_status_for_repo(
5062            Path::new(path!("/root/project/.git")),
5063            &[
5064                (Path::new("src/main.rs"), StatusCode::Modified.worktree()),
5065                (Path::new("src/lib.rs"), StatusCode::Modified.worktree()),
5066                (Path::new("tests/test.rs"), StatusCode::Modified.worktree()),
5067                (Path::new("new_file.txt"), FileStatus::Untracked),
5068                (Path::new("another_new.rs"), FileStatus::Untracked),
5069                (Path::new("src/utils.rs"), FileStatus::Untracked),
5070                (
5071                    Path::new("conflict.txt"),
5072                    UnmergedStatus {
5073                        first_head: UnmergedStatusCode::Updated,
5074                        second_head: UnmergedStatusCode::Updated,
5075                    }
5076                    .into(),
5077                ),
5078            ],
5079        );
5080
5081        let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
5082        let workspace =
5083            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5084        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5085
5086        cx.read(|cx| {
5087            project
5088                .read(cx)
5089                .worktrees(cx)
5090                .next()
5091                .unwrap()
5092                .read(cx)
5093                .as_local()
5094                .unwrap()
5095                .scan_complete()
5096        })
5097        .await;
5098
5099        cx.executor().run_until_parked();
5100
5101        let panel = workspace.update(cx, GitPanel::new).unwrap();
5102
5103        let handle = cx.update_window_entity(&panel, |panel, _, _| {
5104            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5105        });
5106        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5107        handle.await;
5108
5109        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5110        #[rustfmt::skip]
5111        pretty_assertions::assert_matches!(
5112            entries.as_slice(),
5113            &[
5114                Header(GitHeaderEntry { header: Section::Conflict }),
5115                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5116                Header(GitHeaderEntry { header: Section::Tracked }),
5117                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5118                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5119                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5120                Header(GitHeaderEntry { header: Section::New }),
5121                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5122                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5123                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5124            ],
5125        );
5126
5127        let second_status_entry = entries[3].clone();
5128        panel.update_in(cx, |panel, window, cx| {
5129            panel.toggle_staged_for_entry(&second_status_entry, window, cx);
5130        });
5131
5132        panel.update_in(cx, |panel, window, cx| {
5133            panel.selected_entry = Some(7);
5134            panel.stage_range(&git::StageRange, window, cx);
5135        });
5136
5137        cx.read(|cx| {
5138            project
5139                .read(cx)
5140                .worktrees(cx)
5141                .next()
5142                .unwrap()
5143                .read(cx)
5144                .as_local()
5145                .unwrap()
5146                .scan_complete()
5147        })
5148        .await;
5149
5150        cx.executor().run_until_parked();
5151
5152        let handle = cx.update_window_entity(&panel, |panel, _, _| {
5153            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5154        });
5155        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5156        handle.await;
5157
5158        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5159        #[rustfmt::skip]
5160        pretty_assertions::assert_matches!(
5161            entries.as_slice(),
5162            &[
5163                Header(GitHeaderEntry { header: Section::Conflict }),
5164                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5165                Header(GitHeaderEntry { header: Section::Tracked }),
5166                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5167                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5168                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5169                Header(GitHeaderEntry { header: Section::New }),
5170                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5171                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5172                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5173            ],
5174        );
5175
5176        let third_status_entry = entries[4].clone();
5177        panel.update_in(cx, |panel, window, cx| {
5178            panel.toggle_staged_for_entry(&third_status_entry, window, cx);
5179        });
5180
5181        panel.update_in(cx, |panel, window, cx| {
5182            panel.selected_entry = Some(9);
5183            panel.stage_range(&git::StageRange, window, cx);
5184        });
5185
5186        cx.read(|cx| {
5187            project
5188                .read(cx)
5189                .worktrees(cx)
5190                .next()
5191                .unwrap()
5192                .read(cx)
5193                .as_local()
5194                .unwrap()
5195                .scan_complete()
5196        })
5197        .await;
5198
5199        cx.executor().run_until_parked();
5200
5201        let handle = cx.update_window_entity(&panel, |panel, _, _| {
5202            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5203        });
5204        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5205        handle.await;
5206
5207        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5208        #[rustfmt::skip]
5209        pretty_assertions::assert_matches!(
5210            entries.as_slice(),
5211            &[
5212                Header(GitHeaderEntry { header: Section::Conflict }),
5213                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5214                Header(GitHeaderEntry { header: Section::Tracked }),
5215                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5216                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5217                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5218                Header(GitHeaderEntry { header: Section::New }),
5219                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5220                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5221                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5222            ],
5223        );
5224    }
5225
5226    #[gpui::test]
5227    async fn test_amend_commit_message_handling(cx: &mut TestAppContext) {
5228        init_test(cx);
5229        let fs = FakeFs::new(cx.background_executor.clone());
5230        fs.insert_tree(
5231            "/root",
5232            json!({
5233                "project": {
5234                    ".git": {},
5235                    "src": {
5236                        "main.rs": "fn main() {}"
5237                    }
5238                }
5239            }),
5240        )
5241        .await;
5242
5243        fs.set_status_for_repo(
5244            Path::new(path!("/root/project/.git")),
5245            &[(Path::new("src/main.rs"), StatusCode::Modified.worktree())],
5246        );
5247
5248        let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
5249        let workspace =
5250            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5251        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5252
5253        let panel = workspace.update(cx, GitPanel::new).unwrap();
5254
5255        // Test: User has commit message, enables amend (saves message), then disables (restores message)
5256        panel.update(cx, |panel, cx| {
5257            panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
5258                let start = buffer.anchor_before(0);
5259                let end = buffer.anchor_after(buffer.len());
5260                buffer.edit([(start..end, "Initial commit message")], None, cx);
5261            });
5262
5263            panel.set_amend_pending(true, cx);
5264            assert!(panel.original_commit_message.is_some());
5265
5266            panel.set_amend_pending(false, cx);
5267            let current_message = panel.commit_message_buffer(cx).read(cx).text();
5268            assert_eq!(current_message, "Initial commit message");
5269            assert!(panel.original_commit_message.is_none());
5270        });
5271
5272        // Test: User has empty commit message, enables amend, then disables (clears message)
5273        panel.update(cx, |panel, cx| {
5274            panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
5275                let start = buffer.anchor_before(0);
5276                let end = buffer.anchor_after(buffer.len());
5277                buffer.edit([(start..end, "")], None, cx);
5278            });
5279
5280            panel.set_amend_pending(true, cx);
5281            assert!(panel.original_commit_message.is_none());
5282
5283            panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
5284                let start = buffer.anchor_before(0);
5285                let end = buffer.anchor_after(buffer.len());
5286                buffer.edit([(start..end, "Previous commit message")], None, cx);
5287            });
5288
5289            panel.set_amend_pending(false, cx);
5290            let current_message = panel.commit_message_buffer(cx).read(cx).text();
5291            assert_eq!(current_message, "");
5292        });
5293    }
5294}