git_panel.rs

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