git_panel.rs

   1use crate::git_panel_settings::StatusStyle;
   2use crate::project_diff::Diff;
   3use crate::repository_selector::RepositorySelectorPopoverMenu;
   4use crate::{
   5    git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
   6};
   7use crate::{picker_prompt, project_diff, ProjectDiff};
   8use collections::HashMap;
   9use db::kvp::KEY_VALUE_STORE;
  10use editor::commit_tooltip::CommitTooltip;
  11use editor::{
  12    scroll::ScrollbarAutoHide, Editor, EditorElement, EditorMode, EditorSettings, MultiBuffer,
  13    ShowScrollbar,
  14};
  15use git::repository::{Branch, CommitDetails, PushOptions, Remote, ResetMode, UpstreamTracking};
  16use git::{repository::RepoPath, status::FileStatus, Commit, ToggleStaged};
  17use git::{Push, RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll};
  18use gpui::*;
  19use itertools::Itertools;
  20use language::{Buffer, File};
  21use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
  22use multi_buffer::ExcerptInfo;
  23use panel::{panel_editor_container, panel_editor_style, panel_filled_button, PanelHeader};
  24use project::{
  25    git::{GitEvent, Repository},
  26    Fs, Project, ProjectPath,
  27};
  28use serde::{Deserialize, Serialize};
  29use settings::Settings as _;
  30use std::cell::RefCell;
  31use std::future::Future;
  32use std::rc::Rc;
  33use std::{collections::HashSet, path::PathBuf, sync::Arc, time::Duration, usize};
  34use strum::{IntoEnumIterator, VariantNames};
  35use time::OffsetDateTime;
  36use ui::{
  37    prelude::*, ButtonLike, Checkbox, ContextMenu, Divider, DividerColor, ElevationIndex, ListItem,
  38    ListItemSpacing, Scrollbar, ScrollbarState, Tooltip,
  39};
  40use util::{maybe, post_inc, ResultExt, TryFutureExt};
  41use workspace::{
  42    dock::{DockPosition, Panel, PanelEvent},
  43    notifications::{DetachAndPromptErr, NotificationId},
  44    Toast, Workspace,
  45};
  46
  47actions!(
  48    git_panel,
  49    [
  50        Close,
  51        ToggleFocus,
  52        OpenMenu,
  53        FocusEditor,
  54        FocusChanges,
  55        ToggleFillCoAuthors,
  56    ]
  57);
  58
  59fn prompt<T>(msg: &str, detail: Option<&str>, window: &mut Window, cx: &mut App) -> Task<Result<T>>
  60where
  61    T: IntoEnumIterator + VariantNames + 'static,
  62{
  63    let rx = window.prompt(PromptLevel::Info, msg, detail, &T::VARIANTS, cx);
  64    cx.spawn(|_| async move { Ok(T::iter().nth(rx.await?).unwrap()) })
  65}
  66
  67#[derive(strum::EnumIter, strum::VariantNames)]
  68#[strum(serialize_all = "title_case")]
  69enum TrashCancel {
  70    Trash,
  71    Cancel,
  72}
  73
  74const GIT_PANEL_KEY: &str = "GitPanel";
  75
  76const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
  77
  78pub fn init(cx: &mut App) {
  79    cx.observe_new(
  80        |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
  81            workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
  82                workspace.toggle_panel_focus::<GitPanel>(window, cx);
  83            });
  84
  85            // workspace.register_action(|workspace, _: &Commit, window, cx| {
  86            //     workspace.open_panel::<GitPanel>(window, cx);
  87            //     if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
  88            //         git_panel
  89            //             .read(cx)
  90            //             .commit_editor
  91            //             .focus_handle(cx)
  92            //             .focus(window);
  93            //     }
  94            // });
  95        },
  96    )
  97    .detach();
  98}
  99
 100#[derive(Debug, Clone)]
 101pub enum Event {
 102    Focus,
 103}
 104
 105#[derive(Serialize, Deserialize)]
 106struct SerializedGitPanel {
 107    width: Option<Pixels>,
 108}
 109
 110#[derive(Debug, PartialEq, Eq, Clone, Copy)]
 111enum Section {
 112    Conflict,
 113    Tracked,
 114    New,
 115}
 116
 117#[derive(Debug, PartialEq, Eq, Clone)]
 118struct GitHeaderEntry {
 119    header: Section,
 120}
 121
 122impl GitHeaderEntry {
 123    pub fn contains(&self, status_entry: &GitStatusEntry, repo: &Repository) -> bool {
 124        let this = &self.header;
 125        let status = status_entry.status;
 126        match this {
 127            Section::Conflict => repo.has_conflict(&status_entry.repo_path),
 128            Section::Tracked => !status.is_created(),
 129            Section::New => status.is_created(),
 130        }
 131    }
 132    pub fn title(&self) -> &'static str {
 133        match self.header {
 134            Section::Conflict => "Conflicts",
 135            Section::Tracked => "Tracked",
 136            Section::New => "Untracked",
 137        }
 138    }
 139}
 140
 141#[derive(Debug, PartialEq, Eq, Clone)]
 142enum GitListEntry {
 143    GitStatusEntry(GitStatusEntry),
 144    Header(GitHeaderEntry),
 145}
 146
 147impl GitListEntry {
 148    fn status_entry(&self) -> Option<&GitStatusEntry> {
 149        match self {
 150            GitListEntry::GitStatusEntry(entry) => Some(entry),
 151            _ => None,
 152        }
 153    }
 154}
 155
 156#[derive(Debug, PartialEq, Eq, Clone)]
 157pub struct GitStatusEntry {
 158    pub(crate) depth: usize,
 159    pub(crate) display_name: String,
 160    pub(crate) repo_path: RepoPath,
 161    pub(crate) status: FileStatus,
 162    pub(crate) is_staged: Option<bool>,
 163}
 164
 165#[derive(Clone, Copy, Debug, PartialEq, Eq)]
 166enum TargetStatus {
 167    Staged,
 168    Unstaged,
 169    Reverted,
 170    Unchanged,
 171}
 172
 173struct PendingOperation {
 174    finished: bool,
 175    target_status: TargetStatus,
 176    repo_paths: HashSet<RepoPath>,
 177    op_id: usize,
 178}
 179
 180type RemoteOperations = Rc<RefCell<HashSet<u32>>>;
 181
 182pub struct GitPanel {
 183    remote_operation_id: u32,
 184    pending_remote_operations: RemoteOperations,
 185    pub(crate) active_repository: Option<Entity<Repository>>,
 186    commit_editor: Entity<Editor>,
 187    conflicted_count: usize,
 188    conflicted_staged_count: usize,
 189    current_modifiers: Modifiers,
 190    add_coauthors: bool,
 191    entries: Vec<GitListEntry>,
 192    entries_by_path: collections::HashMap<RepoPath, usize>,
 193    focus_handle: FocusHandle,
 194    fs: Arc<dyn Fs>,
 195    hide_scrollbar_task: Option<Task<()>>,
 196    new_count: usize,
 197    new_staged_count: usize,
 198    pending: Vec<PendingOperation>,
 199    pending_commit: Option<Task<()>>,
 200    pending_serialization: Task<Option<()>>,
 201    pub(crate) project: Entity<Project>,
 202    repository_selector: Entity<RepositorySelector>,
 203    scroll_handle: UniformListScrollHandle,
 204    scrollbar_state: ScrollbarState,
 205    selected_entry: Option<usize>,
 206    show_scrollbar: bool,
 207    tracked_count: usize,
 208    tracked_staged_count: usize,
 209    update_visible_entries_task: Task<()>,
 210    width: Option<Pixels>,
 211    workspace: WeakEntity<Workspace>,
 212    context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
 213    modal_open: bool,
 214}
 215
 216struct RemoteOperationGuard {
 217    id: u32,
 218    pending_remote_operations: RemoteOperations,
 219}
 220
 221impl Drop for RemoteOperationGuard {
 222    fn drop(&mut self) {
 223        self.pending_remote_operations.borrow_mut().remove(&self.id);
 224    }
 225}
 226
 227pub(crate) fn commit_message_editor(
 228    commit_message_buffer: Entity<Buffer>,
 229    project: Entity<Project>,
 230    in_panel: bool,
 231    window: &mut Window,
 232    cx: &mut Context<'_, Editor>,
 233) -> Editor {
 234    let buffer = cx.new(|cx| MultiBuffer::singleton(commit_message_buffer, cx));
 235    let max_lines = if in_panel { 6 } else { 18 };
 236    let mut commit_editor = Editor::new(
 237        EditorMode::AutoHeight { max_lines },
 238        buffer,
 239        None,
 240        false,
 241        window,
 242        cx,
 243    );
 244    commit_editor.set_collaboration_hub(Box::new(project));
 245    commit_editor.set_use_autoclose(false);
 246    commit_editor.set_show_gutter(false, cx);
 247    commit_editor.set_show_wrap_guides(false, cx);
 248    commit_editor.set_show_indent_guides(false, cx);
 249    commit_editor.set_placeholder_text("Enter commit message", cx);
 250    commit_editor
 251}
 252
 253impl GitPanel {
 254    pub fn new(
 255        workspace: &mut Workspace,
 256        window: &mut Window,
 257        cx: &mut Context<Workspace>,
 258    ) -> Entity<Self> {
 259        let fs = workspace.app_state().fs.clone();
 260        let project = workspace.project().clone();
 261        let git_store = project.read(cx).git_store().clone();
 262        let active_repository = project.read(cx).active_repository(cx);
 263        let workspace = cx.entity().downgrade();
 264
 265        cx.new(|cx| {
 266            let focus_handle = cx.focus_handle();
 267            cx.on_focus(&focus_handle, window, Self::focus_in).detach();
 268            cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
 269                this.hide_scrollbar(window, cx);
 270            })
 271            .detach();
 272
 273            // just to let us render a placeholder editor.
 274            // Once the active git repo is set, this buffer will be replaced.
 275            let temporary_buffer = cx.new(|cx| Buffer::local("", cx));
 276            let commit_editor = cx.new(|cx| {
 277                commit_message_editor(temporary_buffer, project.clone(), true, window, cx)
 278            });
 279            commit_editor.update(cx, |editor, cx| {
 280                editor.clear(window, cx);
 281            });
 282
 283            let scroll_handle = UniformListScrollHandle::new();
 284
 285            cx.subscribe_in(
 286                &git_store,
 287                window,
 288                move |this, git_store, event, window, cx| match event {
 289                    GitEvent::FileSystemUpdated => {
 290                        this.schedule_update(false, window, cx);
 291                    }
 292                    GitEvent::ActiveRepositoryChanged | GitEvent::GitStateUpdated => {
 293                        this.active_repository = git_store.read(cx).active_repository();
 294                        this.schedule_update(true, window, cx);
 295                    }
 296                },
 297            )
 298            .detach();
 299
 300            let scrollbar_state =
 301                ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity());
 302
 303            let repository_selector =
 304                cx.new(|cx| RepositorySelector::new(project.clone(), window, cx));
 305
 306            let mut git_panel = Self {
 307                pending_remote_operations: Default::default(),
 308                remote_operation_id: 0,
 309                active_repository,
 310                commit_editor,
 311                conflicted_count: 0,
 312                conflicted_staged_count: 0,
 313                current_modifiers: window.modifiers(),
 314                add_coauthors: true,
 315                entries: Vec::new(),
 316                entries_by_path: HashMap::default(),
 317                focus_handle: cx.focus_handle(),
 318                fs,
 319                hide_scrollbar_task: None,
 320                new_count: 0,
 321                new_staged_count: 0,
 322                pending: Vec::new(),
 323                pending_commit: None,
 324                pending_serialization: Task::ready(None),
 325                project,
 326                repository_selector,
 327                scroll_handle,
 328                scrollbar_state,
 329                selected_entry: None,
 330                show_scrollbar: false,
 331                tracked_count: 0,
 332                tracked_staged_count: 0,
 333                update_visible_entries_task: Task::ready(()),
 334                width: Some(px(360.)),
 335                context_menu: None,
 336                workspace,
 337                modal_open: false,
 338            };
 339            git_panel.schedule_update(false, window, cx);
 340            git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx);
 341            git_panel
 342        })
 343    }
 344
 345    pub fn select_entry_by_path(
 346        &mut self,
 347        path: ProjectPath,
 348        _: &mut Window,
 349        cx: &mut Context<Self>,
 350    ) {
 351        let Some(git_repo) = self.active_repository.as_ref() else {
 352            return;
 353        };
 354        let Some(repo_path) = git_repo.read(cx).project_path_to_repo_path(&path) else {
 355            return;
 356        };
 357        let Some(ix) = self.entries_by_path.get(&repo_path) else {
 358            return;
 359        };
 360        self.selected_entry = Some(*ix);
 361        cx.notify();
 362    }
 363
 364    fn start_remote_operation(&mut self) -> RemoteOperationGuard {
 365        let id = post_inc(&mut self.remote_operation_id);
 366        self.pending_remote_operations.borrow_mut().insert(id);
 367
 368        RemoteOperationGuard {
 369            id,
 370            pending_remote_operations: self.pending_remote_operations.clone(),
 371        }
 372    }
 373
 374    fn serialize(&mut self, cx: &mut Context<Self>) {
 375        let width = self.width;
 376        self.pending_serialization = cx.background_spawn(
 377            async move {
 378                KEY_VALUE_STORE
 379                    .write_kvp(
 380                        GIT_PANEL_KEY.into(),
 381                        serde_json::to_string(&SerializedGitPanel { width })?,
 382                    )
 383                    .await?;
 384                anyhow::Ok(())
 385            }
 386            .log_err(),
 387        );
 388    }
 389
 390    pub(crate) fn set_modal_open(&mut self, open: bool, cx: &mut Context<Self>) {
 391        self.modal_open = open;
 392        cx.notify();
 393    }
 394
 395    fn dispatch_context(&self, window: &mut Window, cx: &Context<Self>) -> KeyContext {
 396        let mut dispatch_context = KeyContext::new_with_defaults();
 397        dispatch_context.add("GitPanel");
 398
 399        if self.is_focused(window, cx) {
 400            dispatch_context.add("menu");
 401            dispatch_context.add("ChangesList");
 402        }
 403
 404        if self.commit_editor.read(cx).is_focused(window) {
 405            dispatch_context.add("CommitEditor");
 406        }
 407
 408        dispatch_context
 409    }
 410
 411    fn is_focused(&self, window: &Window, cx: &Context<Self>) -> bool {
 412        window
 413            .focused(cx)
 414            .map_or(false, |focused| self.focus_handle == focused)
 415    }
 416
 417    fn close_panel(&mut self, _: &Close, _window: &mut Window, cx: &mut Context<Self>) {
 418        cx.emit(PanelEvent::Close);
 419    }
 420
 421    fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 422        if !self.focus_handle.contains_focused(window, cx) {
 423            cx.emit(Event::Focus);
 424        }
 425    }
 426
 427    fn show_scrollbar(&self, cx: &mut Context<Self>) -> ShowScrollbar {
 428        GitPanelSettings::get_global(cx)
 429            .scrollbar
 430            .show
 431            .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show)
 432    }
 433
 434    fn should_show_scrollbar(&self, cx: &mut Context<Self>) -> bool {
 435        let show = self.show_scrollbar(cx);
 436        match show {
 437            ShowScrollbar::Auto => true,
 438            ShowScrollbar::System => true,
 439            ShowScrollbar::Always => true,
 440            ShowScrollbar::Never => false,
 441        }
 442    }
 443
 444    fn should_autohide_scrollbar(&self, cx: &mut Context<Self>) -> bool {
 445        let show = self.show_scrollbar(cx);
 446        match show {
 447            ShowScrollbar::Auto => true,
 448            ShowScrollbar::System => cx
 449                .try_global::<ScrollbarAutoHide>()
 450                .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
 451            ShowScrollbar::Always => false,
 452            ShowScrollbar::Never => true,
 453        }
 454    }
 455
 456    fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 457        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
 458        if !self.should_autohide_scrollbar(cx) {
 459            return;
 460        }
 461        self.hide_scrollbar_task = Some(cx.spawn_in(window, |panel, mut cx| async move {
 462            cx.background_executor()
 463                .timer(SCROLLBAR_SHOW_INTERVAL)
 464                .await;
 465            panel
 466                .update(&mut cx, |panel, cx| {
 467                    panel.show_scrollbar = false;
 468                    cx.notify();
 469                })
 470                .log_err();
 471        }))
 472    }
 473
 474    fn handle_modifiers_changed(
 475        &mut self,
 476        event: &ModifiersChangedEvent,
 477        _: &mut Window,
 478        cx: &mut Context<Self>,
 479    ) {
 480        self.current_modifiers = event.modifiers;
 481        cx.notify();
 482    }
 483
 484    fn calculate_depth_and_difference(
 485        repo_path: &RepoPath,
 486        visible_entries: &HashSet<RepoPath>,
 487    ) -> (usize, usize) {
 488        let ancestors = repo_path.ancestors().skip(1);
 489        for ancestor in ancestors {
 490            if let Some(parent_entry) = visible_entries.get(ancestor) {
 491                let entry_component_count = repo_path.components().count();
 492                let parent_component_count = parent_entry.components().count();
 493
 494                let difference = entry_component_count - parent_component_count;
 495
 496                let parent_depth = parent_entry
 497                    .ancestors()
 498                    .skip(1) // Skip the parent itself
 499                    .filter(|ancestor| visible_entries.contains(*ancestor))
 500                    .count();
 501
 502                return (parent_depth + 1, difference);
 503            }
 504        }
 505
 506        (0, 0)
 507    }
 508
 509    fn scroll_to_selected_entry(&mut self, cx: &mut Context<Self>) {
 510        if let Some(selected_entry) = self.selected_entry {
 511            self.scroll_handle
 512                .scroll_to_item(selected_entry, ScrollStrategy::Center);
 513        }
 514
 515        cx.notify();
 516    }
 517
 518    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
 519        if self.entries.first().is_some() {
 520            self.selected_entry = Some(1);
 521            self.scroll_to_selected_entry(cx);
 522        }
 523    }
 524
 525    fn select_prev(&mut self, _: &SelectPrev, _window: &mut Window, cx: &mut Context<Self>) {
 526        let item_count = self.entries.len();
 527        if item_count == 0 {
 528            return;
 529        }
 530
 531        if let Some(selected_entry) = self.selected_entry {
 532            let new_selected_entry = if selected_entry > 0 {
 533                selected_entry - 1
 534            } else {
 535                selected_entry
 536            };
 537
 538            if matches!(
 539                self.entries.get(new_selected_entry),
 540                Some(GitListEntry::Header(..))
 541            ) {
 542                if new_selected_entry > 0 {
 543                    self.selected_entry = Some(new_selected_entry - 1)
 544                }
 545            } else {
 546                self.selected_entry = Some(new_selected_entry);
 547            }
 548
 549            self.scroll_to_selected_entry(cx);
 550        }
 551
 552        cx.notify();
 553    }
 554
 555    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
 556        let item_count = self.entries.len();
 557        if item_count == 0 {
 558            return;
 559        }
 560
 561        if let Some(selected_entry) = self.selected_entry {
 562            let new_selected_entry = if selected_entry < item_count - 1 {
 563                selected_entry + 1
 564            } else {
 565                selected_entry
 566            };
 567            if matches!(
 568                self.entries.get(new_selected_entry),
 569                Some(GitListEntry::Header(..))
 570            ) {
 571                self.selected_entry = Some(new_selected_entry + 1);
 572            } else {
 573                self.selected_entry = Some(new_selected_entry);
 574            }
 575
 576            self.scroll_to_selected_entry(cx);
 577        }
 578
 579        cx.notify();
 580    }
 581
 582    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
 583        if self.entries.last().is_some() {
 584            self.selected_entry = Some(self.entries.len() - 1);
 585            self.scroll_to_selected_entry(cx);
 586        }
 587    }
 588
 589    fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
 590        self.commit_editor.update(cx, |editor, cx| {
 591            window.focus(&editor.focus_handle(cx));
 592        });
 593        cx.notify();
 594    }
 595
 596    fn select_first_entry_if_none(&mut self, cx: &mut Context<Self>) {
 597        let have_entries = self
 598            .active_repository
 599            .as_ref()
 600            .map_or(false, |active_repository| {
 601                active_repository.read(cx).entry_count() > 0
 602            });
 603        if have_entries && self.selected_entry.is_none() {
 604            self.selected_entry = Some(1);
 605            self.scroll_to_selected_entry(cx);
 606            cx.notify();
 607        }
 608    }
 609
 610    fn focus_changes_list(
 611        &mut self,
 612        _: &FocusChanges,
 613        window: &mut Window,
 614        cx: &mut Context<Self>,
 615    ) {
 616        self.select_first_entry_if_none(cx);
 617
 618        cx.focus_self(window);
 619        cx.notify();
 620    }
 621
 622    fn get_selected_entry(&self) -> Option<&GitListEntry> {
 623        self.selected_entry.and_then(|i| self.entries.get(i))
 624    }
 625
 626    fn open_diff(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
 627        maybe!({
 628            let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
 629
 630            self.workspace
 631                .update(cx, |workspace, cx| {
 632                    ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
 633                })
 634                .ok()
 635        });
 636    }
 637
 638    fn open_file(
 639        &mut self,
 640        _: &menu::SecondaryConfirm,
 641        window: &mut Window,
 642        cx: &mut Context<Self>,
 643    ) {
 644        maybe!({
 645            let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
 646            let active_repo = self.active_repository.as_ref()?;
 647            let path = active_repo
 648                .read(cx)
 649                .repo_path_to_project_path(&entry.repo_path)?;
 650            if entry.status.is_deleted() {
 651                return None;
 652            }
 653
 654            self.workspace
 655                .update(cx, |workspace, cx| {
 656                    workspace
 657                        .open_path_preview(path, None, false, false, window, cx)
 658                        .detach_and_prompt_err("Failed to open file", window, cx, |e, _, _| {
 659                            Some(format!("{e}"))
 660                        });
 661                })
 662                .ok()
 663        });
 664    }
 665
 666    fn revert_selected(
 667        &mut self,
 668        _: &git::RestoreFile,
 669        window: &mut Window,
 670        cx: &mut Context<Self>,
 671    ) {
 672        maybe!({
 673            let list_entry = self.entries.get(self.selected_entry?)?.clone();
 674            let entry = list_entry.status_entry()?;
 675            self.revert_entry(&entry, window, cx);
 676            Some(())
 677        });
 678    }
 679
 680    fn revert_entry(
 681        &mut self,
 682        entry: &GitStatusEntry,
 683        window: &mut Window,
 684        cx: &mut Context<Self>,
 685    ) {
 686        maybe!({
 687            let active_repo = self.active_repository.clone()?;
 688            let path = active_repo
 689                .read(cx)
 690                .repo_path_to_project_path(&entry.repo_path)?;
 691            let workspace = self.workspace.clone();
 692
 693            if entry.status.is_staged() != Some(false) {
 694                self.perform_stage(false, vec![entry.repo_path.clone()], cx);
 695            }
 696            let filename = path.path.file_name()?.to_string_lossy();
 697
 698            if !entry.status.is_created() {
 699                self.perform_checkout(vec![entry.repo_path.clone()], cx);
 700            } else {
 701                let prompt = prompt(&format!("Trash {}?", filename), None, window, cx);
 702                cx.spawn_in(window, |_, mut cx| async move {
 703                    match prompt.await? {
 704                        TrashCancel::Trash => {}
 705                        TrashCancel::Cancel => return Ok(()),
 706                    }
 707                    let task = workspace.update(&mut cx, |workspace, cx| {
 708                        workspace
 709                            .project()
 710                            .update(cx, |project, cx| project.delete_file(path, true, cx))
 711                    })?;
 712                    if let Some(task) = task {
 713                        task.await?;
 714                    }
 715                    Ok(())
 716                })
 717                .detach_and_prompt_err(
 718                    "Failed to trash file",
 719                    window,
 720                    cx,
 721                    |e, _, _| Some(format!("{e}")),
 722                );
 723            }
 724            Some(())
 725        });
 726    }
 727
 728    fn perform_checkout(&mut self, repo_paths: Vec<RepoPath>, cx: &mut Context<Self>) {
 729        let workspace = self.workspace.clone();
 730        let Some(active_repository) = self.active_repository.clone() else {
 731            return;
 732        };
 733
 734        let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1;
 735        self.pending.push(PendingOperation {
 736            op_id,
 737            target_status: TargetStatus::Reverted,
 738            repo_paths: repo_paths.iter().cloned().collect(),
 739            finished: false,
 740        });
 741        self.update_visible_entries(cx);
 742        let task = cx.spawn(|_, mut cx| async move {
 743            let tasks: Vec<_> = workspace.update(&mut cx, |workspace, cx| {
 744                workspace.project().update(cx, |project, cx| {
 745                    repo_paths
 746                        .iter()
 747                        .filter_map(|repo_path| {
 748                            let path = active_repository
 749                                .read(cx)
 750                                .repo_path_to_project_path(&repo_path)?;
 751                            Some(project.open_buffer(path, cx))
 752                        })
 753                        .collect()
 754                })
 755            })?;
 756
 757            let buffers = futures::future::join_all(tasks).await;
 758
 759            active_repository
 760                .update(&mut cx, |repo, _| repo.checkout_files("HEAD", repo_paths))?
 761                .await??;
 762
 763            let tasks: Vec<_> = cx.update(|cx| {
 764                buffers
 765                    .iter()
 766                    .filter_map(|buffer| {
 767                        buffer.as_ref().ok()?.update(cx, |buffer, cx| {
 768                            buffer.is_dirty().then(|| buffer.reload(cx))
 769                        })
 770                    })
 771                    .collect()
 772            })?;
 773
 774            futures::future::join_all(tasks).await;
 775
 776            Ok(())
 777        });
 778
 779        cx.spawn(|this, mut cx| async move {
 780            let result = task.await;
 781
 782            this.update(&mut cx, |this, cx| {
 783                for pending in this.pending.iter_mut() {
 784                    if pending.op_id == op_id {
 785                        pending.finished = true;
 786                        if result.is_err() {
 787                            pending.target_status = TargetStatus::Unchanged;
 788                            this.update_visible_entries(cx);
 789                        }
 790                        break;
 791                    }
 792                }
 793                result
 794                    .map_err(|e| {
 795                        this.show_err_toast(e, cx);
 796                    })
 797                    .ok();
 798            })
 799            .ok();
 800        })
 801        .detach();
 802    }
 803
 804    fn discard_tracked_changes(
 805        &mut self,
 806        _: &RestoreTrackedFiles,
 807        window: &mut Window,
 808        cx: &mut Context<Self>,
 809    ) {
 810        let entries = self
 811            .entries
 812            .iter()
 813            .filter_map(|entry| entry.status_entry().cloned())
 814            .filter(|status_entry| !status_entry.status.is_created())
 815            .collect::<Vec<_>>();
 816
 817        match entries.len() {
 818            0 => return,
 819            1 => return self.revert_entry(&entries[0], window, cx),
 820            _ => {}
 821        }
 822        let details = entries
 823            .iter()
 824            .filter_map(|entry| entry.repo_path.0.file_name())
 825            .map(|filename| filename.to_string_lossy())
 826            .join("\n");
 827
 828        #[derive(strum::EnumIter, strum::VariantNames)]
 829        #[strum(serialize_all = "title_case")]
 830        enum DiscardCancel {
 831            DiscardTrackedChanges,
 832            Cancel,
 833        }
 834        let prompt = prompt(
 835            "Discard changes to these files?",
 836            Some(&details),
 837            window,
 838            cx,
 839        );
 840        cx.spawn(|this, mut cx| async move {
 841            match prompt.await {
 842                Ok(DiscardCancel::DiscardTrackedChanges) => {
 843                    this.update(&mut cx, |this, cx| {
 844                        let repo_paths = entries.into_iter().map(|entry| entry.repo_path).collect();
 845                        this.perform_checkout(repo_paths, cx);
 846                    })
 847                    .ok();
 848                }
 849                _ => {
 850                    return;
 851                }
 852            }
 853        })
 854        .detach();
 855    }
 856
 857    fn clean_all(&mut self, _: &TrashUntrackedFiles, window: &mut Window, cx: &mut Context<Self>) {
 858        let workspace = self.workspace.clone();
 859        let Some(active_repo) = self.active_repository.clone() else {
 860            return;
 861        };
 862        let to_delete = self
 863            .entries
 864            .iter()
 865            .filter_map(|entry| entry.status_entry())
 866            .filter(|status_entry| status_entry.status.is_created())
 867            .cloned()
 868            .collect::<Vec<_>>();
 869
 870        match to_delete.len() {
 871            0 => return,
 872            1 => return self.revert_entry(&to_delete[0], window, cx),
 873            _ => {}
 874        };
 875
 876        let details = to_delete
 877            .iter()
 878            .map(|entry| {
 879                entry
 880                    .repo_path
 881                    .0
 882                    .file_name()
 883                    .map(|f| f.to_string_lossy())
 884                    .unwrap_or_default()
 885            })
 886            .join("\n");
 887
 888        let prompt = prompt("Trash these files?", Some(&details), window, cx);
 889        cx.spawn_in(window, |this, mut cx| async move {
 890            match prompt.await? {
 891                TrashCancel::Trash => {}
 892                TrashCancel::Cancel => return Ok(()),
 893            }
 894            let tasks = workspace.update(&mut cx, |workspace, cx| {
 895                to_delete
 896                    .iter()
 897                    .filter_map(|entry| {
 898                        workspace.project().update(cx, |project, cx| {
 899                            let project_path = active_repo
 900                                .read(cx)
 901                                .repo_path_to_project_path(&entry.repo_path)?;
 902                            project.delete_file(project_path, true, cx)
 903                        })
 904                    })
 905                    .collect::<Vec<_>>()
 906            })?;
 907            let to_unstage = to_delete
 908                .into_iter()
 909                .filter_map(|entry| {
 910                    if entry.status.is_staged() != Some(false) {
 911                        Some(entry.repo_path.clone())
 912                    } else {
 913                        None
 914                    }
 915                })
 916                .collect();
 917            this.update(&mut cx, |this, cx| {
 918                this.perform_stage(false, to_unstage, cx)
 919            })?;
 920            for task in tasks {
 921                task.await?;
 922            }
 923            Ok(())
 924        })
 925        .detach_and_prompt_err("Failed to trash files", window, cx, |e, _, _| {
 926            Some(format!("{e}"))
 927        });
 928    }
 929
 930    fn stage_all(&mut self, _: &StageAll, _window: &mut Window, cx: &mut Context<Self>) {
 931        let repo_paths = self
 932            .entries
 933            .iter()
 934            .filter_map(|entry| entry.status_entry())
 935            .filter(|status_entry| status_entry.is_staged != Some(true))
 936            .map(|status_entry| status_entry.repo_path.clone())
 937            .collect::<Vec<_>>();
 938        self.perform_stage(true, repo_paths, cx);
 939    }
 940
 941    fn unstage_all(&mut self, _: &UnstageAll, _window: &mut Window, cx: &mut Context<Self>) {
 942        let repo_paths = self
 943            .entries
 944            .iter()
 945            .filter_map(|entry| entry.status_entry())
 946            .filter(|status_entry| status_entry.is_staged != Some(false))
 947            .map(|status_entry| status_entry.repo_path.clone())
 948            .collect::<Vec<_>>();
 949        self.perform_stage(false, repo_paths, cx);
 950    }
 951
 952    fn toggle_staged_for_entry(
 953        &mut self,
 954        entry: &GitListEntry,
 955        _window: &mut Window,
 956        cx: &mut Context<Self>,
 957    ) {
 958        let Some(active_repository) = self.active_repository.as_ref() else {
 959            return;
 960        };
 961        let (stage, repo_paths) = match entry {
 962            GitListEntry::GitStatusEntry(status_entry) => {
 963                if status_entry.status.is_staged().unwrap_or(false) {
 964                    (false, vec![status_entry.repo_path.clone()])
 965                } else {
 966                    (true, vec![status_entry.repo_path.clone()])
 967                }
 968            }
 969            GitListEntry::Header(section) => {
 970                let goal_staged_state = !self.header_state(section.header).selected();
 971                let repository = active_repository.read(cx);
 972                let entries = self
 973                    .entries
 974                    .iter()
 975                    .filter_map(|entry| entry.status_entry())
 976                    .filter(|status_entry| {
 977                        section.contains(&status_entry, repository)
 978                            && status_entry.is_staged != Some(goal_staged_state)
 979                    })
 980                    .map(|status_entry| status_entry.repo_path.clone())
 981                    .collect::<Vec<_>>();
 982
 983                (goal_staged_state, entries)
 984            }
 985        };
 986        self.perform_stage(stage, repo_paths, cx);
 987    }
 988
 989    fn perform_stage(&mut self, stage: bool, repo_paths: Vec<RepoPath>, cx: &mut Context<Self>) {
 990        let Some(active_repository) = self.active_repository.clone() else {
 991            return;
 992        };
 993        let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1;
 994        self.pending.push(PendingOperation {
 995            op_id,
 996            target_status: if stage {
 997                TargetStatus::Staged
 998            } else {
 999                TargetStatus::Unstaged
1000            },
1001            repo_paths: repo_paths.iter().cloned().collect(),
1002            finished: false,
1003        });
1004        let repo_paths = repo_paths.clone();
1005        let repository = active_repository.read(cx);
1006        self.update_counts(repository);
1007        cx.notify();
1008
1009        cx.spawn({
1010            |this, mut cx| async move {
1011                let result = cx
1012                    .update(|cx| {
1013                        if stage {
1014                            active_repository
1015                                .update(cx, |repo, cx| repo.stage_entries(repo_paths.clone(), cx))
1016                        } else {
1017                            active_repository
1018                                .update(cx, |repo, cx| repo.unstage_entries(repo_paths.clone(), cx))
1019                        }
1020                    })?
1021                    .await;
1022
1023                this.update(&mut cx, |this, cx| {
1024                    for pending in this.pending.iter_mut() {
1025                        if pending.op_id == op_id {
1026                            pending.finished = true
1027                        }
1028                    }
1029                    result
1030                        .map_err(|e| {
1031                            this.show_err_toast(e, cx);
1032                        })
1033                        .ok();
1034                    cx.notify();
1035                })
1036            }
1037        })
1038        .detach();
1039    }
1040
1041    pub fn commit_message_buffer(&self, cx: &App) -> Entity<Buffer> {
1042        self.commit_editor
1043            .read(cx)
1044            .buffer()
1045            .read(cx)
1046            .as_singleton()
1047            .unwrap()
1048            .clone()
1049    }
1050
1051    fn toggle_staged_for_selected(
1052        &mut self,
1053        _: &git::ToggleStaged,
1054        window: &mut Window,
1055        cx: &mut Context<Self>,
1056    ) {
1057        if let Some(selected_entry) = self.get_selected_entry().cloned() {
1058            self.toggle_staged_for_entry(&selected_entry, window, cx);
1059        }
1060    }
1061
1062    /// Commit all staged changes
1063    fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
1064        let editor = self.commit_editor.read(cx);
1065        if editor.is_empty(cx) {
1066            if !editor.focus_handle(cx).contains_focused(window, cx) {
1067                editor.focus_handle(cx).focus(window);
1068                return;
1069            }
1070        }
1071
1072        self.commit_changes(window, cx)
1073    }
1074
1075    pub(crate) fn commit_changes(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1076        let Some(active_repository) = self.active_repository.clone() else {
1077            return;
1078        };
1079        let error_spawn = |message, window: &mut Window, cx: &mut App| {
1080            let prompt = window.prompt(PromptLevel::Warning, message, None, &["Ok"], cx);
1081            cx.spawn(|_| async move {
1082                prompt.await.ok();
1083            })
1084            .detach();
1085        };
1086
1087        if self.has_unstaged_conflicts() {
1088            error_spawn(
1089                "There are still conflicts. You must stage these before committing",
1090                window,
1091                cx,
1092            );
1093            return;
1094        }
1095
1096        let mut message = self.commit_editor.read(cx).text(cx);
1097        if message.trim().is_empty() {
1098            self.commit_editor.read(cx).focus_handle(cx).focus(window);
1099            return;
1100        }
1101        if self.add_coauthors {
1102            self.fill_co_authors(&mut message, cx);
1103        }
1104
1105        let task = if self.has_staged_changes() {
1106            // Repository serializes all git operations, so we can just send a commit immediately
1107            let commit_task = active_repository.read(cx).commit(message.into(), None);
1108            cx.background_spawn(async move { commit_task.await? })
1109        } else {
1110            let changed_files = self
1111                .entries
1112                .iter()
1113                .filter_map(|entry| entry.status_entry())
1114                .filter(|status_entry| !status_entry.status.is_created())
1115                .map(|status_entry| status_entry.repo_path.clone())
1116                .collect::<Vec<_>>();
1117
1118            if changed_files.is_empty() {
1119                error_spawn("No changes to commit", window, cx);
1120                return;
1121            }
1122
1123            let stage_task =
1124                active_repository.update(cx, |repo, cx| repo.stage_entries(changed_files, cx));
1125            cx.spawn(|_, mut cx| async move {
1126                stage_task.await?;
1127                let commit_task = active_repository
1128                    .update(&mut cx, |repo, _| repo.commit(message.into(), None))?;
1129                commit_task.await?
1130            })
1131        };
1132        let task = cx.spawn_in(window, |this, mut cx| async move {
1133            let result = task.await;
1134            this.update_in(&mut cx, |this, window, cx| {
1135                this.pending_commit.take();
1136                match result {
1137                    Ok(()) => {
1138                        this.commit_editor
1139                            .update(cx, |editor, cx| editor.clear(window, cx));
1140                    }
1141                    Err(e) => this.show_err_toast(e, cx),
1142                }
1143            })
1144            .ok();
1145        });
1146
1147        self.pending_commit = Some(task);
1148    }
1149
1150    fn uncommit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1151        let Some(repo) = self.active_repository.clone() else {
1152            return;
1153        };
1154
1155        // TODO: Use git merge-base to find the upstream and main branch split
1156        let confirmation = Task::ready(true);
1157        // let confirmation = if self.commit_editor.read(cx).is_empty(cx) {
1158        //     Task::ready(true)
1159        // } else {
1160        //     let prompt = window.prompt(
1161        //         PromptLevel::Warning,
1162        //         "Uncomitting will replace the current commit message with the previous commit's message",
1163        //         None,
1164        //         &["Ok", "Cancel"],
1165        //         cx,
1166        //     );
1167        //     cx.spawn(|_, _| async move { prompt.await.is_ok_and(|i| i == 0) })
1168        // };
1169
1170        let prior_head = self.load_commit_details("HEAD", cx);
1171
1172        let task = cx.spawn_in(window, |this, mut cx| async move {
1173            let result = maybe!(async {
1174                if !confirmation.await {
1175                    Ok(None)
1176                } else {
1177                    let prior_head = prior_head.await?;
1178
1179                    repo.update(&mut cx, |repo, _| repo.reset("HEAD^", ResetMode::Soft))?
1180                        .await??;
1181
1182                    Ok(Some(prior_head))
1183                }
1184            })
1185            .await;
1186
1187            this.update_in(&mut cx, |this, window, cx| {
1188                this.pending_commit.take();
1189                match result {
1190                    Ok(None) => {}
1191                    Ok(Some(prior_commit)) => {
1192                        this.commit_editor.update(cx, |editor, cx| {
1193                            editor.set_text(prior_commit.message, window, cx)
1194                        });
1195                    }
1196                    Err(e) => this.show_err_toast(e, cx),
1197                }
1198            })
1199            .ok();
1200        });
1201
1202        self.pending_commit = Some(task);
1203    }
1204
1205    fn fetch(&mut self, _: &git::Fetch, _window: &mut Window, cx: &mut Context<Self>) {
1206        let Some(repo) = self.active_repository.clone() else {
1207            return;
1208        };
1209        let guard = self.start_remote_operation();
1210        let fetch = repo.read(cx).fetch();
1211        cx.spawn(|_, _| async move {
1212            fetch.await??;
1213            drop(guard);
1214            anyhow::Ok(())
1215        })
1216        .detach_and_log_err(cx);
1217    }
1218
1219    fn pull(&mut self, _: &git::Pull, window: &mut Window, cx: &mut Context<Self>) {
1220        let guard = self.start_remote_operation();
1221        let remote = self.get_current_remote(window, cx);
1222        cx.spawn(move |this, mut cx| async move {
1223            let remote = remote.await?;
1224
1225            this.update(&mut cx, |this, cx| {
1226                let Some(repo) = this.active_repository.clone() else {
1227                    return Err(anyhow::anyhow!("No active repository"));
1228                };
1229
1230                let Some(branch) = repo.read(cx).current_branch() else {
1231                    return Err(anyhow::anyhow!("No active branch"));
1232                };
1233
1234                Ok(repo.read(cx).pull(branch.name.clone(), remote.name))
1235            })??
1236            .await??;
1237
1238            drop(guard);
1239            anyhow::Ok(())
1240        })
1241        .detach_and_log_err(cx);
1242    }
1243
1244    fn push(&mut self, action: &git::Push, window: &mut Window, cx: &mut Context<Self>) {
1245        let guard = self.start_remote_operation();
1246        let options = action.options;
1247        let remote = self.get_current_remote(window, cx);
1248        cx.spawn(move |this, mut cx| async move {
1249            let remote = remote.await?;
1250
1251            this.update(&mut cx, |this, cx| {
1252                let Some(repo) = this.active_repository.clone() else {
1253                    return Err(anyhow::anyhow!("No active repository"));
1254                };
1255
1256                let Some(branch) = repo.read(cx).current_branch() else {
1257                    return Err(anyhow::anyhow!("No active branch"));
1258                };
1259
1260                Ok(repo
1261                    .read(cx)
1262                    .push(branch.name.clone(), remote.name, options))
1263            })??
1264            .await??;
1265
1266            drop(guard);
1267            anyhow::Ok(())
1268        })
1269        .detach_and_log_err(cx);
1270    }
1271
1272    fn get_current_remote(
1273        &mut self,
1274        window: &mut Window,
1275        cx: &mut Context<Self>,
1276    ) -> impl Future<Output = Result<Remote>> {
1277        let repo = self.active_repository.clone();
1278        let workspace = self.workspace.clone();
1279        let mut cx = window.to_async(cx);
1280
1281        async move {
1282            let Some(repo) = repo else {
1283                return Err(anyhow::anyhow!("No active repository"));
1284            };
1285
1286            let mut current_remotes: Vec<Remote> = repo
1287                .update(&mut cx, |repo, cx| {
1288                    let Some(current_branch) = repo.current_branch() else {
1289                        return Err(anyhow::anyhow!("No active branch"));
1290                    };
1291
1292                    Ok(repo.get_remotes(Some(current_branch.name.to_string()), cx))
1293                })??
1294                .await?;
1295
1296            if current_remotes.len() == 0 {
1297                return Err(anyhow::anyhow!("No active remote"));
1298            } else if current_remotes.len() == 1 {
1299                return Ok(current_remotes.pop().unwrap());
1300            } else {
1301                let current_remotes: Vec<_> = current_remotes
1302                    .into_iter()
1303                    .map(|remotes| remotes.name)
1304                    .collect();
1305                let selection = cx
1306                    .update(|window, cx| {
1307                        picker_prompt::prompt(
1308                            "Pick which remote to push to",
1309                            current_remotes.clone(),
1310                            workspace,
1311                            window,
1312                            cx,
1313                        )
1314                    })?
1315                    .await?;
1316
1317                return Ok(Remote {
1318                    name: current_remotes[selection].clone(),
1319                });
1320            }
1321        }
1322    }
1323
1324    fn potential_co_authors(&self, cx: &App) -> Vec<(String, String)> {
1325        let mut new_co_authors = Vec::new();
1326        let project = self.project.read(cx);
1327
1328        let Some(room) = self
1329            .workspace
1330            .upgrade()
1331            .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned())
1332        else {
1333            return Vec::default();
1334        };
1335
1336        let room = room.read(cx);
1337
1338        for (peer_id, collaborator) in project.collaborators() {
1339            if collaborator.is_host {
1340                continue;
1341            }
1342
1343            let Some(participant) = room.remote_participant_for_peer_id(*peer_id) else {
1344                continue;
1345            };
1346            if participant.can_write() && participant.user.email.is_some() {
1347                let email = participant.user.email.clone().unwrap();
1348
1349                new_co_authors.push((
1350                    participant
1351                        .user
1352                        .name
1353                        .clone()
1354                        .unwrap_or_else(|| participant.user.github_login.clone()),
1355                    email,
1356                ))
1357            }
1358        }
1359        if !project.is_local() && !project.is_read_only(cx) {
1360            if let Some(user) = room.local_participant_user(cx) {
1361                if let Some(email) = user.email.clone() {
1362                    new_co_authors.push((
1363                        user.name
1364                            .clone()
1365                            .unwrap_or_else(|| user.github_login.clone()),
1366                        email.clone(),
1367                    ))
1368                }
1369            }
1370        }
1371        new_co_authors
1372    }
1373
1374    fn toggle_fill_co_authors(
1375        &mut self,
1376        _: &ToggleFillCoAuthors,
1377        _: &mut Window,
1378        cx: &mut Context<Self>,
1379    ) {
1380        self.add_coauthors = !self.add_coauthors;
1381        cx.notify();
1382    }
1383
1384    fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context<Self>) {
1385        const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
1386
1387        let existing_text = message.to_ascii_lowercase();
1388        let lowercase_co_author_prefix = CO_AUTHOR_PREFIX.to_lowercase();
1389        let mut ends_with_co_authors = false;
1390        let existing_co_authors = existing_text
1391            .lines()
1392            .filter_map(|line| {
1393                let line = line.trim();
1394                if line.starts_with(&lowercase_co_author_prefix) {
1395                    ends_with_co_authors = true;
1396                    Some(line)
1397                } else {
1398                    ends_with_co_authors = false;
1399                    None
1400                }
1401            })
1402            .collect::<HashSet<_>>();
1403
1404        let new_co_authors = self
1405            .potential_co_authors(cx)
1406            .into_iter()
1407            .filter(|(_, email)| {
1408                !existing_co_authors
1409                    .iter()
1410                    .any(|existing| existing.contains(email.as_str()))
1411            })
1412            .collect::<Vec<_>>();
1413
1414        if new_co_authors.is_empty() {
1415            return;
1416        }
1417
1418        if !ends_with_co_authors {
1419            message.push('\n');
1420        }
1421        for (name, email) in new_co_authors {
1422            message.push('\n');
1423            message.push_str(CO_AUTHOR_PREFIX);
1424            message.push_str(&name);
1425            message.push_str(" <");
1426            message.push_str(&email);
1427            message.push('>');
1428        }
1429        message.push('\n');
1430    }
1431
1432    fn schedule_update(
1433        &mut self,
1434        clear_pending: bool,
1435        window: &mut Window,
1436        cx: &mut Context<Self>,
1437    ) {
1438        let handle = cx.entity().downgrade();
1439        self.reopen_commit_buffer(window, cx);
1440        self.update_visible_entries_task = cx.spawn_in(window, |_, mut cx| async move {
1441            cx.background_executor().timer(UPDATE_DEBOUNCE).await;
1442            if let Some(git_panel) = handle.upgrade() {
1443                git_panel
1444                    .update_in(&mut cx, |git_panel, _, cx| {
1445                        if clear_pending {
1446                            git_panel.clear_pending();
1447                        }
1448                        git_panel.update_visible_entries(cx);
1449                    })
1450                    .ok();
1451            }
1452        });
1453    }
1454
1455    fn reopen_commit_buffer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1456        let Some(active_repo) = self.active_repository.as_ref() else {
1457            return;
1458        };
1459        let load_buffer = active_repo.update(cx, |active_repo, cx| {
1460            let project = self.project.read(cx);
1461            active_repo.open_commit_buffer(
1462                Some(project.languages().clone()),
1463                project.buffer_store().clone(),
1464                cx,
1465            )
1466        });
1467
1468        cx.spawn_in(window, |git_panel, mut cx| async move {
1469            let buffer = load_buffer.await?;
1470            git_panel.update_in(&mut cx, |git_panel, window, cx| {
1471                if git_panel
1472                    .commit_editor
1473                    .read(cx)
1474                    .buffer()
1475                    .read(cx)
1476                    .as_singleton()
1477                    .as_ref()
1478                    != Some(&buffer)
1479                {
1480                    git_panel.commit_editor = cx.new(|cx| {
1481                        commit_message_editor(buffer, git_panel.project.clone(), true, window, cx)
1482                    });
1483                }
1484            })
1485        })
1486        .detach_and_log_err(cx);
1487    }
1488
1489    fn clear_pending(&mut self) {
1490        self.pending.retain(|v| !v.finished)
1491    }
1492
1493    fn update_visible_entries(&mut self, cx: &mut Context<Self>) {
1494        self.entries.clear();
1495        self.entries_by_path.clear();
1496        let mut changed_entries = Vec::new();
1497        let mut new_entries = Vec::new();
1498        let mut conflict_entries = Vec::new();
1499
1500        let Some(repo) = self.active_repository.as_ref() else {
1501            // Just clear entries if no repository is active.
1502            cx.notify();
1503            return;
1504        };
1505
1506        // First pass - collect all paths
1507        let repo = repo.read(cx);
1508        let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path));
1509
1510        // Second pass - create entries with proper depth calculation
1511        for entry in repo.status() {
1512            let (depth, difference) =
1513                Self::calculate_depth_and_difference(&entry.repo_path, &path_set);
1514
1515            let is_conflict = repo.has_conflict(&entry.repo_path);
1516            let is_new = entry.status.is_created();
1517            let is_staged = entry.status.is_staged();
1518
1519            if self.pending.iter().any(|pending| {
1520                pending.target_status == TargetStatus::Reverted
1521                    && !pending.finished
1522                    && pending.repo_paths.contains(&entry.repo_path)
1523            }) {
1524                continue;
1525            }
1526
1527            let display_name = if difference > 1 {
1528                // Show partial path for deeply nested files
1529                entry
1530                    .repo_path
1531                    .as_ref()
1532                    .iter()
1533                    .skip(entry.repo_path.components().count() - difference)
1534                    .collect::<PathBuf>()
1535                    .to_string_lossy()
1536                    .into_owned()
1537            } else {
1538                // Just show filename
1539                entry
1540                    .repo_path
1541                    .file_name()
1542                    .map(|name| name.to_string_lossy().into_owned())
1543                    .unwrap_or_default()
1544            };
1545
1546            let entry = GitStatusEntry {
1547                depth,
1548                display_name,
1549                repo_path: entry.repo_path.clone(),
1550                status: entry.status,
1551                is_staged,
1552            };
1553
1554            if is_conflict {
1555                conflict_entries.push(entry);
1556            } else if is_new {
1557                new_entries.push(entry);
1558            } else {
1559                changed_entries.push(entry);
1560            }
1561        }
1562
1563        // Sort entries by path to maintain consistent order
1564        conflict_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
1565        changed_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
1566        new_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
1567
1568        if conflict_entries.len() > 0 {
1569            self.entries.push(GitListEntry::Header(GitHeaderEntry {
1570                header: Section::Conflict,
1571            }));
1572            self.entries.extend(
1573                conflict_entries
1574                    .into_iter()
1575                    .map(GitListEntry::GitStatusEntry),
1576            );
1577        }
1578
1579        if changed_entries.len() > 0 {
1580            self.entries.push(GitListEntry::Header(GitHeaderEntry {
1581                header: Section::Tracked,
1582            }));
1583            self.entries.extend(
1584                changed_entries
1585                    .into_iter()
1586                    .map(GitListEntry::GitStatusEntry),
1587            );
1588        }
1589        if new_entries.len() > 0 {
1590            self.entries.push(GitListEntry::Header(GitHeaderEntry {
1591                header: Section::New,
1592            }));
1593            self.entries
1594                .extend(new_entries.into_iter().map(GitListEntry::GitStatusEntry));
1595        }
1596
1597        for (ix, entry) in self.entries.iter().enumerate() {
1598            if let Some(status_entry) = entry.status_entry() {
1599                self.entries_by_path
1600                    .insert(status_entry.repo_path.clone(), ix);
1601            }
1602        }
1603        self.update_counts(repo);
1604
1605        self.select_first_entry_if_none(cx);
1606
1607        cx.notify();
1608    }
1609
1610    fn header_state(&self, header_type: Section) -> ToggleState {
1611        let (staged_count, count) = match header_type {
1612            Section::New => (self.new_staged_count, self.new_count),
1613            Section::Tracked => (self.tracked_staged_count, self.tracked_count),
1614            Section::Conflict => (self.conflicted_staged_count, self.conflicted_count),
1615        };
1616        if staged_count == 0 {
1617            ToggleState::Unselected
1618        } else if count == staged_count {
1619            ToggleState::Selected
1620        } else {
1621            ToggleState::Indeterminate
1622        }
1623    }
1624
1625    fn update_counts(&mut self, repo: &Repository) {
1626        self.conflicted_count = 0;
1627        self.conflicted_staged_count = 0;
1628        self.new_count = 0;
1629        self.tracked_count = 0;
1630        self.new_staged_count = 0;
1631        self.tracked_staged_count = 0;
1632        for entry in &self.entries {
1633            let Some(status_entry) = entry.status_entry() else {
1634                continue;
1635            };
1636            if repo.has_conflict(&status_entry.repo_path) {
1637                self.conflicted_count += 1;
1638                if self.entry_is_staged(status_entry) != Some(false) {
1639                    self.conflicted_staged_count += 1;
1640                }
1641            } else if status_entry.status.is_created() {
1642                self.new_count += 1;
1643                if self.entry_is_staged(status_entry) != Some(false) {
1644                    self.new_staged_count += 1;
1645                }
1646            } else {
1647                self.tracked_count += 1;
1648                if self.entry_is_staged(status_entry) != Some(false) {
1649                    self.tracked_staged_count += 1;
1650                }
1651            }
1652        }
1653    }
1654
1655    fn entry_is_staged(&self, entry: &GitStatusEntry) -> Option<bool> {
1656        for pending in self.pending.iter().rev() {
1657            if pending.repo_paths.contains(&entry.repo_path) {
1658                match pending.target_status {
1659                    TargetStatus::Staged => return Some(true),
1660                    TargetStatus::Unstaged => return Some(false),
1661                    TargetStatus::Reverted => continue,
1662                    TargetStatus::Unchanged => continue,
1663                }
1664            }
1665        }
1666        entry.is_staged
1667    }
1668
1669    pub(crate) fn has_staged_changes(&self) -> bool {
1670        self.tracked_staged_count > 0
1671            || self.new_staged_count > 0
1672            || self.conflicted_staged_count > 0
1673    }
1674
1675    pub(crate) fn has_unstaged_changes(&self) -> bool {
1676        self.tracked_count > self.tracked_staged_count
1677            || self.new_count > self.new_staged_count
1678            || self.conflicted_count > self.conflicted_staged_count
1679    }
1680
1681    fn has_conflicts(&self) -> bool {
1682        self.conflicted_count > 0
1683    }
1684
1685    fn has_tracked_changes(&self) -> bool {
1686        self.tracked_count > 0
1687    }
1688
1689    pub fn has_unstaged_conflicts(&self) -> bool {
1690        self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count
1691    }
1692
1693    fn show_err_toast(&self, e: anyhow::Error, cx: &mut App) {
1694        let Some(workspace) = self.workspace.upgrade() else {
1695            return;
1696        };
1697        let notif_id = NotificationId::Named("git-operation-error".into());
1698
1699        let message = e.to_string();
1700        workspace.update(cx, |workspace, cx| {
1701            let toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| {
1702                window.dispatch_action(workspace::OpenLog.boxed_clone(), cx);
1703            });
1704            workspace.show_toast(toast, cx);
1705        });
1706    }
1707
1708    pub fn panel_button(
1709        &self,
1710        id: impl Into<SharedString>,
1711        label: impl Into<SharedString>,
1712    ) -> Button {
1713        let id = id.into().clone();
1714        let label = label.into().clone();
1715
1716        Button::new(id, label)
1717            .label_size(LabelSize::Small)
1718            .layer(ElevationIndex::ElevatedSurface)
1719            .size(ButtonSize::Compact)
1720            .style(ButtonStyle::Filled)
1721    }
1722
1723    pub fn indent_size(&self, window: &Window, cx: &mut Context<Self>) -> Pixels {
1724        Checkbox::container_size(cx).to_pixels(window.rem_size())
1725    }
1726
1727    pub fn render_divider(&self, _cx: &mut Context<Self>) -> impl IntoElement {
1728        h_flex()
1729            .items_center()
1730            .h(px(8.))
1731            .child(Divider::horizontal_dashed().color(DividerColor::Border))
1732    }
1733
1734    pub fn render_panel_header(
1735        &self,
1736        window: &mut Window,
1737        cx: &mut Context<Self>,
1738    ) -> Option<impl IntoElement> {
1739        let all_repositories = self
1740            .project
1741            .read(cx)
1742            .git_store()
1743            .read(cx)
1744            .all_repositories();
1745
1746        let has_repo_above = all_repositories.iter().any(|repo| {
1747            repo.read(cx)
1748                .repository_entry
1749                .work_directory
1750                .is_above_project()
1751        });
1752
1753        let has_visible_repo = all_repositories.len() > 0 || has_repo_above;
1754
1755        if has_visible_repo {
1756            Some(
1757                self.panel_header_container(window, cx)
1758                    .child(
1759                        Label::new("Repository")
1760                            .size(LabelSize::Small)
1761                            .color(Color::Muted),
1762                    )
1763                    .child(self.render_repository_selector(cx))
1764                    .child(div().flex_grow()) // spacer
1765                    .child(
1766                        div()
1767                            .h_flex()
1768                            .gap_1()
1769                            .children(self.render_spinner(cx))
1770                            .children(self.render_sync_button(cx))
1771                            .children(self.render_pull_button(cx))
1772                            .child(
1773                                Button::new("diff", "+/-")
1774                                    .tooltip(Tooltip::for_action_title("Open diff", &Diff))
1775                                    .on_click(|_, _, cx| {
1776                                        cx.defer(|cx| {
1777                                            cx.dispatch_action(&Diff);
1778                                        })
1779                                    }),
1780                            ),
1781                    ),
1782            )
1783        } else {
1784            None
1785        }
1786    }
1787
1788    pub fn render_spinner(&self, _cx: &mut Context<Self>) -> Option<impl IntoElement> {
1789        (!self.pending_remote_operations.borrow().is_empty()).then(|| {
1790            Icon::new(IconName::ArrowCircle)
1791                .size(IconSize::XSmall)
1792                .color(Color::Info)
1793                .with_animation(
1794                    "arrow-circle",
1795                    Animation::new(Duration::from_secs(2)).repeat(),
1796                    |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
1797                )
1798                .into_any_element()
1799        })
1800    }
1801
1802    pub fn render_sync_button(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
1803        let active_repository = self.project.read(cx).active_repository(cx);
1804        active_repository.as_ref().map(|_| {
1805            panel_filled_button("Fetch")
1806                .icon(IconName::ArrowCircle)
1807                .icon_size(IconSize::Small)
1808                .icon_color(Color::Muted)
1809                .icon_position(IconPosition::Start)
1810                .tooltip(Tooltip::for_action_title("git fetch", &git::Fetch))
1811                .on_click(
1812                    cx.listener(move |this, _, window, cx| this.fetch(&git::Fetch, window, cx)),
1813                )
1814                .into_any_element()
1815        })
1816    }
1817
1818    pub fn render_pull_button(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
1819        let active_repository = self.project.read(cx).active_repository(cx);
1820        active_repository
1821            .as_ref()
1822            .and_then(|repo| repo.read(cx).current_branch())
1823            .and_then(|branch| {
1824                branch.upstream.as_ref().map(|upstream| {
1825                    let status = &upstream.tracking;
1826
1827                    let disabled = status.is_gone();
1828
1829                    panel_filled_button(match status {
1830                        git::repository::UpstreamTracking::Tracked(status) if status.behind > 0 => {
1831                            format!("Pull ({})", status.behind)
1832                        }
1833                        _ => "Pull".to_string(),
1834                    })
1835                    .icon(IconName::ArrowDown)
1836                    .icon_size(IconSize::Small)
1837                    .icon_color(Color::Muted)
1838                    .icon_position(IconPosition::Start)
1839                    .disabled(status.is_gone())
1840                    .tooltip(move |window, cx| {
1841                        if disabled {
1842                            Tooltip::simple("Upstream is gone", cx)
1843                        } else {
1844                            // TODO: Add <origin> and <branch> argument substitutions to this
1845                            Tooltip::for_action("git pull", &git::Pull, window, cx)
1846                        }
1847                    })
1848                    .on_click(
1849                        cx.listener(move |this, _, window, cx| this.pull(&git::Pull, window, cx)),
1850                    )
1851                    .into_any_element()
1852                })
1853            })
1854    }
1855
1856    pub fn render_repository_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
1857        let active_repository = self.project.read(cx).active_repository(cx);
1858        let repository_display_name = active_repository
1859            .as_ref()
1860            .map(|repo| repo.read(cx).display_name(self.project.read(cx), cx))
1861            .unwrap_or_default();
1862
1863        RepositorySelectorPopoverMenu::new(
1864            self.repository_selector.clone(),
1865            ButtonLike::new("active-repository")
1866                .style(ButtonStyle::Subtle)
1867                .child(Label::new(repository_display_name).size(LabelSize::Small)),
1868            Tooltip::text("Select a repository"),
1869        )
1870    }
1871
1872    pub fn can_commit(&self) -> bool {
1873        (self.has_staged_changes() || self.has_tracked_changes()) && !self.has_unstaged_conflicts()
1874    }
1875
1876    pub fn can_stage_all(&self) -> bool {
1877        self.has_unstaged_changes()
1878    }
1879
1880    pub fn can_unstage_all(&self) -> bool {
1881        self.has_staged_changes()
1882    }
1883
1884    pub(crate) fn render_co_authors(&self, cx: &Context<Self>) -> Option<AnyElement> {
1885        let potential_co_authors = self.potential_co_authors(cx);
1886        if potential_co_authors.is_empty() {
1887            None
1888        } else {
1889            Some(
1890                IconButton::new("co-authors", IconName::Person)
1891                    .icon_color(Color::Disabled)
1892                    .selected_icon_color(Color::Selected)
1893                    .toggle_state(self.add_coauthors)
1894                    .tooltip(move |_, cx| {
1895                        let title = format!(
1896                            "Add co-authored-by:{}{}",
1897                            if potential_co_authors.len() == 1 {
1898                                ""
1899                            } else {
1900                                "\n"
1901                            },
1902                            potential_co_authors
1903                                .iter()
1904                                .map(|(name, email)| format!(" {} <{}>", name, email))
1905                                .join("\n")
1906                        );
1907                        Tooltip::simple(title, cx)
1908                    })
1909                    .on_click(cx.listener(|this, _, _, cx| {
1910                        this.add_coauthors = !this.add_coauthors;
1911                        cx.notify();
1912                    }))
1913                    .into_any_element(),
1914            )
1915        }
1916    }
1917
1918    pub fn render_commit_editor(
1919        &self,
1920        window: &mut Window,
1921        cx: &mut Context<Self>,
1922    ) -> impl IntoElement {
1923        let editor = self.commit_editor.clone();
1924        let can_commit = self.can_commit()
1925            && self.pending_commit.is_none()
1926            && !editor.read(cx).is_empty(cx)
1927            && self.has_write_access(cx);
1928
1929        let panel_editor_style = panel_editor_style(true, window, cx);
1930        let enable_coauthors = self.render_co_authors(cx);
1931
1932        let tooltip = if self.has_staged_changes() {
1933            "git commit"
1934        } else {
1935            "git commit --all"
1936        };
1937        let title = if self.has_staged_changes() {
1938            "Commit"
1939        } else {
1940            "Commit Tracked"
1941        };
1942        let editor_focus_handle = self.commit_editor.focus_handle(cx);
1943
1944        let commit_button = panel_filled_button(title)
1945            .tooltip(move |window, cx| {
1946                Tooltip::for_action_in(tooltip, &Commit, &editor_focus_handle, window, cx)
1947            })
1948            .disabled(!can_commit)
1949            .on_click({
1950                cx.listener(move |this, _: &ClickEvent, window, cx| this.commit_changes(window, cx))
1951            });
1952
1953        let branch = self
1954            .active_repository
1955            .as_ref()
1956            .and_then(|repo| repo.read(cx).current_branch().map(|b| b.name.clone()))
1957            .unwrap_or_else(|| "<no branch>".into());
1958
1959        let branch_selector = Button::new("branch-selector", branch)
1960            .color(Color::Muted)
1961            .style(ButtonStyle::Subtle)
1962            .icon(IconName::GitBranch)
1963            .icon_size(IconSize::Small)
1964            .icon_color(Color::Muted)
1965            .size(ButtonSize::Compact)
1966            .icon_position(IconPosition::Start)
1967            .tooltip(Tooltip::for_action_title(
1968                "Switch Branch",
1969                &zed_actions::git::Branch,
1970            ))
1971            .on_click(cx.listener(|_, _, window, cx| {
1972                window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
1973            }))
1974            .style(ButtonStyle::Transparent);
1975
1976        let footer_size = px(32.);
1977        let gap = px(16.0);
1978
1979        let max_height = window.line_height() * 6. + gap + footer_size;
1980
1981        panel_editor_container(window, cx)
1982            .id("commit-editor-container")
1983            .relative()
1984            .h(max_height)
1985            .w_full()
1986            .border_t_1()
1987            .border_color(cx.theme().colors().border)
1988            .bg(cx.theme().colors().editor_background)
1989            .cursor_text()
1990            .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
1991                window.focus(&this.commit_editor.focus_handle(cx));
1992            }))
1993            .when(!self.modal_open, |el| {
1994                el.child(EditorElement::new(&self.commit_editor, panel_editor_style))
1995                    .child(
1996                        h_flex()
1997                            .absolute()
1998                            .bottom_0()
1999                            .left_2()
2000                            .h(footer_size)
2001                            .flex_none()
2002                            .child(branch_selector),
2003                    )
2004                    .child(
2005                        h_flex()
2006                            .absolute()
2007                            .bottom_0()
2008                            .right_2()
2009                            .h(footer_size)
2010                            .flex_none()
2011                            .children(enable_coauthors)
2012                            .child(commit_button),
2013                    )
2014            })
2015    }
2016
2017    fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
2018        let active_repository = self.active_repository.as_ref()?;
2019        let branch = active_repository.read(cx).current_branch()?;
2020        let commit = branch.most_recent_commit.as_ref()?.clone();
2021
2022        let this = cx.entity();
2023        Some(
2024            h_flex()
2025                .items_center()
2026                .py_1p5()
2027                .px(px(8.))
2028                .bg(cx.theme().colors().background)
2029                .border_t_1()
2030                .border_color(cx.theme().colors().border)
2031                .gap_1p5()
2032                .child(
2033                    div()
2034                        .flex_grow()
2035                        .overflow_hidden()
2036                        .max_w(relative(0.6))
2037                        .h_full()
2038                        .child(
2039                            Label::new(commit.subject.clone())
2040                                .size(LabelSize::Small)
2041                                .text_ellipsis(),
2042                        )
2043                        .id("commit-msg-hover")
2044                        .hoverable_tooltip(move |window, cx| {
2045                            GitPanelMessageTooltip::new(
2046                                this.clone(),
2047                                commit.sha.clone(),
2048                                window,
2049                                cx,
2050                            )
2051                            .into()
2052                        }),
2053                )
2054                .child(div().flex_1())
2055                .child(
2056                    panel_filled_button("Uncommit")
2057                        .icon(IconName::Undo)
2058                        .icon_size(IconSize::Small)
2059                        .icon_color(Color::Muted)
2060                        .icon_position(IconPosition::Start)
2061                        .tooltip(Tooltip::for_action_title(
2062                            if self.has_staged_changes() {
2063                                "git reset HEAD^ --soft"
2064                            } else {
2065                                "git reset HEAD^"
2066                            },
2067                            &git::Uncommit,
2068                        ))
2069                        .on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))),
2070                )
2071                .child(self.render_push_button(branch, cx)),
2072        )
2073    }
2074
2075    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
2076        h_flex()
2077            .h_full()
2078            .flex_grow()
2079            .justify_center()
2080            .items_center()
2081            .child(
2082                v_flex()
2083                    .gap_3()
2084                    .child(if self.active_repository.is_some() {
2085                        "No changes to commit"
2086                    } else {
2087                        "No Git repositories"
2088                    })
2089                    .text_ui_sm(cx)
2090                    .mx_auto()
2091                    .text_color(Color::Placeholder.color(cx)),
2092            )
2093    }
2094
2095    fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
2096        let scroll_bar_style = self.show_scrollbar(cx);
2097        let show_container = matches!(scroll_bar_style, ShowScrollbar::Always);
2098
2099        if !self.should_show_scrollbar(cx)
2100            || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
2101        {
2102            return None;
2103        }
2104
2105        Some(
2106            div()
2107                .id("git-panel-vertical-scroll")
2108                .occlude()
2109                .flex_none()
2110                .h_full()
2111                .cursor_default()
2112                .when(show_container, |this| this.pl_1().px_1p5())
2113                .when(!show_container, |this| {
2114                    this.absolute().right_1().top_1().bottom_1().w(px(12.))
2115                })
2116                .on_mouse_move(cx.listener(|_, _, _, cx| {
2117                    cx.notify();
2118                    cx.stop_propagation()
2119                }))
2120                .on_hover(|_, _, cx| {
2121                    cx.stop_propagation();
2122                })
2123                .on_any_mouse_down(|_, _, cx| {
2124                    cx.stop_propagation();
2125                })
2126                .on_mouse_up(
2127                    MouseButton::Left,
2128                    cx.listener(|this, _, window, cx| {
2129                        if !this.scrollbar_state.is_dragging()
2130                            && !this.focus_handle.contains_focused(window, cx)
2131                        {
2132                            this.hide_scrollbar(window, cx);
2133                            cx.notify();
2134                        }
2135
2136                        cx.stop_propagation();
2137                    }),
2138                )
2139                .on_scroll_wheel(cx.listener(|_, _, _, cx| {
2140                    cx.notify();
2141                }))
2142                .children(Scrollbar::vertical(
2143                    // percentage as f32..end_offset as f32,
2144                    self.scrollbar_state.clone(),
2145                )),
2146        )
2147    }
2148
2149    pub fn render_buffer_header_controls(
2150        &self,
2151        entity: &Entity<Self>,
2152        file: &Arc<dyn File>,
2153        _: &Window,
2154        cx: &App,
2155    ) -> Option<AnyElement> {
2156        let repo = self.active_repository.as_ref()?.read(cx);
2157        let repo_path = repo.worktree_id_path_to_repo_path(file.worktree_id(cx), file.path())?;
2158        let ix = self.entries_by_path.get(&repo_path)?;
2159        let entry = self.entries.get(*ix)?;
2160
2161        let is_staged = self.entry_is_staged(entry.status_entry()?);
2162
2163        let checkbox = Checkbox::new("stage-file", is_staged.into())
2164            .disabled(!self.has_write_access(cx))
2165            .fill()
2166            .elevation(ElevationIndex::Surface)
2167            .on_click({
2168                let entry = entry.clone();
2169                let git_panel = entity.downgrade();
2170                move |_, window, cx| {
2171                    git_panel
2172                        .update(cx, |this, cx| {
2173                            this.toggle_staged_for_entry(&entry, window, cx);
2174                            cx.stop_propagation();
2175                        })
2176                        .ok();
2177                }
2178            });
2179        Some(
2180            h_flex()
2181                .id("start-slot")
2182                .text_lg()
2183                .child(checkbox)
2184                .on_mouse_down(MouseButton::Left, |_, _, cx| {
2185                    // prevent the list item active state triggering when toggling checkbox
2186                    cx.stop_propagation();
2187                })
2188                .into_any_element(),
2189        )
2190    }
2191
2192    fn render_entries(
2193        &self,
2194        has_write_access: bool,
2195        _: &Window,
2196        cx: &mut Context<Self>,
2197    ) -> impl IntoElement {
2198        let entry_count = self.entries.len();
2199
2200        v_flex()
2201            .size_full()
2202            .flex_grow()
2203            .overflow_hidden()
2204            .child(
2205                uniform_list(cx.entity().clone(), "entries", entry_count, {
2206                    move |this, range, window, cx| {
2207                        let mut items = Vec::with_capacity(range.end - range.start);
2208
2209                        for ix in range {
2210                            match &this.entries.get(ix) {
2211                                Some(GitListEntry::GitStatusEntry(entry)) => {
2212                                    items.push(this.render_entry(
2213                                        ix,
2214                                        entry,
2215                                        has_write_access,
2216                                        window,
2217                                        cx,
2218                                    ));
2219                                }
2220                                Some(GitListEntry::Header(header)) => {
2221                                    items.push(this.render_list_header(
2222                                        ix,
2223                                        header,
2224                                        has_write_access,
2225                                        window,
2226                                        cx,
2227                                    ));
2228                                }
2229                                None => {}
2230                            }
2231                        }
2232
2233                        items
2234                    }
2235                })
2236                .size_full()
2237                .with_sizing_behavior(ListSizingBehavior::Infer)
2238                .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
2239                .track_scroll(self.scroll_handle.clone()),
2240            )
2241            .on_mouse_down(
2242                MouseButton::Right,
2243                cx.listener(move |this, event: &MouseDownEvent, window, cx| {
2244                    this.deploy_panel_context_menu(event.position, window, cx)
2245                }),
2246            )
2247            .children(self.render_scrollbar(cx))
2248    }
2249
2250    fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
2251        Label::new(label.into()).color(color).single_line()
2252    }
2253
2254    fn render_list_header(
2255        &self,
2256        ix: usize,
2257        header: &GitHeaderEntry,
2258        _: bool,
2259        _: &Window,
2260        _: &Context<Self>,
2261    ) -> AnyElement {
2262        div()
2263            .w_full()
2264            .child(
2265                ListItem::new(ix)
2266                    .spacing(ListItemSpacing::Sparse)
2267                    .disabled(true)
2268                    .child(
2269                        Label::new(header.title())
2270                            .color(Color::Muted)
2271                            .size(LabelSize::Small)
2272                            .single_line(),
2273                    ),
2274            )
2275            .into_any_element()
2276    }
2277
2278    fn load_commit_details(
2279        &self,
2280        sha: &str,
2281        cx: &mut Context<Self>,
2282    ) -> Task<Result<CommitDetails>> {
2283        let Some(repo) = self.active_repository.clone() else {
2284            return Task::ready(Err(anyhow::anyhow!("no active repo")));
2285        };
2286        repo.update(cx, |repo, cx| repo.show(sha, cx))
2287    }
2288
2289    fn deploy_entry_context_menu(
2290        &mut self,
2291        position: Point<Pixels>,
2292        ix: usize,
2293        window: &mut Window,
2294        cx: &mut Context<Self>,
2295    ) {
2296        let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else {
2297            return;
2298        };
2299        let stage_title = if entry.status.is_staged() == Some(true) {
2300            "Unstage File"
2301        } else {
2302            "Stage File"
2303        };
2304        let revert_title = if entry.status.is_deleted() {
2305            "Restore file"
2306        } else if entry.status.is_created() {
2307            "Trash file"
2308        } else {
2309            "Discard changes"
2310        };
2311        let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
2312            context_menu
2313                .action(stage_title, ToggleStaged.boxed_clone())
2314                .action(revert_title, git::RestoreFile.boxed_clone())
2315                .separator()
2316                .action("Open Diff", Confirm.boxed_clone())
2317                .action("Open File", SecondaryConfirm.boxed_clone())
2318        });
2319        self.selected_entry = Some(ix);
2320        self.set_context_menu(context_menu, position, window, cx);
2321    }
2322
2323    fn deploy_panel_context_menu(
2324        &mut self,
2325        position: Point<Pixels>,
2326        window: &mut Window,
2327        cx: &mut Context<Self>,
2328    ) {
2329        let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
2330            context_menu
2331                .action("Stage All", StageAll.boxed_clone())
2332                .action("Unstage All", UnstageAll.boxed_clone())
2333                .action("Open Diff", project_diff::Diff.boxed_clone())
2334                .separator()
2335                .action("Discard Tracked Changes", RestoreTrackedFiles.boxed_clone())
2336                .action("Trash Untracked Files", TrashUntrackedFiles.boxed_clone())
2337        });
2338        self.set_context_menu(context_menu, position, window, cx);
2339    }
2340
2341    fn set_context_menu(
2342        &mut self,
2343        context_menu: Entity<ContextMenu>,
2344        position: Point<Pixels>,
2345        window: &Window,
2346        cx: &mut Context<Self>,
2347    ) {
2348        let subscription = cx.subscribe_in(
2349            &context_menu,
2350            window,
2351            |this, _, _: &DismissEvent, window, cx| {
2352                if this.context_menu.as_ref().is_some_and(|context_menu| {
2353                    context_menu.0.focus_handle(cx).contains_focused(window, cx)
2354                }) {
2355                    cx.focus_self(window);
2356                }
2357                this.context_menu.take();
2358                cx.notify();
2359            },
2360        );
2361        self.context_menu = Some((context_menu, position, subscription));
2362        cx.notify();
2363    }
2364
2365    fn render_entry(
2366        &self,
2367        ix: usize,
2368        entry: &GitStatusEntry,
2369        has_write_access: bool,
2370        window: &Window,
2371        cx: &Context<Self>,
2372    ) -> AnyElement {
2373        let display_name = entry
2374            .repo_path
2375            .file_name()
2376            .map(|name| name.to_string_lossy().into_owned())
2377            .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
2378
2379        let repo_path = entry.repo_path.clone();
2380        let selected = self.selected_entry == Some(ix);
2381        let status_style = GitPanelSettings::get_global(cx).status_style;
2382        let status = entry.status;
2383        let has_conflict = status.is_conflicted();
2384        let is_modified = status.is_modified();
2385        let is_deleted = status.is_deleted();
2386
2387        let label_color = if status_style == StatusStyle::LabelColor {
2388            if has_conflict {
2389                Color::Conflict
2390            } else if is_modified {
2391                Color::Modified
2392            } else if is_deleted {
2393                // We don't want a bunch of red labels in the list
2394                Color::Disabled
2395            } else {
2396                Color::Created
2397            }
2398        } else {
2399            Color::Default
2400        };
2401
2402        let path_color = if status.is_deleted() {
2403            Color::Disabled
2404        } else {
2405            Color::Muted
2406        };
2407
2408        let id: ElementId = ElementId::Name(format!("entry_{}", display_name).into());
2409
2410        let mut is_staged: ToggleState = self.entry_is_staged(entry).into();
2411
2412        if !self.has_staged_changes() && !self.has_conflicts() && !entry.status.is_created() {
2413            is_staged = ToggleState::Selected;
2414        }
2415
2416        let checkbox = Checkbox::new(id, is_staged)
2417            .disabled(!has_write_access)
2418            .fill()
2419            .placeholder(!self.has_staged_changes() && !self.has_conflicts())
2420            .elevation(ElevationIndex::Surface)
2421            .on_click({
2422                let entry = entry.clone();
2423                cx.listener(move |this, _, window, cx| {
2424                    this.toggle_staged_for_entry(
2425                        &GitListEntry::GitStatusEntry(entry.clone()),
2426                        window,
2427                        cx,
2428                    );
2429                    cx.stop_propagation();
2430                })
2431            });
2432
2433        let start_slot = h_flex()
2434            .id(("start-slot", ix))
2435            .gap(DynamicSpacing::Base04.rems(cx))
2436            .child(checkbox)
2437            .tooltip(|window, cx| Tooltip::for_action("Stage File", &ToggleStaged, window, cx))
2438            .child(git_status_icon(status, cx))
2439            .on_mouse_down(MouseButton::Left, |_, _, cx| {
2440                // prevent the list item active state triggering when toggling checkbox
2441                cx.stop_propagation();
2442            });
2443
2444        div()
2445            .w_full()
2446            .child(
2447                ListItem::new(ix)
2448                    .spacing(ListItemSpacing::Sparse)
2449                    .start_slot(start_slot)
2450                    .toggle_state(selected)
2451                    .focused(selected && self.focus_handle(cx).is_focused(window))
2452                    .disabled(!has_write_access)
2453                    .on_click({
2454                        cx.listener(move |this, event: &ClickEvent, window, cx| {
2455                            this.selected_entry = Some(ix);
2456                            cx.notify();
2457                            if event.modifiers().secondary() {
2458                                this.open_file(&Default::default(), window, cx)
2459                            } else {
2460                                this.open_diff(&Default::default(), window, cx);
2461                            }
2462                        })
2463                    })
2464                    .on_secondary_mouse_down(cx.listener(
2465                        move |this, event: &MouseDownEvent, window, cx| {
2466                            this.deploy_entry_context_menu(event.position, ix, window, cx);
2467                            cx.stop_propagation();
2468                        },
2469                    ))
2470                    .child(
2471                        h_flex()
2472                            .when_some(repo_path.parent(), |this, parent| {
2473                                let parent_str = parent.to_string_lossy();
2474                                if !parent_str.is_empty() {
2475                                    this.child(
2476                                        self.entry_label(format!("{}/", parent_str), path_color)
2477                                            .when(status.is_deleted(), |this| this.strikethrough()),
2478                                    )
2479                                } else {
2480                                    this
2481                                }
2482                            })
2483                            .child(
2484                                self.entry_label(display_name.clone(), label_color)
2485                                    .when(status.is_deleted(), |this| this.strikethrough()),
2486                            ),
2487                    ),
2488            )
2489            .into_any_element()
2490    }
2491
2492    fn render_push_button(&self, branch: &Branch, cx: &Context<Self>) -> AnyElement {
2493        let mut disabled = false;
2494
2495        // TODO: Add <origin> and <branch> argument substitutions to this
2496        let button: SharedString;
2497        let tooltip: SharedString;
2498        let action: Option<Push>;
2499        if let Some(upstream) = &branch.upstream {
2500            match upstream.tracking {
2501                UpstreamTracking::Gone => {
2502                    button = "Republish".into();
2503                    tooltip = "git push --set-upstream".into();
2504                    action = Some(git::Push {
2505                        options: Some(PushOptions::SetUpstream),
2506                    });
2507                }
2508                UpstreamTracking::Tracked(tracking) => {
2509                    if tracking.behind > 0 {
2510                        disabled = true;
2511                        button = "Push".into();
2512                        tooltip = "Upstream is ahead of local branch".into();
2513                        action = None;
2514                    } else if tracking.ahead > 0 {
2515                        button = format!("Push ({})", tracking.ahead).into();
2516                        tooltip = "git push".into();
2517                        action = Some(git::Push { options: None });
2518                    } else {
2519                        disabled = true;
2520                        button = "Push".into();
2521                        tooltip = "Upstream matches local branch".into();
2522                        action = None;
2523                    }
2524                }
2525            }
2526        } else {
2527            button = "Publish".into();
2528            tooltip = "git push --set-upstream".into();
2529            action = Some(git::Push {
2530                options: Some(PushOptions::SetUpstream),
2531            });
2532        };
2533
2534        panel_filled_button(button)
2535            .icon(IconName::ArrowUp)
2536            .icon_size(IconSize::Small)
2537            .icon_color(Color::Muted)
2538            .icon_position(IconPosition::Start)
2539            .disabled(disabled)
2540            .when_some(action, |this, action| {
2541                this.on_click(
2542                    cx.listener(move |this, _, window, cx| this.push(&action, window, cx)),
2543                )
2544            })
2545            .tooltip(move |window, cx| {
2546                if let Some(action) = action.as_ref() {
2547                    Tooltip::for_action(tooltip.clone(), action, window, cx)
2548                } else {
2549                    Tooltip::simple(tooltip.clone(), cx)
2550                }
2551            })
2552            .into_any_element()
2553    }
2554
2555    fn has_write_access(&self, cx: &App) -> bool {
2556        !self.project.read(cx).is_read_only(cx)
2557    }
2558}
2559
2560impl Render for GitPanel {
2561    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2562        let project = self.project.read(cx);
2563        let has_entries = self.entries.len() > 0;
2564        let room = self
2565            .workspace
2566            .upgrade()
2567            .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
2568
2569        let has_write_access = self.has_write_access(cx);
2570
2571        let has_co_authors = room.map_or(false, |room| {
2572            room.read(cx)
2573                .remote_participants()
2574                .values()
2575                .any(|remote_participant| remote_participant.can_write())
2576        });
2577
2578        v_flex()
2579            .id("git_panel")
2580            .key_context(self.dispatch_context(window, cx))
2581            .track_focus(&self.focus_handle)
2582            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
2583            .when(has_write_access && !project.is_read_only(cx), |this| {
2584                this.on_action(cx.listener(|this, &ToggleStaged, window, cx| {
2585                    this.toggle_staged_for_selected(&ToggleStaged, window, cx)
2586                }))
2587                .on_action(cx.listener(GitPanel::commit))
2588            })
2589            .on_action(cx.listener(Self::select_first))
2590            .on_action(cx.listener(Self::select_next))
2591            .on_action(cx.listener(Self::select_prev))
2592            .on_action(cx.listener(Self::select_last))
2593            .on_action(cx.listener(Self::close_panel))
2594            .on_action(cx.listener(Self::open_diff))
2595            .on_action(cx.listener(Self::open_file))
2596            .on_action(cx.listener(Self::revert_selected))
2597            .on_action(cx.listener(Self::focus_changes_list))
2598            .on_action(cx.listener(Self::focus_editor))
2599            .on_action(cx.listener(Self::toggle_staged_for_selected))
2600            .on_action(cx.listener(Self::stage_all))
2601            .on_action(cx.listener(Self::unstage_all))
2602            .on_action(cx.listener(Self::discard_tracked_changes))
2603            .on_action(cx.listener(Self::clean_all))
2604            .on_action(cx.listener(Self::fetch))
2605            .on_action(cx.listener(Self::pull))
2606            .on_action(cx.listener(Self::push))
2607            .when(has_write_access && has_co_authors, |git_panel| {
2608                git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
2609            })
2610            // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx)))
2611            .on_hover(cx.listener(|this, hovered, window, cx| {
2612                if *hovered {
2613                    this.show_scrollbar = true;
2614                    this.hide_scrollbar_task.take();
2615                    cx.notify();
2616                } else if !this.focus_handle.contains_focused(window, cx) {
2617                    this.hide_scrollbar(window, cx);
2618                }
2619            }))
2620            .size_full()
2621            .overflow_hidden()
2622            .bg(ElevationIndex::Surface.bg(cx))
2623            .child(
2624                v_flex()
2625                    .size_full()
2626                    .children(self.render_panel_header(window, cx))
2627                    .map(|this| {
2628                        if has_entries {
2629                            this.child(self.render_entries(has_write_access, window, cx))
2630                        } else {
2631                            this.child(self.render_empty_state(cx).into_any_element())
2632                        }
2633                    })
2634                    .children(self.render_previous_commit(cx))
2635                    .child(self.render_commit_editor(window, cx))
2636                    .into_any_element(),
2637            )
2638            .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2639                deferred(
2640                    anchored()
2641                        .position(*position)
2642                        .anchor(gpui::Corner::TopLeft)
2643                        .child(menu.clone()),
2644                )
2645                .with_priority(1)
2646            }))
2647    }
2648}
2649
2650impl Focusable for GitPanel {
2651    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
2652        self.focus_handle.clone()
2653    }
2654}
2655
2656impl EventEmitter<Event> for GitPanel {}
2657
2658impl EventEmitter<PanelEvent> for GitPanel {}
2659
2660pub(crate) struct GitPanelAddon {
2661    pub(crate) workspace: WeakEntity<Workspace>,
2662}
2663
2664impl editor::Addon for GitPanelAddon {
2665    fn to_any(&self) -> &dyn std::any::Any {
2666        self
2667    }
2668
2669    fn render_buffer_header_controls(
2670        &self,
2671        excerpt_info: &ExcerptInfo,
2672        window: &Window,
2673        cx: &App,
2674    ) -> Option<AnyElement> {
2675        let file = excerpt_info.buffer.file()?;
2676        let git_panel = self.workspace.upgrade()?.read(cx).panel::<GitPanel>(cx)?;
2677
2678        git_panel
2679            .read(cx)
2680            .render_buffer_header_controls(&git_panel, &file, window, cx)
2681    }
2682}
2683
2684impl Panel for GitPanel {
2685    fn persistent_name() -> &'static str {
2686        "GitPanel"
2687    }
2688
2689    fn position(&self, _: &Window, cx: &App) -> DockPosition {
2690        GitPanelSettings::get_global(cx).dock
2691    }
2692
2693    fn position_is_valid(&self, position: DockPosition) -> bool {
2694        matches!(position, DockPosition::Left | DockPosition::Right)
2695    }
2696
2697    fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
2698        settings::update_settings_file::<GitPanelSettings>(
2699            self.fs.clone(),
2700            cx,
2701            move |settings, _| settings.dock = Some(position),
2702        );
2703    }
2704
2705    fn size(&self, _: &Window, cx: &App) -> Pixels {
2706        self.width
2707            .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
2708    }
2709
2710    fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
2711        self.width = size;
2712        self.serialize(cx);
2713        cx.notify();
2714    }
2715
2716    fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
2717        Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
2718    }
2719
2720    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
2721        Some("Git Panel")
2722    }
2723
2724    fn toggle_action(&self) -> Box<dyn Action> {
2725        Box::new(ToggleFocus)
2726    }
2727
2728    fn activation_priority(&self) -> u32 {
2729        2
2730    }
2731}
2732
2733impl PanelHeader for GitPanel {}
2734
2735struct GitPanelMessageTooltip {
2736    commit_tooltip: Option<Entity<CommitTooltip>>,
2737}
2738
2739impl GitPanelMessageTooltip {
2740    fn new(
2741        git_panel: Entity<GitPanel>,
2742        sha: SharedString,
2743        window: &mut Window,
2744        cx: &mut App,
2745    ) -> Entity<Self> {
2746        cx.new(|cx| {
2747            cx.spawn_in(window, |this, mut cx| async move {
2748                let details = git_panel
2749                    .update(&mut cx, |git_panel, cx| {
2750                        git_panel.load_commit_details(&sha, cx)
2751                    })?
2752                    .await?;
2753
2754                let commit_details = editor::commit_tooltip::CommitDetails {
2755                    sha: details.sha.clone(),
2756                    committer_name: details.committer_name.clone(),
2757                    committer_email: details.committer_email.clone(),
2758                    commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
2759                    message: Some(editor::commit_tooltip::ParsedCommitMessage {
2760                        message: details.message.clone(),
2761                        ..Default::default()
2762                    }),
2763                };
2764
2765                this.update_in(&mut cx, |this: &mut GitPanelMessageTooltip, window, cx| {
2766                    this.commit_tooltip =
2767                        Some(cx.new(move |cx| CommitTooltip::new(commit_details, window, cx)));
2768                    cx.notify();
2769                })
2770            })
2771            .detach();
2772
2773            Self {
2774                commit_tooltip: None,
2775            }
2776        })
2777    }
2778}
2779
2780impl Render for GitPanelMessageTooltip {
2781    fn render(&mut self, _window: &mut Window, _cx: &mut Context<'_, Self>) -> impl IntoElement {
2782        if let Some(commit_tooltip) = &self.commit_tooltip {
2783            commit_tooltip.clone().into_any_element()
2784        } else {
2785            gpui::Empty.into_any_element()
2786        }
2787    }
2788}