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