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