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