git_panel.rs

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