git_panel.rs

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