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