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