git_panel.rs

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