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