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