git_panel.rs

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