git_panel.rs

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