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    signoff_enabled: bool,
 354    pending_serialization: Task<()>,
 355    pub(crate) project: Entity<Project>,
 356    scroll_handle: UniformListScrollHandle,
 357    max_width_item_index: Option<usize>,
 358    selected_entry: Option<usize>,
 359    marked_entries: Vec<usize>,
 360    tracked_count: usize,
 361    tracked_staged_count: usize,
 362    update_visible_entries_task: Task<()>,
 363    width: Option<Pixels>,
 364    workspace: WeakEntity<Workspace>,
 365    context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
 366    modal_open: bool,
 367    show_placeholders: bool,
 368    local_committer: Option<GitCommitter>,
 369    local_committer_task: Option<Task<()>>,
 370    bulk_staging: Option<BulkStaging>,
 371    _settings_subscription: Subscription,
 372}
 373
 374#[derive(Clone, Debug, PartialEq, Eq)]
 375struct BulkStaging {
 376    repo_id: RepositoryId,
 377    anchor: RepoPath,
 378}
 379
 380const MAX_PANEL_EDITOR_LINES: usize = 6;
 381
 382pub(crate) fn commit_message_editor(
 383    commit_message_buffer: Entity<Buffer>,
 384    placeholder: Option<SharedString>,
 385    project: Entity<Project>,
 386    in_panel: bool,
 387    window: &mut Window,
 388    cx: &mut Context<Editor>,
 389) -> Editor {
 390    project.update(cx, |this, cx| {
 391        this.mark_buffer_as_non_searchable(commit_message_buffer.read(cx).remote_id(), cx);
 392    });
 393    let buffer = cx.new(|cx| MultiBuffer::singleton(commit_message_buffer, cx));
 394    let max_lines = if in_panel { MAX_PANEL_EDITOR_LINES } else { 18 };
 395    let mut commit_editor = Editor::new(
 396        EditorMode::AutoHeight {
 397            min_lines: 1,
 398            max_lines: Some(max_lines),
 399        },
 400        buffer,
 401        None,
 402        window,
 403        cx,
 404    );
 405    commit_editor.set_collaboration_hub(Box::new(project));
 406    commit_editor.set_use_autoclose(false);
 407    commit_editor.set_show_gutter(false, cx);
 408    commit_editor.set_use_modal_editing(true);
 409    commit_editor.set_show_wrap_guides(false, cx);
 410    commit_editor.set_show_indent_guides(false, cx);
 411    let placeholder = placeholder.unwrap_or("Enter commit message".into());
 412    commit_editor.set_placeholder_text(placeholder, cx);
 413    commit_editor
 414}
 415
 416impl GitPanel {
 417    fn new(
 418        workspace: &mut Workspace,
 419        window: &mut Window,
 420        cx: &mut Context<Workspace>,
 421    ) -> Entity<Self> {
 422        let project = workspace.project().clone();
 423        let app_state = workspace.app_state().clone();
 424        let fs = app_state.fs.clone();
 425        let git_store = project.read(cx).git_store().clone();
 426        let active_repository = project.read(cx).active_repository(cx);
 427
 428        cx.new(|cx| {
 429            let focus_handle = cx.focus_handle();
 430            cx.on_focus(&focus_handle, window, Self::focus_in).detach();
 431            cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
 432                this.hide_scrollbars(window, cx);
 433            })
 434            .detach();
 435
 436            let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
 437            cx.observe_global::<SettingsStore>(move |this, cx| {
 438                let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
 439                if is_sort_by_path != was_sort_by_path {
 440                    this.update_visible_entries(cx);
 441                }
 442                was_sort_by_path = is_sort_by_path
 443            })
 444            .detach();
 445
 446            // just to let us render a placeholder editor.
 447            // Once the active git repo is set, this buffer will be replaced.
 448            let temporary_buffer = cx.new(|cx| Buffer::local("", cx));
 449            let commit_editor = cx.new(|cx| {
 450                commit_message_editor(temporary_buffer, None, project.clone(), true, window, cx)
 451            });
 452
 453            commit_editor.update(cx, |editor, cx| {
 454                editor.clear(window, cx);
 455            });
 456
 457            let scroll_handle = UniformListScrollHandle::new();
 458
 459            let vertical_scrollbar = ScrollbarProperties {
 460                axis: Axis::Vertical,
 461                state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
 462                show_scrollbar: false,
 463                show_track: false,
 464                auto_hide: false,
 465                hide_task: None,
 466            };
 467
 468            let horizontal_scrollbar = ScrollbarProperties {
 469                axis: Axis::Horizontal,
 470                state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
 471                show_scrollbar: false,
 472                show_track: false,
 473                auto_hide: false,
 474                hide_task: None,
 475            };
 476
 477            let mut assistant_enabled = AgentSettings::get_global(cx).enabled;
 478            let mut was_ai_disabled = DisableAiSettings::get_global(cx).disable_ai;
 479            let _settings_subscription = cx.observe_global::<SettingsStore>(move |_, cx| {
 480                let is_ai_disabled = DisableAiSettings::get_global(cx).disable_ai;
 481                if assistant_enabled != AgentSettings::get_global(cx).enabled
 482                    || was_ai_disabled != is_ai_disabled
 483                {
 484                    assistant_enabled = AgentSettings::get_global(cx).enabled;
 485                    was_ai_disabled = is_ai_disabled;
 486                    cx.notify();
 487                }
 488            });
 489
 490            cx.subscribe_in(
 491                &git_store,
 492                window,
 493                move |this, _git_store, event, window, cx| match event {
 494                    GitStoreEvent::ActiveRepositoryChanged(_) => {
 495                        this.active_repository = this.project.read(cx).active_repository(cx);
 496                        this.schedule_update(true, window, cx);
 497                    }
 498                    GitStoreEvent::RepositoryUpdated(
 499                        _,
 500                        RepositoryEvent::Updated { full_scan, .. },
 501                        true,
 502                    ) => {
 503                        this.schedule_update(*full_scan, window, cx);
 504                    }
 505
 506                    GitStoreEvent::RepositoryAdded(_) | GitStoreEvent::RepositoryRemoved(_) => {
 507                        this.schedule_update(false, window, cx);
 508                    }
 509                    GitStoreEvent::IndexWriteError(error) => {
 510                        this.workspace
 511                            .update(cx, |workspace, cx| {
 512                                workspace.show_error(error, cx);
 513                            })
 514                            .ok();
 515                    }
 516                    GitStoreEvent::RepositoryUpdated(_, _, _) => {}
 517                    GitStoreEvent::JobsUpdated | GitStoreEvent::ConflictsUpdated => {}
 518                },
 519            )
 520            .detach();
 521
 522            let mut this = Self {
 523                active_repository,
 524                commit_editor,
 525                conflicted_count: 0,
 526                conflicted_staged_count: 0,
 527                add_coauthors: true,
 528                generate_commit_message_task: None,
 529                entries: Vec::new(),
 530                focus_handle: cx.focus_handle(),
 531                fs,
 532                new_count: 0,
 533                new_staged_count: 0,
 534                pending: Vec::new(),
 535                pending_commit: None,
 536                amend_pending: false,
 537                signoff_enabled: false,
 538                pending_serialization: Task::ready(()),
 539                single_staged_entry: None,
 540                single_tracked_entry: None,
 541                project,
 542                scroll_handle,
 543                max_width_item_index: None,
 544                selected_entry: None,
 545                marked_entries: Vec::new(),
 546                tracked_count: 0,
 547                tracked_staged_count: 0,
 548                update_visible_entries_task: Task::ready(()),
 549                width: None,
 550                show_placeholders: false,
 551                local_committer: None,
 552                local_committer_task: None,
 553                context_menu: None,
 554                workspace: workspace.weak_handle(),
 555                modal_open: false,
 556                entry_count: 0,
 557                horizontal_scrollbar,
 558                vertical_scrollbar,
 559                bulk_staging: None,
 560                _settings_subscription,
 561            };
 562
 563            this.schedule_update(false, window, cx);
 564            this
 565        })
 566    }
 567
 568    fn hide_scrollbars(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 569        self.horizontal_scrollbar.hide(window, cx);
 570        self.vertical_scrollbar.hide(window, cx);
 571    }
 572
 573    fn update_scrollbar_properties(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
 574        // TODO: This PR should have defined Editor's `scrollbar.axis`
 575        // as an Option<ScrollbarAxis>, not a ScrollbarAxes as it would allow you to
 576        // `.unwrap_or(EditorSettings::get_global(cx).scrollbar.show)`.
 577        //
 578        // Once this is fixed we can extend the GitPanelSettings with a `scrollbar.axis`
 579        // so we can show each axis based on the settings.
 580        //
 581        // We should fix this. PR: https://github.com/zed-industries/zed/pull/19495
 582
 583        let show_setting = GitPanelSettings::get_global(cx)
 584            .scrollbar
 585            .show
 586            .unwrap_or(EditorSettings::get_global(cx).scrollbar.show);
 587
 588        let scroll_handle = self.scroll_handle.0.borrow();
 589
 590        let autohide = |show: ShowScrollbar, cx: &mut Context<Self>| match show {
 591            ShowScrollbar::Auto => true,
 592            ShowScrollbar::System => cx
 593                .try_global::<ScrollbarAutoHide>()
 594                .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
 595            ShowScrollbar::Always => false,
 596            ShowScrollbar::Never => false,
 597        };
 598
 599        let longest_item_width = scroll_handle.last_item_size.and_then(|size| {
 600            (size.contents.width > size.item.width).then_some(size.contents.width)
 601        });
 602
 603        // is there an item long enough that we should show a horizontal scrollbar?
 604        let item_wider_than_container = if let Some(longest_item_width) = longest_item_width {
 605            longest_item_width > px(scroll_handle.base_handle.bounds().size.width.0)
 606        } else {
 607            true
 608        };
 609
 610        let show_horizontal = match (show_setting, item_wider_than_container) {
 611            (_, false) => false,
 612            (ShowScrollbar::Auto | ShowScrollbar::System | ShowScrollbar::Always, true) => true,
 613            (ShowScrollbar::Never, true) => false,
 614        };
 615
 616        let show_vertical = match show_setting {
 617            ShowScrollbar::Auto | ShowScrollbar::System | ShowScrollbar::Always => true,
 618            ShowScrollbar::Never => false,
 619        };
 620
 621        let show_horizontal_track =
 622            show_horizontal && matches!(show_setting, ShowScrollbar::Always);
 623
 624        // TODO: we probably should hide the scroll track when the list doesn't need to scroll
 625        let show_vertical_track = show_vertical && matches!(show_setting, ShowScrollbar::Always);
 626
 627        self.vertical_scrollbar = ScrollbarProperties {
 628            axis: self.vertical_scrollbar.axis,
 629            state: self.vertical_scrollbar.state.clone(),
 630            show_scrollbar: show_vertical,
 631            show_track: show_vertical_track,
 632            auto_hide: autohide(show_setting, cx),
 633            hide_task: None,
 634        };
 635
 636        self.horizontal_scrollbar = ScrollbarProperties {
 637            axis: self.horizontal_scrollbar.axis,
 638            state: self.horizontal_scrollbar.state.clone(),
 639            show_scrollbar: show_horizontal,
 640            show_track: show_horizontal_track,
 641            auto_hide: autohide(show_setting, cx),
 642            hide_task: None,
 643        };
 644
 645        cx.notify();
 646    }
 647
 648    pub fn entry_by_path(&self, path: &RepoPath, cx: &App) -> Option<usize> {
 649        if GitPanelSettings::get_global(cx).sort_by_path {
 650            return self
 651                .entries
 652                .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path))
 653                .ok();
 654        }
 655
 656        if self.conflicted_count > 0 {
 657            let conflicted_start = 1;
 658            if let Ok(ix) = self.entries[conflicted_start..conflicted_start + self.conflicted_count]
 659                .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path))
 660            {
 661                return Some(conflicted_start + ix);
 662            }
 663        }
 664        if self.tracked_count > 0 {
 665            let tracked_start = if self.conflicted_count > 0 {
 666                1 + self.conflicted_count
 667            } else {
 668                0
 669            } + 1;
 670            if let Ok(ix) = self.entries[tracked_start..tracked_start + self.tracked_count]
 671                .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path))
 672            {
 673                return Some(tracked_start + ix);
 674            }
 675        }
 676        if self.new_count > 0 {
 677            let untracked_start = if self.conflicted_count > 0 {
 678                1 + self.conflicted_count
 679            } else {
 680                0
 681            } + if self.tracked_count > 0 {
 682                1 + self.tracked_count
 683            } else {
 684                0
 685            } + 1;
 686            if let Ok(ix) = self.entries[untracked_start..untracked_start + self.new_count]
 687                .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(path))
 688            {
 689                return Some(untracked_start + ix);
 690            }
 691        }
 692        None
 693    }
 694
 695    pub fn select_entry_by_path(
 696        &mut self,
 697        path: ProjectPath,
 698        _: &mut Window,
 699        cx: &mut Context<Self>,
 700    ) {
 701        let Some(git_repo) = self.active_repository.as_ref() else {
 702            return;
 703        };
 704        let Some(repo_path) = git_repo.read(cx).project_path_to_repo_path(&path, cx) else {
 705            return;
 706        };
 707        let Some(ix) = self.entry_by_path(&repo_path, cx) else {
 708            return;
 709        };
 710        self.selected_entry = Some(ix);
 711        cx.notify();
 712    }
 713
 714    fn serialization_key(workspace: &Workspace) -> Option<String> {
 715        workspace
 716            .database_id()
 717            .map(|id| i64::from(id).to_string())
 718            .or(workspace.session_id())
 719            .map(|id| format!("{}-{:?}", GIT_PANEL_KEY, id))
 720    }
 721
 722    fn serialize(&mut self, cx: &mut Context<Self>) {
 723        let width = self.width;
 724        let amend_pending = self.amend_pending;
 725        let signoff_enabled = self.signoff_enabled;
 726
 727        self.pending_serialization = cx.spawn(async move |git_panel, cx| {
 728            cx.background_executor()
 729                .timer(SERIALIZATION_THROTTLE_TIME)
 730                .await;
 731            let Some(serialization_key) = git_panel
 732                .update(cx, |git_panel, cx| {
 733                    git_panel
 734                        .workspace
 735                        .read_with(cx, |workspace, _| Self::serialization_key(workspace))
 736                        .ok()
 737                        .flatten()
 738                })
 739                .ok()
 740                .flatten()
 741            else {
 742                return;
 743            };
 744            cx.background_spawn(
 745                async move {
 746                    KEY_VALUE_STORE
 747                        .write_kvp(
 748                            serialization_key,
 749                            serde_json::to_string(&SerializedGitPanel {
 750                                width,
 751                                amend_pending,
 752                                signoff_enabled,
 753                            })?,
 754                        )
 755                        .await?;
 756                    anyhow::Ok(())
 757                }
 758                .log_err(),
 759            )
 760            .await;
 761        });
 762    }
 763
 764    pub(crate) fn set_modal_open(&mut self, open: bool, cx: &mut Context<Self>) {
 765        self.modal_open = open;
 766        cx.notify();
 767    }
 768
 769    fn dispatch_context(&self, window: &mut Window, cx: &Context<Self>) -> KeyContext {
 770        let mut dispatch_context = KeyContext::new_with_defaults();
 771        dispatch_context.add("GitPanel");
 772
 773        if window
 774            .focused(cx)
 775            .is_some_and(|focused| self.focus_handle == focused)
 776        {
 777            dispatch_context.add("menu");
 778            dispatch_context.add("ChangesList");
 779        }
 780
 781        if self.commit_editor.read(cx).is_focused(window) {
 782            dispatch_context.add("CommitEditor");
 783        }
 784
 785        dispatch_context
 786    }
 787
 788    fn close_panel(&mut self, _: &Close, _window: &mut Window, cx: &mut Context<Self>) {
 789        cx.emit(PanelEvent::Close);
 790    }
 791
 792    fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 793        if !self.focus_handle.contains_focused(window, cx) {
 794            cx.emit(Event::Focus);
 795        }
 796    }
 797
 798    fn scroll_to_selected_entry(&mut self, cx: &mut Context<Self>) {
 799        if let Some(selected_entry) = self.selected_entry {
 800            self.scroll_handle
 801                .scroll_to_item(selected_entry, ScrollStrategy::Center);
 802        }
 803
 804        cx.notify();
 805    }
 806
 807    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
 808        if !self.entries.is_empty() {
 809            self.selected_entry = Some(1);
 810            self.scroll_to_selected_entry(cx);
 811        }
 812    }
 813
 814    fn select_previous(
 815        &mut self,
 816        _: &SelectPrevious,
 817        _window: &mut Window,
 818        cx: &mut Context<Self>,
 819    ) {
 820        let item_count = self.entries.len();
 821        if item_count == 0 {
 822            return;
 823        }
 824
 825        if let Some(selected_entry) = self.selected_entry {
 826            let new_selected_entry = if selected_entry > 0 {
 827                selected_entry - 1
 828            } else {
 829                selected_entry
 830            };
 831
 832            if matches!(
 833                self.entries.get(new_selected_entry),
 834                Some(GitListEntry::Header(..))
 835            ) {
 836                if new_selected_entry > 0 {
 837                    self.selected_entry = Some(new_selected_entry - 1)
 838                }
 839            } else {
 840                self.selected_entry = Some(new_selected_entry);
 841            }
 842
 843            self.scroll_to_selected_entry(cx);
 844        }
 845
 846        cx.notify();
 847    }
 848
 849    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
 850        let item_count = self.entries.len();
 851        if item_count == 0 {
 852            return;
 853        }
 854
 855        if let Some(selected_entry) = self.selected_entry {
 856            let new_selected_entry = if selected_entry < item_count - 1 {
 857                selected_entry + 1
 858            } else {
 859                selected_entry
 860            };
 861            if matches!(
 862                self.entries.get(new_selected_entry),
 863                Some(GitListEntry::Header(..))
 864            ) {
 865                self.selected_entry = Some(new_selected_entry + 1);
 866            } else {
 867                self.selected_entry = Some(new_selected_entry);
 868            }
 869
 870            self.scroll_to_selected_entry(cx);
 871        }
 872
 873        cx.notify();
 874    }
 875
 876    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
 877        if self.entries.last().is_some() {
 878            self.selected_entry = Some(self.entries.len() - 1);
 879            self.scroll_to_selected_entry(cx);
 880        }
 881    }
 882
 883    fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
 884        self.commit_editor.update(cx, |editor, cx| {
 885            window.focus(&editor.focus_handle(cx));
 886        });
 887        cx.notify();
 888    }
 889
 890    fn select_first_entry_if_none(&mut self, cx: &mut Context<Self>) {
 891        let have_entries = self
 892            .active_repository
 893            .as_ref()
 894            .is_some_and(|active_repository| active_repository.read(cx).status_summary().count > 0);
 895        if have_entries && self.selected_entry.is_none() {
 896            self.selected_entry = Some(1);
 897            self.scroll_to_selected_entry(cx);
 898            cx.notify();
 899        }
 900    }
 901
 902    fn focus_changes_list(
 903        &mut self,
 904        _: &FocusChanges,
 905        window: &mut Window,
 906        cx: &mut Context<Self>,
 907    ) {
 908        self.select_first_entry_if_none(cx);
 909
 910        cx.focus_self(window);
 911        cx.notify();
 912    }
 913
 914    fn get_selected_entry(&self) -> Option<&GitListEntry> {
 915        self.selected_entry.and_then(|i| self.entries.get(i))
 916    }
 917
 918    fn open_diff(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
 919        maybe!({
 920            let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
 921            let workspace = self.workspace.upgrade()?;
 922            let git_repo = self.active_repository.as_ref()?;
 923
 924            if let Some(project_diff) = workspace.read(cx).active_item_as::<ProjectDiff>(cx)
 925                && let Some(project_path) = project_diff.read(cx).active_path(cx)
 926                && Some(&entry.repo_path)
 927                    == git_repo
 928                        .read(cx)
 929                        .project_path_to_repo_path(&project_path, cx)
 930                        .as_ref()
 931            {
 932                project_diff.focus_handle(cx).focus(window);
 933                project_diff.update(cx, |project_diff, cx| project_diff.autoscroll(cx));
 934                return None;
 935            };
 936
 937            self.workspace
 938                .update(cx, |workspace, cx| {
 939                    ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
 940                })
 941                .ok();
 942            self.focus_handle.focus(window);
 943
 944            Some(())
 945        });
 946    }
 947
 948    fn open_file(
 949        &mut self,
 950        _: &menu::SecondaryConfirm,
 951        window: &mut Window,
 952        cx: &mut Context<Self>,
 953    ) {
 954        maybe!({
 955            let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
 956            let active_repo = self.active_repository.as_ref()?;
 957            let path = active_repo
 958                .read(cx)
 959                .repo_path_to_project_path(&entry.repo_path, cx)?;
 960            if entry.status.is_deleted() {
 961                return None;
 962            }
 963
 964            self.workspace
 965                .update(cx, |workspace, cx| {
 966                    workspace
 967                        .open_path_preview(path, None, false, false, true, window, cx)
 968                        .detach_and_prompt_err("Failed to open file", window, cx, |e, _, _| {
 969                            Some(format!("{e}"))
 970                        });
 971                })
 972                .ok()
 973        });
 974    }
 975
 976    fn revert_selected(
 977        &mut self,
 978        action: &git::RestoreFile,
 979        window: &mut Window,
 980        cx: &mut Context<Self>,
 981    ) {
 982        maybe!({
 983            let list_entry = self.entries.get(self.selected_entry?)?.clone();
 984            let entry = list_entry.status_entry()?.to_owned();
 985            let skip_prompt = action.skip_prompt || entry.status.is_created();
 986
 987            let prompt = if skip_prompt {
 988                Task::ready(Ok(0))
 989            } else {
 990                let prompt = window.prompt(
 991                    PromptLevel::Warning,
 992                    &format!(
 993                        "Are you sure you want to restore {}?",
 994                        entry
 995                            .repo_path
 996                            .file_name()
 997                            .unwrap_or(entry.repo_path.as_os_str())
 998                            .to_string_lossy()
 999                    ),
1000                    None,
1001                    &["Restore", "Cancel"],
1002                    cx,
1003                );
1004                cx.background_spawn(prompt)
1005            };
1006
1007            let this = cx.weak_entity();
1008            window
1009                .spawn(cx, async move |cx| {
1010                    if prompt.await? != 0 {
1011                        return anyhow::Ok(());
1012                    }
1013
1014                    this.update_in(cx, |this, window, cx| {
1015                        this.revert_entry(&entry, window, cx);
1016                    })?;
1017
1018                    Ok(())
1019                })
1020                .detach();
1021            Some(())
1022        });
1023    }
1024
1025    fn revert_entry(
1026        &mut self,
1027        entry: &GitStatusEntry,
1028        window: &mut Window,
1029        cx: &mut Context<Self>,
1030    ) {
1031        maybe!({
1032            let active_repo = self.active_repository.clone()?;
1033            let path = active_repo
1034                .read(cx)
1035                .repo_path_to_project_path(&entry.repo_path, cx)?;
1036            let workspace = self.workspace.clone();
1037
1038            if entry.status.staging().has_staged() {
1039                self.change_file_stage(false, vec![entry.clone()], cx);
1040            }
1041            let filename = path.path.file_name()?.to_string_lossy();
1042
1043            if !entry.status.is_created() {
1044                self.perform_checkout(vec![entry.clone()], cx);
1045            } else {
1046                let prompt = prompt(&format!("Trash {}?", filename), None, window, cx);
1047                cx.spawn_in(window, async move |_, cx| {
1048                    match prompt.await? {
1049                        TrashCancel::Trash => {}
1050                        TrashCancel::Cancel => return Ok(()),
1051                    }
1052                    let task = workspace.update(cx, |workspace, cx| {
1053                        workspace
1054                            .project()
1055                            .update(cx, |project, cx| project.delete_file(path, true, cx))
1056                    })?;
1057                    if let Some(task) = task {
1058                        task.await?;
1059                    }
1060                    Ok(())
1061                })
1062                .detach_and_prompt_err(
1063                    "Failed to trash file",
1064                    window,
1065                    cx,
1066                    |e, _, _| Some(format!("{e}")),
1067                );
1068            }
1069            Some(())
1070        });
1071    }
1072
1073    fn perform_checkout(&mut self, entries: Vec<GitStatusEntry>, cx: &mut Context<Self>) {
1074        let workspace = self.workspace.clone();
1075        let Some(active_repository) = self.active_repository.clone() else {
1076            return;
1077        };
1078
1079        let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1;
1080        self.pending.push(PendingOperation {
1081            op_id,
1082            target_status: TargetStatus::Reverted,
1083            entries: entries.clone(),
1084            finished: false,
1085        });
1086        self.update_visible_entries(cx);
1087        let task = cx.spawn(async move |_, cx| {
1088            let tasks: Vec<_> = workspace.update(cx, |workspace, cx| {
1089                workspace.project().update(cx, |project, cx| {
1090                    entries
1091                        .iter()
1092                        .filter_map(|entry| {
1093                            let path = active_repository
1094                                .read(cx)
1095                                .repo_path_to_project_path(&entry.repo_path, cx)?;
1096                            Some(project.open_buffer(path, cx))
1097                        })
1098                        .collect()
1099                })
1100            })?;
1101
1102            let buffers = futures::future::join_all(tasks).await;
1103
1104            active_repository
1105                .update(cx, |repo, cx| {
1106                    repo.checkout_files(
1107                        "HEAD",
1108                        entries
1109                            .into_iter()
1110                            .map(|entries| entries.repo_path)
1111                            .collect(),
1112                        cx,
1113                    )
1114                })?
1115                .await??;
1116
1117            let tasks: Vec<_> = cx.update(|cx| {
1118                buffers
1119                    .iter()
1120                    .filter_map(|buffer| {
1121                        buffer.as_ref().ok()?.update(cx, |buffer, cx| {
1122                            buffer.is_dirty().then(|| buffer.reload(cx))
1123                        })
1124                    })
1125                    .collect()
1126            })?;
1127
1128            futures::future::join_all(tasks).await;
1129
1130            Ok(())
1131        });
1132
1133        cx.spawn(async move |this, cx| {
1134            let result = task.await;
1135
1136            this.update(cx, |this, cx| {
1137                for pending in this.pending.iter_mut() {
1138                    if pending.op_id == op_id {
1139                        pending.finished = true;
1140                        if result.is_err() {
1141                            pending.target_status = TargetStatus::Unchanged;
1142                            this.update_visible_entries(cx);
1143                        }
1144                        break;
1145                    }
1146                }
1147                result
1148                    .map_err(|e| {
1149                        this.show_error_toast("checkout", e, cx);
1150                    })
1151                    .ok();
1152            })
1153            .ok();
1154        })
1155        .detach();
1156    }
1157
1158    fn restore_tracked_files(
1159        &mut self,
1160        _: &RestoreTrackedFiles,
1161        window: &mut Window,
1162        cx: &mut Context<Self>,
1163    ) {
1164        let entries = self
1165            .entries
1166            .iter()
1167            .filter_map(|entry| entry.status_entry().cloned())
1168            .filter(|status_entry| !status_entry.status.is_created())
1169            .collect::<Vec<_>>();
1170
1171        match entries.len() {
1172            0 => return,
1173            1 => return self.revert_entry(&entries[0], window, cx),
1174            _ => {}
1175        }
1176        let mut details = entries
1177            .iter()
1178            .filter_map(|entry| entry.repo_path.0.file_name())
1179            .map(|filename| filename.to_string_lossy())
1180            .take(5)
1181            .join("\n");
1182        if entries.len() > 5 {
1183            details.push_str(&format!("\nand {} more…", entries.len() - 5))
1184        }
1185
1186        #[derive(strum::EnumIter, strum::VariantNames)]
1187        #[strum(serialize_all = "title_case")]
1188        enum RestoreCancel {
1189            RestoreTrackedFiles,
1190            Cancel,
1191        }
1192        let prompt = prompt(
1193            "Discard changes to these files?",
1194            Some(&details),
1195            window,
1196            cx,
1197        );
1198        cx.spawn(async move |this, cx| {
1199            if let Ok(RestoreCancel::RestoreTrackedFiles) = prompt.await {
1200                this.update(cx, |this, cx| {
1201                    this.perform_checkout(entries, cx);
1202                })
1203                .ok();
1204            }
1205        })
1206        .detach();
1207    }
1208
1209    fn clean_all(&mut self, _: &TrashUntrackedFiles, window: &mut Window, cx: &mut Context<Self>) {
1210        let workspace = self.workspace.clone();
1211        let Some(active_repo) = self.active_repository.clone() else {
1212            return;
1213        };
1214        let to_delete = self
1215            .entries
1216            .iter()
1217            .filter_map(|entry| entry.status_entry())
1218            .filter(|status_entry| status_entry.status.is_created())
1219            .cloned()
1220            .collect::<Vec<_>>();
1221
1222        match to_delete.len() {
1223            0 => return,
1224            1 => return self.revert_entry(&to_delete[0], window, cx),
1225            _ => {}
1226        };
1227
1228        let mut details = to_delete
1229            .iter()
1230            .map(|entry| {
1231                entry
1232                    .repo_path
1233                    .0
1234                    .file_name()
1235                    .map(|f| f.to_string_lossy())
1236                    .unwrap_or_default()
1237            })
1238            .take(5)
1239            .join("\n");
1240
1241        if to_delete.len() > 5 {
1242            details.push_str(&format!("\nand {} more…", to_delete.len() - 5))
1243        }
1244
1245        let prompt = prompt("Trash these files?", Some(&details), window, cx);
1246        cx.spawn_in(window, async move |this, cx| {
1247            match prompt.await? {
1248                TrashCancel::Trash => {}
1249                TrashCancel::Cancel => return Ok(()),
1250            }
1251            let tasks = workspace.update(cx, |workspace, cx| {
1252                to_delete
1253                    .iter()
1254                    .filter_map(|entry| {
1255                        workspace.project().update(cx, |project, cx| {
1256                            let project_path = active_repo
1257                                .read(cx)
1258                                .repo_path_to_project_path(&entry.repo_path, cx)?;
1259                            project.delete_file(project_path, true, cx)
1260                        })
1261                    })
1262                    .collect::<Vec<_>>()
1263            })?;
1264            let to_unstage = to_delete
1265                .into_iter()
1266                .filter(|entry| !entry.status.staging().is_fully_unstaged())
1267                .collect();
1268            this.update(cx, |this, cx| this.change_file_stage(false, to_unstage, cx))?;
1269            for task in tasks {
1270                task.await?;
1271            }
1272            Ok(())
1273        })
1274        .detach_and_prompt_err("Failed to trash files", window, cx, |e, _, _| {
1275            Some(format!("{e}"))
1276        });
1277    }
1278
1279    pub fn stage_all(&mut self, _: &StageAll, _window: &mut Window, cx: &mut Context<Self>) {
1280        let entries = self
1281            .entries
1282            .iter()
1283            .filter_map(|entry| entry.status_entry())
1284            .filter(|status_entry| status_entry.staging.has_unstaged())
1285            .cloned()
1286            .collect::<Vec<_>>();
1287        self.change_file_stage(true, entries, cx);
1288    }
1289
1290    pub fn unstage_all(&mut self, _: &UnstageAll, _window: &mut Window, cx: &mut Context<Self>) {
1291        let entries = self
1292            .entries
1293            .iter()
1294            .filter_map(|entry| entry.status_entry())
1295            .filter(|status_entry| status_entry.staging.has_staged())
1296            .cloned()
1297            .collect::<Vec<_>>();
1298        self.change_file_stage(false, entries, cx);
1299    }
1300
1301    fn toggle_staged_for_entry(
1302        &mut self,
1303        entry: &GitListEntry,
1304        _window: &mut Window,
1305        cx: &mut Context<Self>,
1306    ) {
1307        let Some(active_repository) = self.active_repository.as_ref() else {
1308            return;
1309        };
1310        let (stage, repo_paths) = match entry {
1311            GitListEntry::Status(status_entry) => {
1312                if status_entry.status.staging().is_fully_staged() {
1313                    if let Some(op) = self.bulk_staging.clone()
1314                        && op.anchor == status_entry.repo_path
1315                    {
1316                        self.bulk_staging = None;
1317                    }
1318
1319                    (false, vec![status_entry.clone()])
1320                } else {
1321                    self.set_bulk_staging_anchor(status_entry.repo_path.clone(), cx);
1322
1323                    (true, vec![status_entry.clone()])
1324                }
1325            }
1326            GitListEntry::Header(section) => {
1327                let goal_staged_state = !self.header_state(section.header).selected();
1328                let repository = active_repository.read(cx);
1329                let entries = self
1330                    .entries
1331                    .iter()
1332                    .filter_map(|entry| entry.status_entry())
1333                    .filter(|status_entry| {
1334                        section.contains(status_entry, repository)
1335                            && status_entry.staging.as_bool() != Some(goal_staged_state)
1336                    })
1337                    .cloned()
1338                    .collect::<Vec<_>>();
1339
1340                (goal_staged_state, entries)
1341            }
1342        };
1343        self.change_file_stage(stage, repo_paths, cx);
1344    }
1345
1346    fn change_file_stage(
1347        &mut self,
1348        stage: bool,
1349        entries: Vec<GitStatusEntry>,
1350        cx: &mut Context<Self>,
1351    ) {
1352        let Some(active_repository) = self.active_repository.clone() else {
1353            return;
1354        };
1355        let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1;
1356        self.pending.push(PendingOperation {
1357            op_id,
1358            target_status: if stage {
1359                TargetStatus::Staged
1360            } else {
1361                TargetStatus::Unstaged
1362            },
1363            entries: entries.clone(),
1364            finished: false,
1365        });
1366        let repository = active_repository.read(cx);
1367        self.update_counts(repository);
1368        cx.notify();
1369
1370        cx.spawn({
1371            async move |this, cx| {
1372                let result = cx
1373                    .update(|cx| {
1374                        if stage {
1375                            active_repository.update(cx, |repo, cx| {
1376                                let repo_paths = entries
1377                                    .iter()
1378                                    .map(|entry| entry.repo_path.clone())
1379                                    .collect();
1380                                repo.stage_entries(repo_paths, cx)
1381                            })
1382                        } else {
1383                            active_repository.update(cx, |repo, cx| {
1384                                let repo_paths = entries
1385                                    .iter()
1386                                    .map(|entry| entry.repo_path.clone())
1387                                    .collect();
1388                                repo.unstage_entries(repo_paths, cx)
1389                            })
1390                        }
1391                    })?
1392                    .await;
1393
1394                this.update(cx, |this, cx| {
1395                    for pending in this.pending.iter_mut() {
1396                        if pending.op_id == op_id {
1397                            pending.finished = true
1398                        }
1399                    }
1400                    result
1401                        .map_err(|e| {
1402                            this.show_error_toast(if stage { "add" } else { "reset" }, e, cx);
1403                        })
1404                        .ok();
1405                    cx.notify();
1406                })
1407            }
1408        })
1409        .detach();
1410    }
1411
1412    pub fn total_staged_count(&self) -> usize {
1413        self.tracked_staged_count + self.new_staged_count + self.conflicted_staged_count
1414    }
1415
1416    pub fn stash_pop(&mut self, _: &StashPop, _window: &mut Window, cx: &mut Context<Self>) {
1417        let Some(active_repository) = self.active_repository.clone() else {
1418            return;
1419        };
1420
1421        cx.spawn({
1422            async move |this, cx| {
1423                let stash_task = active_repository
1424                    .update(cx, |repo, cx| repo.stash_pop(cx))?
1425                    .await;
1426                this.update(cx, |this, cx| {
1427                    stash_task
1428                        .map_err(|e| {
1429                            this.show_error_toast("stash pop", e, cx);
1430                        })
1431                        .ok();
1432                    cx.notify();
1433                })
1434            }
1435        })
1436        .detach();
1437    }
1438
1439    pub fn stash_all(&mut self, _: &StashAll, _window: &mut Window, cx: &mut Context<Self>) {
1440        let Some(active_repository) = self.active_repository.clone() else {
1441            return;
1442        };
1443
1444        cx.spawn({
1445            async move |this, cx| {
1446                let stash_task = active_repository
1447                    .update(cx, |repo, cx| repo.stash_all(cx))?
1448                    .await;
1449                this.update(cx, |this, cx| {
1450                    stash_task
1451                        .map_err(|e| {
1452                            this.show_error_toast("stash", e, cx);
1453                        })
1454                        .ok();
1455                    cx.notify();
1456                })
1457            }
1458        })
1459        .detach();
1460    }
1461
1462    pub fn commit_message_buffer(&self, cx: &App) -> Entity<Buffer> {
1463        self.commit_editor
1464            .read(cx)
1465            .buffer()
1466            .read(cx)
1467            .as_singleton()
1468            .unwrap()
1469    }
1470
1471    fn toggle_staged_for_selected(
1472        &mut self,
1473        _: &git::ToggleStaged,
1474        window: &mut Window,
1475        cx: &mut Context<Self>,
1476    ) {
1477        if let Some(selected_entry) = self.get_selected_entry().cloned() {
1478            self.toggle_staged_for_entry(&selected_entry, window, cx);
1479        }
1480    }
1481
1482    fn stage_range(&mut self, _: &git::StageRange, _window: &mut Window, cx: &mut Context<Self>) {
1483        let Some(index) = self.selected_entry else {
1484            return;
1485        };
1486        self.stage_bulk(index, cx);
1487    }
1488
1489    fn stage_selected(&mut self, _: &git::StageFile, _window: &mut Window, cx: &mut Context<Self>) {
1490        let Some(selected_entry) = self.get_selected_entry() else {
1491            return;
1492        };
1493        let Some(status_entry) = selected_entry.status_entry() else {
1494            return;
1495        };
1496        if status_entry.staging != StageStatus::Staged {
1497            self.change_file_stage(true, vec![status_entry.clone()], cx);
1498        }
1499    }
1500
1501    fn unstage_selected(
1502        &mut self,
1503        _: &git::UnstageFile,
1504        _window: &mut Window,
1505        cx: &mut Context<Self>,
1506    ) {
1507        let Some(selected_entry) = self.get_selected_entry() else {
1508            return;
1509        };
1510        let Some(status_entry) = selected_entry.status_entry() else {
1511            return;
1512        };
1513        if status_entry.staging != StageStatus::Unstaged {
1514            self.change_file_stage(false, vec![status_entry.clone()], cx);
1515        }
1516    }
1517
1518    fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
1519        if self.amend_pending {
1520            return;
1521        }
1522        if self
1523            .commit_editor
1524            .focus_handle(cx)
1525            .contains_focused(window, cx)
1526        {
1527            telemetry::event!("Git Committed", source = "Git Panel");
1528            self.commit_changes(
1529                CommitOptions {
1530                    amend: false,
1531                    signoff: self.signoff_enabled,
1532                },
1533                window,
1534                cx,
1535            )
1536        } else {
1537            cx.propagate();
1538        }
1539    }
1540
1541    fn amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context<Self>) {
1542        if self
1543            .commit_editor
1544            .focus_handle(cx)
1545            .contains_focused(window, cx)
1546        {
1547            if self.head_commit(cx).is_some() {
1548                if !self.amend_pending {
1549                    self.set_amend_pending(true, cx);
1550                    self.load_last_commit_message_if_empty(cx);
1551                } else {
1552                    telemetry::event!("Git Amended", source = "Git Panel");
1553                    self.set_amend_pending(false, cx);
1554                    self.commit_changes(
1555                        CommitOptions {
1556                            amend: true,
1557                            signoff: self.signoff_enabled,
1558                        },
1559                        window,
1560                        cx,
1561                    );
1562                }
1563            }
1564        } else {
1565            cx.propagate();
1566        }
1567    }
1568
1569    pub fn head_commit(&self, cx: &App) -> Option<CommitDetails> {
1570        self.active_repository
1571            .as_ref()
1572            .and_then(|repo| repo.read(cx).head_commit.as_ref())
1573            .cloned()
1574    }
1575
1576    pub fn load_last_commit_message_if_empty(&mut self, cx: &mut Context<Self>) {
1577        if !self.commit_editor.read(cx).is_empty(cx) {
1578            return;
1579        }
1580        let Some(head_commit) = self.head_commit(cx) else {
1581            return;
1582        };
1583        let recent_sha = head_commit.sha.to_string();
1584        let detail_task = self.load_commit_details(recent_sha, cx);
1585        cx.spawn(async move |this, cx| {
1586            if let Ok(message) = detail_task.await.map(|detail| detail.message) {
1587                this.update(cx, |this, cx| {
1588                    this.commit_message_buffer(cx).update(cx, |buffer, cx| {
1589                        let start = buffer.anchor_before(0);
1590                        let end = buffer.anchor_after(buffer.len());
1591                        buffer.edit([(start..end, message)], None, cx);
1592                    });
1593                })
1594                .log_err();
1595            }
1596        })
1597        .detach();
1598    }
1599
1600    fn custom_or_suggested_commit_message(
1601        &self,
1602        window: &mut Window,
1603        cx: &mut Context<Self>,
1604    ) -> Option<String> {
1605        let git_commit_language = self.commit_editor.read(cx).language_at(0, cx);
1606        let message = self.commit_editor.read(cx).text(cx);
1607        if message.is_empty() {
1608            return self
1609                .suggest_commit_message(cx)
1610                .filter(|message| !message.trim().is_empty());
1611        } else if message.trim().is_empty() {
1612            return None;
1613        }
1614        let buffer = cx.new(|cx| {
1615            let mut buffer = Buffer::local(message, cx);
1616            buffer.set_language(git_commit_language, cx);
1617            buffer
1618        });
1619        let editor = cx.new(|cx| Editor::for_buffer(buffer, None, window, cx));
1620        let wrapped_message = editor.update(cx, |editor, cx| {
1621            editor.select_all(&Default::default(), window, cx);
1622            editor.rewrap(&Default::default(), window, cx);
1623            editor.text(cx)
1624        });
1625        if wrapped_message.trim().is_empty() {
1626            return None;
1627        }
1628        Some(wrapped_message)
1629    }
1630
1631    fn has_commit_message(&self, cx: &mut Context<Self>) -> bool {
1632        let text = self.commit_editor.read(cx).text(cx);
1633        if !text.trim().is_empty() {
1634            true
1635        } else if text.is_empty() {
1636            self.suggest_commit_message(cx)
1637                .is_some_and(|text| !text.trim().is_empty())
1638        } else {
1639            false
1640        }
1641    }
1642
1643    pub(crate) fn commit_changes(
1644        &mut self,
1645        options: CommitOptions,
1646        window: &mut Window,
1647        cx: &mut Context<Self>,
1648    ) {
1649        let Some(active_repository) = self.active_repository.clone() else {
1650            return;
1651        };
1652        let error_spawn = |message, window: &mut Window, cx: &mut App| {
1653            let prompt = window.prompt(PromptLevel::Warning, message, None, &["Ok"], cx);
1654            cx.spawn(async move |_| {
1655                prompt.await.ok();
1656            })
1657            .detach();
1658        };
1659
1660        if self.has_unstaged_conflicts() {
1661            error_spawn(
1662                "There are still conflicts. You must stage these before committing",
1663                window,
1664                cx,
1665            );
1666            return;
1667        }
1668
1669        let commit_message = self.custom_or_suggested_commit_message(window, cx);
1670
1671        let Some(mut message) = commit_message else {
1672            self.commit_editor.read(cx).focus_handle(cx).focus(window);
1673            return;
1674        };
1675
1676        if self.add_coauthors {
1677            self.fill_co_authors(&mut message, cx);
1678        }
1679
1680        let task = if self.has_staged_changes() {
1681            // Repository serializes all git operations, so we can just send a commit immediately
1682            let commit_task = active_repository.update(cx, |repo, cx| {
1683                repo.commit(message.into(), None, options, cx)
1684            });
1685            cx.background_spawn(async move { commit_task.await? })
1686        } else {
1687            let changed_files = self
1688                .entries
1689                .iter()
1690                .filter_map(|entry| entry.status_entry())
1691                .filter(|status_entry| !status_entry.status.is_created())
1692                .map(|status_entry| status_entry.repo_path.clone())
1693                .collect::<Vec<_>>();
1694
1695            if changed_files.is_empty() {
1696                error_spawn("No changes to commit", window, cx);
1697                return;
1698            }
1699
1700            let stage_task =
1701                active_repository.update(cx, |repo, cx| repo.stage_entries(changed_files, cx));
1702            cx.spawn(async move |_, cx| {
1703                stage_task.await?;
1704                let commit_task = active_repository.update(cx, |repo, cx| {
1705                    repo.commit(message.into(), None, options, cx)
1706                })?;
1707                commit_task.await?
1708            })
1709        };
1710        let task = cx.spawn_in(window, async move |this, cx| {
1711            let result = task.await;
1712            this.update_in(cx, |this, window, cx| {
1713                this.pending_commit.take();
1714                match result {
1715                    Ok(()) => {
1716                        this.commit_editor
1717                            .update(cx, |editor, cx| editor.clear(window, cx));
1718                    }
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        self.amend_pending = value;
4376        self.serialize(cx);
4377        cx.notify();
4378    }
4379
4380    pub fn signoff_enabled(&self) -> bool {
4381        self.signoff_enabled
4382    }
4383
4384    pub fn set_signoff_enabled(&mut self, value: bool, cx: &mut Context<Self>) {
4385        self.signoff_enabled = value;
4386        self.serialize(cx);
4387        cx.notify();
4388    }
4389
4390    pub fn toggle_signoff_enabled(
4391        &mut self,
4392        _: &Signoff,
4393        _window: &mut Window,
4394        cx: &mut Context<Self>,
4395    ) {
4396        self.set_signoff_enabled(!self.signoff_enabled, cx);
4397    }
4398
4399    pub async fn load(
4400        workspace: WeakEntity<Workspace>,
4401        mut cx: AsyncWindowContext,
4402    ) -> anyhow::Result<Entity<Self>> {
4403        let serialized_panel = match workspace
4404            .read_with(&cx, |workspace, _| Self::serialization_key(workspace))
4405            .ok()
4406            .flatten()
4407        {
4408            Some(serialization_key) => cx
4409                .background_spawn(async move { KEY_VALUE_STORE.read_kvp(&serialization_key) })
4410                .await
4411                .context("loading git panel")
4412                .log_err()
4413                .flatten()
4414                .map(|panel| serde_json::from_str::<SerializedGitPanel>(&panel))
4415                .transpose()
4416                .log_err()
4417                .flatten(),
4418            None => None,
4419        };
4420
4421        workspace.update_in(&mut cx, |workspace, window, cx| {
4422            let panel = GitPanel::new(workspace, window, cx);
4423
4424            if let Some(serialized_panel) = serialized_panel {
4425                panel.update(cx, |panel, cx| {
4426                    panel.width = serialized_panel.width;
4427                    panel.amend_pending = serialized_panel.amend_pending;
4428                    panel.signoff_enabled = serialized_panel.signoff_enabled;
4429                    cx.notify();
4430                })
4431            }
4432
4433            panel
4434        })
4435    }
4436
4437    fn stage_bulk(&mut self, mut index: usize, cx: &mut Context<'_, Self>) {
4438        let Some(op) = self.bulk_staging.as_ref() else {
4439            return;
4440        };
4441        let Some(mut anchor_index) = self.entry_by_path(&op.anchor, cx) else {
4442            return;
4443        };
4444        if let Some(entry) = self.entries.get(index)
4445            && let Some(entry) = entry.status_entry()
4446        {
4447            self.set_bulk_staging_anchor(entry.repo_path.clone(), cx);
4448        }
4449        if index < anchor_index {
4450            std::mem::swap(&mut index, &mut anchor_index);
4451        }
4452        let entries = self
4453            .entries
4454            .get(anchor_index..=index)
4455            .unwrap_or_default()
4456            .iter()
4457            .filter_map(|entry| entry.status_entry().cloned())
4458            .collect::<Vec<_>>();
4459        self.change_file_stage(true, entries, cx);
4460    }
4461
4462    fn set_bulk_staging_anchor(&mut self, path: RepoPath, cx: &mut Context<'_, GitPanel>) {
4463        let Some(repo) = self.active_repository.as_ref() else {
4464            return;
4465        };
4466        self.bulk_staging = Some(BulkStaging {
4467            repo_id: repo.read(cx).id,
4468            anchor: path,
4469        });
4470    }
4471
4472    pub(crate) fn toggle_amend_pending(&mut self, cx: &mut Context<Self>) {
4473        self.set_amend_pending(!self.amend_pending, cx);
4474        if self.amend_pending {
4475            self.load_last_commit_message_if_empty(cx);
4476        }
4477    }
4478}
4479
4480impl Render for GitPanel {
4481    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4482        let project = self.project.read(cx);
4483        let has_entries = !self.entries.is_empty();
4484        let room = self
4485            .workspace
4486            .upgrade()
4487            .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
4488
4489        let has_write_access = self.has_write_access(cx);
4490
4491        let has_co_authors = room.is_some_and(|room| {
4492            self.load_local_committer(cx);
4493            let room = room.read(cx);
4494            room.remote_participants()
4495                .values()
4496                .any(|remote_participant| remote_participant.can_write())
4497        });
4498
4499        v_flex()
4500            .id("git_panel")
4501            .key_context(self.dispatch_context(window, cx))
4502            .track_focus(&self.focus_handle)
4503            .when(has_write_access && !project.is_read_only(cx), |this| {
4504                this.on_action(cx.listener(Self::toggle_staged_for_selected))
4505                    .on_action(cx.listener(Self::stage_range))
4506                    .on_action(cx.listener(GitPanel::commit))
4507                    .on_action(cx.listener(GitPanel::amend))
4508                    .on_action(cx.listener(GitPanel::toggle_signoff_enabled))
4509                    .on_action(cx.listener(Self::stage_all))
4510                    .on_action(cx.listener(Self::unstage_all))
4511                    .on_action(cx.listener(Self::stage_selected))
4512                    .on_action(cx.listener(Self::unstage_selected))
4513                    .on_action(cx.listener(Self::restore_tracked_files))
4514                    .on_action(cx.listener(Self::revert_selected))
4515                    .on_action(cx.listener(Self::clean_all))
4516                    .on_action(cx.listener(Self::generate_commit_message_action))
4517                    .on_action(cx.listener(Self::stash_all))
4518                    .on_action(cx.listener(Self::stash_pop))
4519            })
4520            .on_action(cx.listener(Self::select_first))
4521            .on_action(cx.listener(Self::select_next))
4522            .on_action(cx.listener(Self::select_previous))
4523            .on_action(cx.listener(Self::select_last))
4524            .on_action(cx.listener(Self::close_panel))
4525            .on_action(cx.listener(Self::open_diff))
4526            .on_action(cx.listener(Self::open_file))
4527            .on_action(cx.listener(Self::focus_changes_list))
4528            .on_action(cx.listener(Self::focus_editor))
4529            .on_action(cx.listener(Self::expand_commit_editor))
4530            .when(has_write_access && has_co_authors, |git_panel| {
4531                git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
4532            })
4533            .on_hover(cx.listener(move |this, hovered, window, cx| {
4534                if *hovered {
4535                    this.horizontal_scrollbar.show(cx);
4536                    this.vertical_scrollbar.show(cx);
4537                    cx.notify();
4538                } else if !this.focus_handle.contains_focused(window, cx) {
4539                    this.hide_scrollbars(window, cx);
4540                }
4541            }))
4542            .size_full()
4543            .overflow_hidden()
4544            .bg(cx.theme().colors().panel_background)
4545            .child(
4546                v_flex()
4547                    .size_full()
4548                    .children(self.render_panel_header(window, cx))
4549                    .map(|this| {
4550                        if has_entries {
4551                            this.child(self.render_entries(has_write_access, window, cx))
4552                        } else {
4553                            this.child(self.render_empty_state(cx).into_any_element())
4554                        }
4555                    })
4556                    .children(self.render_footer(window, cx))
4557                    .when(self.amend_pending, |this| {
4558                        this.child(self.render_pending_amend(cx))
4559                    })
4560                    .when(!self.amend_pending, |this| {
4561                        this.children(self.render_previous_commit(cx))
4562                    })
4563                    .into_any_element(),
4564            )
4565            .children(self.context_menu.as_ref().map(|(menu, position, _)| {
4566                deferred(
4567                    anchored()
4568                        .position(*position)
4569                        .anchor(Corner::TopLeft)
4570                        .child(menu.clone()),
4571                )
4572                .with_priority(1)
4573            }))
4574    }
4575}
4576
4577impl Focusable for GitPanel {
4578    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
4579        if self.entries.is_empty() {
4580            self.commit_editor.focus_handle(cx)
4581        } else {
4582            self.focus_handle.clone()
4583        }
4584    }
4585}
4586
4587impl EventEmitter<Event> for GitPanel {}
4588
4589impl EventEmitter<PanelEvent> for GitPanel {}
4590
4591pub(crate) struct GitPanelAddon {
4592    pub(crate) workspace: WeakEntity<Workspace>,
4593}
4594
4595impl editor::Addon for GitPanelAddon {
4596    fn to_any(&self) -> &dyn std::any::Any {
4597        self
4598    }
4599
4600    fn render_buffer_header_controls(
4601        &self,
4602        excerpt_info: &ExcerptInfo,
4603        window: &Window,
4604        cx: &App,
4605    ) -> Option<AnyElement> {
4606        let file = excerpt_info.buffer.file()?;
4607        let git_panel = self.workspace.upgrade()?.read(cx).panel::<GitPanel>(cx)?;
4608
4609        git_panel
4610            .read(cx)
4611            .render_buffer_header_controls(&git_panel, file, window, cx)
4612    }
4613}
4614
4615impl Panel for GitPanel {
4616    fn persistent_name() -> &'static str {
4617        "GitPanel"
4618    }
4619
4620    fn position(&self, _: &Window, cx: &App) -> DockPosition {
4621        GitPanelSettings::get_global(cx).dock
4622    }
4623
4624    fn position_is_valid(&self, position: DockPosition) -> bool {
4625        matches!(position, DockPosition::Left | DockPosition::Right)
4626    }
4627
4628    fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
4629        settings::update_settings_file::<GitPanelSettings>(
4630            self.fs.clone(),
4631            cx,
4632            move |settings, _| settings.dock = Some(position),
4633        );
4634    }
4635
4636    fn size(&self, _: &Window, cx: &App) -> Pixels {
4637        self.width
4638            .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
4639    }
4640
4641    fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
4642        self.width = size;
4643        self.serialize(cx);
4644        cx.notify();
4645    }
4646
4647    fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
4648        Some(ui::IconName::GitBranchAlt).filter(|_| GitPanelSettings::get_global(cx).button)
4649    }
4650
4651    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
4652        Some("Git Panel")
4653    }
4654
4655    fn toggle_action(&self) -> Box<dyn Action> {
4656        Box::new(ToggleFocus)
4657    }
4658
4659    fn activation_priority(&self) -> u32 {
4660        2
4661    }
4662}
4663
4664impl PanelHeader for GitPanel {}
4665
4666struct GitPanelMessageTooltip {
4667    commit_tooltip: Option<Entity<CommitTooltip>>,
4668}
4669
4670impl GitPanelMessageTooltip {
4671    fn new(
4672        git_panel: Entity<GitPanel>,
4673        sha: SharedString,
4674        repository: Entity<Repository>,
4675        window: &mut Window,
4676        cx: &mut App,
4677    ) -> Entity<Self> {
4678        cx.new(|cx| {
4679            cx.spawn_in(window, async move |this, cx| {
4680                let (details, workspace) = git_panel.update(cx, |git_panel, cx| {
4681                    (
4682                        git_panel.load_commit_details(sha.to_string(), cx),
4683                        git_panel.workspace.clone(),
4684                    )
4685                })?;
4686                let details = details.await?;
4687
4688                let commit_details = crate::commit_tooltip::CommitDetails {
4689                    sha: details.sha.clone(),
4690                    author_name: details.author_name.clone(),
4691                    author_email: details.author_email.clone(),
4692                    commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
4693                    message: Some(ParsedCommitMessage {
4694                        message: details.message,
4695                        ..Default::default()
4696                    }),
4697                };
4698
4699                this.update(cx, |this: &mut GitPanelMessageTooltip, cx| {
4700                    this.commit_tooltip = Some(cx.new(move |cx| {
4701                        CommitTooltip::new(commit_details, repository, workspace, cx)
4702                    }));
4703                    cx.notify();
4704                })
4705            })
4706            .detach();
4707
4708            Self {
4709                commit_tooltip: None,
4710            }
4711        })
4712    }
4713}
4714
4715impl Render for GitPanelMessageTooltip {
4716    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
4717        if let Some(commit_tooltip) = &self.commit_tooltip {
4718            commit_tooltip.clone().into_any_element()
4719        } else {
4720            gpui::Empty.into_any_element()
4721        }
4722    }
4723}
4724
4725#[derive(IntoElement, RegisterComponent)]
4726pub struct PanelRepoFooter {
4727    active_repository: SharedString,
4728    branch: Option<Branch>,
4729    head_commit: Option<CommitDetails>,
4730
4731    // Getting a GitPanel in previews will be difficult.
4732    //
4733    // For now just take an option here, and we won't bind handlers to buttons in previews.
4734    git_panel: Option<Entity<GitPanel>>,
4735}
4736
4737impl PanelRepoFooter {
4738    pub fn new(
4739        active_repository: SharedString,
4740        branch: Option<Branch>,
4741        head_commit: Option<CommitDetails>,
4742        git_panel: Option<Entity<GitPanel>>,
4743    ) -> Self {
4744        Self {
4745            active_repository,
4746            branch,
4747            head_commit,
4748            git_panel,
4749        }
4750    }
4751
4752    pub fn new_preview(active_repository: SharedString, branch: Option<Branch>) -> Self {
4753        Self {
4754            active_repository,
4755            branch,
4756            head_commit: None,
4757            git_panel: None,
4758        }
4759    }
4760}
4761
4762impl RenderOnce for PanelRepoFooter {
4763    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
4764        let project = self
4765            .git_panel
4766            .as_ref()
4767            .map(|panel| panel.read(cx).project.clone());
4768
4769        let repo = self
4770            .git_panel
4771            .as_ref()
4772            .and_then(|panel| panel.read(cx).active_repository.clone());
4773
4774        let single_repo = project
4775            .as_ref()
4776            .map(|project| project.read(cx).git_store().read(cx).repositories().len() == 1)
4777            .unwrap_or(true);
4778
4779        const MAX_BRANCH_LEN: usize = 16;
4780        const MAX_REPO_LEN: usize = 16;
4781        const LABEL_CHARACTER_BUDGET: usize = MAX_BRANCH_LEN + MAX_REPO_LEN;
4782        const MAX_SHORT_SHA_LEN: usize = 8;
4783
4784        let branch_name = self
4785            .branch
4786            .as_ref()
4787            .map(|branch| branch.name().to_owned())
4788            .or_else(|| {
4789                self.head_commit.as_ref().map(|commit| {
4790                    commit
4791                        .sha
4792                        .chars()
4793                        .take(MAX_SHORT_SHA_LEN)
4794                        .collect::<String>()
4795                })
4796            })
4797            .unwrap_or_else(|| " (no branch)".to_owned());
4798        let show_separator = self.branch.is_some() || self.head_commit.is_some();
4799
4800        let active_repo_name = self.active_repository.clone();
4801
4802        let branch_actual_len = branch_name.len();
4803        let repo_actual_len = active_repo_name.len();
4804
4805        // ideally, show the whole branch and repo names but
4806        // when we can't, use a budget to allocate space between the two
4807        let (repo_display_len, branch_display_len) =
4808            if branch_actual_len + repo_actual_len <= LABEL_CHARACTER_BUDGET {
4809                (repo_actual_len, branch_actual_len)
4810            } else if branch_actual_len <= MAX_BRANCH_LEN {
4811                let repo_space = (LABEL_CHARACTER_BUDGET - branch_actual_len).min(MAX_REPO_LEN);
4812                (repo_space, branch_actual_len)
4813            } else if repo_actual_len <= MAX_REPO_LEN {
4814                let branch_space = (LABEL_CHARACTER_BUDGET - repo_actual_len).min(MAX_BRANCH_LEN);
4815                (repo_actual_len, branch_space)
4816            } else {
4817                (MAX_REPO_LEN, MAX_BRANCH_LEN)
4818            };
4819
4820        let truncated_repo_name = if repo_actual_len <= repo_display_len {
4821            active_repo_name.to_string()
4822        } else {
4823            util::truncate_and_trailoff(active_repo_name.trim_ascii(), repo_display_len)
4824        };
4825
4826        let truncated_branch_name = if branch_actual_len <= branch_display_len {
4827            branch_name
4828        } else {
4829            util::truncate_and_trailoff(branch_name.trim_ascii(), branch_display_len)
4830        };
4831
4832        let repo_selector_trigger = Button::new("repo-selector", truncated_repo_name)
4833            .style(ButtonStyle::Transparent)
4834            .size(ButtonSize::None)
4835            .label_size(LabelSize::Small)
4836            .color(Color::Muted);
4837
4838        let repo_selector = PopoverMenu::new("repository-switcher")
4839            .menu({
4840                let project = project;
4841                move |window, cx| {
4842                    let project = project.clone()?;
4843                    Some(cx.new(|cx| RepositorySelector::new(project, rems(16.), window, cx)))
4844                }
4845            })
4846            .trigger_with_tooltip(
4847                repo_selector_trigger.disabled(single_repo).truncate(true),
4848                Tooltip::text("Switch Active Repository"),
4849            )
4850            .anchor(Corner::BottomLeft)
4851            .into_any_element();
4852
4853        let branch_selector_button = Button::new("branch-selector", truncated_branch_name)
4854            .style(ButtonStyle::Transparent)
4855            .size(ButtonSize::None)
4856            .label_size(LabelSize::Small)
4857            .truncate(true)
4858            .tooltip(Tooltip::for_action_title(
4859                "Switch Branch",
4860                &zed_actions::git::Switch,
4861            ))
4862            .on_click(|_, window, cx| {
4863                window.dispatch_action(zed_actions::git::Switch.boxed_clone(), cx);
4864            });
4865
4866        let branch_selector = PopoverMenu::new("popover-button")
4867            .menu(move |window, cx| Some(branch_picker::popover(repo.clone(), window, cx)))
4868            .trigger_with_tooltip(
4869                branch_selector_button,
4870                Tooltip::for_action_title("Switch Branch", &zed_actions::git::Switch),
4871            )
4872            .anchor(Corner::BottomLeft)
4873            .offset(gpui::Point {
4874                x: px(0.0),
4875                y: px(-2.0),
4876            });
4877
4878        h_flex()
4879            .w_full()
4880            .px_2()
4881            .h(px(36.))
4882            .items_center()
4883            .justify_between()
4884            .gap_1()
4885            .child(
4886                h_flex()
4887                    .flex_1()
4888                    .overflow_hidden()
4889                    .items_center()
4890                    .child(
4891                        div().child(
4892                            Icon::new(IconName::GitBranchAlt)
4893                                .size(IconSize::Small)
4894                                .color(if single_repo {
4895                                    Color::Disabled
4896                                } else {
4897                                    Color::Muted
4898                                }),
4899                        ),
4900                    )
4901                    .child(repo_selector)
4902                    .when(show_separator, |this| {
4903                        this.child(
4904                            div()
4905                                .text_color(cx.theme().colors().text_muted)
4906                                .text_sm()
4907                                .child("/"),
4908                        )
4909                    })
4910                    .child(branch_selector),
4911            )
4912            .children(if let Some(git_panel) = self.git_panel {
4913                git_panel.update(cx, |git_panel, cx| git_panel.render_remote_button(cx))
4914            } else {
4915                None
4916            })
4917    }
4918}
4919
4920impl Component for PanelRepoFooter {
4921    fn scope() -> ComponentScope {
4922        ComponentScope::VersionControl
4923    }
4924
4925    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
4926        let unknown_upstream = None;
4927        let no_remote_upstream = Some(UpstreamTracking::Gone);
4928        let ahead_of_upstream = Some(
4929            UpstreamTrackingStatus {
4930                ahead: 2,
4931                behind: 0,
4932            }
4933            .into(),
4934        );
4935        let behind_upstream = Some(
4936            UpstreamTrackingStatus {
4937                ahead: 0,
4938                behind: 2,
4939            }
4940            .into(),
4941        );
4942        let ahead_and_behind_upstream = Some(
4943            UpstreamTrackingStatus {
4944                ahead: 3,
4945                behind: 1,
4946            }
4947            .into(),
4948        );
4949
4950        let not_ahead_or_behind_upstream = Some(
4951            UpstreamTrackingStatus {
4952                ahead: 0,
4953                behind: 0,
4954            }
4955            .into(),
4956        );
4957
4958        fn branch(upstream: Option<UpstreamTracking>) -> Branch {
4959            Branch {
4960                is_head: true,
4961                ref_name: "some-branch".into(),
4962                upstream: upstream.map(|tracking| Upstream {
4963                    ref_name: "origin/some-branch".into(),
4964                    tracking,
4965                }),
4966                most_recent_commit: Some(CommitSummary {
4967                    sha: "abc123".into(),
4968                    subject: "Modify stuff".into(),
4969                    commit_timestamp: 1710932954,
4970                    has_parent: true,
4971                }),
4972            }
4973        }
4974
4975        fn custom(branch_name: &str, upstream: Option<UpstreamTracking>) -> Branch {
4976            Branch {
4977                is_head: true,
4978                ref_name: branch_name.to_string().into(),
4979                upstream: upstream.map(|tracking| Upstream {
4980                    ref_name: format!("zed/{}", branch_name).into(),
4981                    tracking,
4982                }),
4983                most_recent_commit: Some(CommitSummary {
4984                    sha: "abc123".into(),
4985                    subject: "Modify stuff".into(),
4986                    commit_timestamp: 1710932954,
4987                    has_parent: true,
4988                }),
4989            }
4990        }
4991
4992        fn active_repository(id: usize) -> SharedString {
4993            format!("repo-{}", id).into()
4994        }
4995
4996        let example_width = px(340.);
4997        Some(
4998            v_flex()
4999                .gap_6()
5000                .w_full()
5001                .flex_none()
5002                .children(vec![
5003                    example_group_with_title(
5004                        "Action Button States",
5005                        vec![
5006                            single_example(
5007                                "No Branch",
5008                                div()
5009                                    .w(example_width)
5010                                    .overflow_hidden()
5011                                    .child(PanelRepoFooter::new_preview(active_repository(1), None))
5012                                    .into_any_element(),
5013                            ),
5014                            single_example(
5015                                "Remote status unknown",
5016                                div()
5017                                    .w(example_width)
5018                                    .overflow_hidden()
5019                                    .child(PanelRepoFooter::new_preview(
5020                                        active_repository(2),
5021                                        Some(branch(unknown_upstream)),
5022                                    ))
5023                                    .into_any_element(),
5024                            ),
5025                            single_example(
5026                                "No Remote Upstream",
5027                                div()
5028                                    .w(example_width)
5029                                    .overflow_hidden()
5030                                    .child(PanelRepoFooter::new_preview(
5031                                        active_repository(3),
5032                                        Some(branch(no_remote_upstream)),
5033                                    ))
5034                                    .into_any_element(),
5035                            ),
5036                            single_example(
5037                                "Not Ahead or Behind",
5038                                div()
5039                                    .w(example_width)
5040                                    .overflow_hidden()
5041                                    .child(PanelRepoFooter::new_preview(
5042                                        active_repository(4),
5043                                        Some(branch(not_ahead_or_behind_upstream)),
5044                                    ))
5045                                    .into_any_element(),
5046                            ),
5047                            single_example(
5048                                "Behind remote",
5049                                div()
5050                                    .w(example_width)
5051                                    .overflow_hidden()
5052                                    .child(PanelRepoFooter::new_preview(
5053                                        active_repository(5),
5054                                        Some(branch(behind_upstream)),
5055                                    ))
5056                                    .into_any_element(),
5057                            ),
5058                            single_example(
5059                                "Ahead of remote",
5060                                div()
5061                                    .w(example_width)
5062                                    .overflow_hidden()
5063                                    .child(PanelRepoFooter::new_preview(
5064                                        active_repository(6),
5065                                        Some(branch(ahead_of_upstream)),
5066                                    ))
5067                                    .into_any_element(),
5068                            ),
5069                            single_example(
5070                                "Ahead and behind remote",
5071                                div()
5072                                    .w(example_width)
5073                                    .overflow_hidden()
5074                                    .child(PanelRepoFooter::new_preview(
5075                                        active_repository(7),
5076                                        Some(branch(ahead_and_behind_upstream)),
5077                                    ))
5078                                    .into_any_element(),
5079                            ),
5080                        ],
5081                    )
5082                    .grow()
5083                    .vertical(),
5084                ])
5085                .children(vec![
5086                    example_group_with_title(
5087                        "Labels",
5088                        vec![
5089                            single_example(
5090                                "Short Branch & Repo",
5091                                div()
5092                                    .w(example_width)
5093                                    .overflow_hidden()
5094                                    .child(PanelRepoFooter::new_preview(
5095                                        SharedString::from("zed"),
5096                                        Some(custom("main", behind_upstream)),
5097                                    ))
5098                                    .into_any_element(),
5099                            ),
5100                            single_example(
5101                                "Long Branch",
5102                                div()
5103                                    .w(example_width)
5104                                    .overflow_hidden()
5105                                    .child(PanelRepoFooter::new_preview(
5106                                        SharedString::from("zed"),
5107                                        Some(custom(
5108                                            "redesign-and-update-git-ui-list-entry-style",
5109                                            behind_upstream,
5110                                        )),
5111                                    ))
5112                                    .into_any_element(),
5113                            ),
5114                            single_example(
5115                                "Long Repo",
5116                                div()
5117                                    .w(example_width)
5118                                    .overflow_hidden()
5119                                    .child(PanelRepoFooter::new_preview(
5120                                        SharedString::from("zed-industries-community-examples"),
5121                                        Some(custom("gpui", ahead_of_upstream)),
5122                                    ))
5123                                    .into_any_element(),
5124                            ),
5125                            single_example(
5126                                "Long Repo & Branch",
5127                                div()
5128                                    .w(example_width)
5129                                    .overflow_hidden()
5130                                    .child(PanelRepoFooter::new_preview(
5131                                        SharedString::from("zed-industries-community-examples"),
5132                                        Some(custom(
5133                                            "redesign-and-update-git-ui-list-entry-style",
5134                                            behind_upstream,
5135                                        )),
5136                                    ))
5137                                    .into_any_element(),
5138                            ),
5139                            single_example(
5140                                "Uppercase Repo",
5141                                div()
5142                                    .w(example_width)
5143                                    .overflow_hidden()
5144                                    .child(PanelRepoFooter::new_preview(
5145                                        SharedString::from("LICENSES"),
5146                                        Some(custom("main", ahead_of_upstream)),
5147                                    ))
5148                                    .into_any_element(),
5149                            ),
5150                            single_example(
5151                                "Uppercase Branch",
5152                                div()
5153                                    .w(example_width)
5154                                    .overflow_hidden()
5155                                    .child(PanelRepoFooter::new_preview(
5156                                        SharedString::from("zed"),
5157                                        Some(custom("update-README", behind_upstream)),
5158                                    ))
5159                                    .into_any_element(),
5160                            ),
5161                        ],
5162                    )
5163                    .grow()
5164                    .vertical(),
5165                ])
5166                .into_any_element(),
5167        )
5168    }
5169}
5170
5171#[cfg(test)]
5172mod tests {
5173    use git::status::{StatusCode, UnmergedStatus, UnmergedStatusCode};
5174    use gpui::{TestAppContext, VisualTestContext};
5175    use project::{FakeFs, WorktreeSettings};
5176    use serde_json::json;
5177    use settings::SettingsStore;
5178    use theme::LoadThemes;
5179    use util::path;
5180
5181    use super::*;
5182
5183    fn init_test(cx: &mut gpui::TestAppContext) {
5184        zlog::init_test();
5185
5186        cx.update(|cx| {
5187            let settings_store = SettingsStore::test(cx);
5188            cx.set_global(settings_store);
5189            AgentSettings::register(cx);
5190            WorktreeSettings::register(cx);
5191            workspace::init_settings(cx);
5192            theme::init(LoadThemes::JustBase, cx);
5193            language::init(cx);
5194            editor::init(cx);
5195            Project::init_settings(cx);
5196            crate::init(cx);
5197        });
5198    }
5199
5200    #[gpui::test]
5201    async fn test_entry_worktree_paths(cx: &mut TestAppContext) {
5202        init_test(cx);
5203        let fs = FakeFs::new(cx.background_executor.clone());
5204        fs.insert_tree(
5205            "/root",
5206            json!({
5207                "zed": {
5208                    ".git": {},
5209                    "crates": {
5210                        "gpui": {
5211                            "gpui.rs": "fn main() {}"
5212                        },
5213                        "util": {
5214                            "util.rs": "fn do_it() {}"
5215                        }
5216                    }
5217                },
5218            }),
5219        )
5220        .await;
5221
5222        fs.set_status_for_repo(
5223            Path::new(path!("/root/zed/.git")),
5224            &[
5225                (
5226                    Path::new("crates/gpui/gpui.rs"),
5227                    StatusCode::Modified.worktree(),
5228                ),
5229                (
5230                    Path::new("crates/util/util.rs"),
5231                    StatusCode::Modified.worktree(),
5232                ),
5233            ],
5234        );
5235
5236        let project =
5237            Project::test(fs.clone(), [path!("/root/zed/crates/gpui").as_ref()], cx).await;
5238        let workspace =
5239            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5240        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5241
5242        cx.read(|cx| {
5243            project
5244                .read(cx)
5245                .worktrees(cx)
5246                .next()
5247                .unwrap()
5248                .read(cx)
5249                .as_local()
5250                .unwrap()
5251                .scan_complete()
5252        })
5253        .await;
5254
5255        cx.executor().run_until_parked();
5256
5257        let panel = workspace.update(cx, GitPanel::new).unwrap();
5258
5259        let handle = cx.update_window_entity(&panel, |panel, _, _| {
5260            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5261        });
5262        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5263        handle.await;
5264
5265        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5266        pretty_assertions::assert_eq!(
5267            entries,
5268            [
5269                GitListEntry::Header(GitHeaderEntry {
5270                    header: Section::Tracked
5271                }),
5272                GitListEntry::Status(GitStatusEntry {
5273                    abs_path: path!("/root/zed/crates/gpui/gpui.rs").into(),
5274                    repo_path: "crates/gpui/gpui.rs".into(),
5275                    status: StatusCode::Modified.worktree(),
5276                    staging: StageStatus::Unstaged,
5277                }),
5278                GitListEntry::Status(GitStatusEntry {
5279                    abs_path: path!("/root/zed/crates/util/util.rs").into(),
5280                    repo_path: "crates/util/util.rs".into(),
5281                    status: StatusCode::Modified.worktree(),
5282                    staging: StageStatus::Unstaged,
5283                },),
5284            ],
5285        );
5286
5287        let handle = cx.update_window_entity(&panel, |panel, _, _| {
5288            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5289        });
5290        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5291        handle.await;
5292        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5293        pretty_assertions::assert_eq!(
5294            entries,
5295            [
5296                GitListEntry::Header(GitHeaderEntry {
5297                    header: Section::Tracked
5298                }),
5299                GitListEntry::Status(GitStatusEntry {
5300                    abs_path: path!("/root/zed/crates/gpui/gpui.rs").into(),
5301                    repo_path: "crates/gpui/gpui.rs".into(),
5302                    status: StatusCode::Modified.worktree(),
5303                    staging: StageStatus::Unstaged,
5304                }),
5305                GitListEntry::Status(GitStatusEntry {
5306                    abs_path: path!("/root/zed/crates/util/util.rs").into(),
5307                    repo_path: "crates/util/util.rs".into(),
5308                    status: StatusCode::Modified.worktree(),
5309                    staging: StageStatus::Unstaged,
5310                },),
5311            ],
5312        );
5313    }
5314
5315    #[gpui::test]
5316    async fn test_bulk_staging(cx: &mut TestAppContext) {
5317        use GitListEntry::*;
5318
5319        init_test(cx);
5320        let fs = FakeFs::new(cx.background_executor.clone());
5321        fs.insert_tree(
5322            "/root",
5323            json!({
5324                "project": {
5325                    ".git": {},
5326                    "src": {
5327                        "main.rs": "fn main() {}",
5328                        "lib.rs": "pub fn hello() {}",
5329                        "utils.rs": "pub fn util() {}"
5330                    },
5331                    "tests": {
5332                        "test.rs": "fn test() {}"
5333                    },
5334                    "new_file.txt": "new content",
5335                    "another_new.rs": "// new file",
5336                    "conflict.txt": "conflicted content"
5337                }
5338            }),
5339        )
5340        .await;
5341
5342        fs.set_status_for_repo(
5343            Path::new(path!("/root/project/.git")),
5344            &[
5345                (Path::new("src/main.rs"), StatusCode::Modified.worktree()),
5346                (Path::new("src/lib.rs"), StatusCode::Modified.worktree()),
5347                (Path::new("tests/test.rs"), StatusCode::Modified.worktree()),
5348                (Path::new("new_file.txt"), FileStatus::Untracked),
5349                (Path::new("another_new.rs"), FileStatus::Untracked),
5350                (Path::new("src/utils.rs"), FileStatus::Untracked),
5351                (
5352                    Path::new("conflict.txt"),
5353                    UnmergedStatus {
5354                        first_head: UnmergedStatusCode::Updated,
5355                        second_head: UnmergedStatusCode::Updated,
5356                    }
5357                    .into(),
5358                ),
5359            ],
5360        );
5361
5362        let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
5363        let workspace =
5364            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5365        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5366
5367        cx.read(|cx| {
5368            project
5369                .read(cx)
5370                .worktrees(cx)
5371                .next()
5372                .unwrap()
5373                .read(cx)
5374                .as_local()
5375                .unwrap()
5376                .scan_complete()
5377        })
5378        .await;
5379
5380        cx.executor().run_until_parked();
5381
5382        let panel = workspace.update(cx, GitPanel::new).unwrap();
5383
5384        let handle = cx.update_window_entity(&panel, |panel, _, _| {
5385            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5386        });
5387        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5388        handle.await;
5389
5390        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5391        #[rustfmt::skip]
5392        pretty_assertions::assert_matches!(
5393            entries.as_slice(),
5394            &[
5395                Header(GitHeaderEntry { header: Section::Conflict }),
5396                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5397                Header(GitHeaderEntry { header: Section::Tracked }),
5398                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5399                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5400                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5401                Header(GitHeaderEntry { header: Section::New }),
5402                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5403                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5404                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5405            ],
5406        );
5407
5408        let second_status_entry = entries[3].clone();
5409        panel.update_in(cx, |panel, window, cx| {
5410            panel.toggle_staged_for_entry(&second_status_entry, window, cx);
5411        });
5412
5413        panel.update_in(cx, |panel, window, cx| {
5414            panel.selected_entry = Some(7);
5415            panel.stage_range(&git::StageRange, window, cx);
5416        });
5417
5418        cx.read(|cx| {
5419            project
5420                .read(cx)
5421                .worktrees(cx)
5422                .next()
5423                .unwrap()
5424                .read(cx)
5425                .as_local()
5426                .unwrap()
5427                .scan_complete()
5428        })
5429        .await;
5430
5431        cx.executor().run_until_parked();
5432
5433        let handle = cx.update_window_entity(&panel, |panel, _, _| {
5434            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5435        });
5436        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5437        handle.await;
5438
5439        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5440        #[rustfmt::skip]
5441        pretty_assertions::assert_matches!(
5442            entries.as_slice(),
5443            &[
5444                Header(GitHeaderEntry { header: Section::Conflict }),
5445                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5446                Header(GitHeaderEntry { header: Section::Tracked }),
5447                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5448                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5449                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5450                Header(GitHeaderEntry { header: Section::New }),
5451                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5452                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5453                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5454            ],
5455        );
5456
5457        let third_status_entry = entries[4].clone();
5458        panel.update_in(cx, |panel, window, cx| {
5459            panel.toggle_staged_for_entry(&third_status_entry, window, cx);
5460        });
5461
5462        panel.update_in(cx, |panel, window, cx| {
5463            panel.selected_entry = Some(9);
5464            panel.stage_range(&git::StageRange, window, cx);
5465        });
5466
5467        cx.read(|cx| {
5468            project
5469                .read(cx)
5470                .worktrees(cx)
5471                .next()
5472                .unwrap()
5473                .read(cx)
5474                .as_local()
5475                .unwrap()
5476                .scan_complete()
5477        })
5478        .await;
5479
5480        cx.executor().run_until_parked();
5481
5482        let handle = cx.update_window_entity(&panel, |panel, _, _| {
5483            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5484        });
5485        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5486        handle.await;
5487
5488        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5489        #[rustfmt::skip]
5490        pretty_assertions::assert_matches!(
5491            entries.as_slice(),
5492            &[
5493                Header(GitHeaderEntry { header: Section::Conflict }),
5494                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5495                Header(GitHeaderEntry { header: Section::Tracked }),
5496                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5497                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5498                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5499                Header(GitHeaderEntry { header: Section::New }),
5500                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5501                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5502                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5503            ],
5504        );
5505    }
5506}