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