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