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