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