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