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    fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
1063        if self
1064            .commit_editor
1065            .focus_handle(cx)
1066            .contains_focused(window, cx)
1067        {
1068            self.commit_changes(window, cx)
1069        }
1070        cx.propagate();
1071    }
1072
1073    pub(crate) fn commit_changes(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1074        let Some(active_repository) = self.active_repository.clone() else {
1075            return;
1076        };
1077        let error_spawn = |message, window: &mut Window, cx: &mut App| {
1078            let prompt = window.prompt(PromptLevel::Warning, message, None, &["Ok"], cx);
1079            cx.spawn(|_| async move {
1080                prompt.await.ok();
1081            })
1082            .detach();
1083        };
1084
1085        if self.has_unstaged_conflicts() {
1086            error_spawn(
1087                "There are still conflicts. You must stage these before committing",
1088                window,
1089                cx,
1090            );
1091            return;
1092        }
1093
1094        let mut message = self.commit_editor.read(cx).text(cx);
1095        if message.trim().is_empty() {
1096            self.commit_editor.read(cx).focus_handle(cx).focus(window);
1097            return;
1098        }
1099        if self.add_coauthors {
1100            self.fill_co_authors(&mut message, cx);
1101        }
1102
1103        let task = if self.has_staged_changes() {
1104            // Repository serializes all git operations, so we can just send a commit immediately
1105            let commit_task = active_repository.read(cx).commit(message.into(), None);
1106            cx.background_spawn(async move { commit_task.await? })
1107        } else {
1108            let changed_files = self
1109                .entries
1110                .iter()
1111                .filter_map(|entry| entry.status_entry())
1112                .filter(|status_entry| !status_entry.status.is_created())
1113                .map(|status_entry| status_entry.repo_path.clone())
1114                .collect::<Vec<_>>();
1115
1116            if changed_files.is_empty() {
1117                error_spawn("No changes to commit", window, cx);
1118                return;
1119            }
1120
1121            let stage_task =
1122                active_repository.update(cx, |repo, cx| repo.stage_entries(changed_files, cx));
1123            cx.spawn(|_, mut cx| async move {
1124                stage_task.await?;
1125                let commit_task = active_repository
1126                    .update(&mut cx, |repo, _| repo.commit(message.into(), None))?;
1127                commit_task.await?
1128            })
1129        };
1130        let task = cx.spawn_in(window, |this, mut cx| async move {
1131            let result = task.await;
1132            this.update_in(&mut cx, |this, window, cx| {
1133                this.pending_commit.take();
1134                match result {
1135                    Ok(()) => {
1136                        this.commit_editor
1137                            .update(cx, |editor, cx| editor.clear(window, cx));
1138                    }
1139                    Err(e) => this.show_err_toast(e, cx),
1140                }
1141            })
1142            .ok();
1143        });
1144
1145        self.pending_commit = Some(task);
1146    }
1147
1148    fn uncommit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1149        let Some(repo) = self.active_repository.clone() else {
1150            return;
1151        };
1152
1153        // TODO: Use git merge-base to find the upstream and main branch split
1154        let confirmation = Task::ready(true);
1155        // let confirmation = if self.commit_editor.read(cx).is_empty(cx) {
1156        //     Task::ready(true)
1157        // } else {
1158        //     let prompt = window.prompt(
1159        //         PromptLevel::Warning,
1160        //         "Uncomitting will replace the current commit message with the previous commit's message",
1161        //         None,
1162        //         &["Ok", "Cancel"],
1163        //         cx,
1164        //     );
1165        //     cx.spawn(|_, _| async move { prompt.await.is_ok_and(|i| i == 0) })
1166        // };
1167
1168        let prior_head = self.load_commit_details("HEAD", cx);
1169
1170        let task = cx.spawn_in(window, |this, mut cx| async move {
1171            let result = maybe!(async {
1172                if !confirmation.await {
1173                    Ok(None)
1174                } else {
1175                    let prior_head = prior_head.await?;
1176
1177                    repo.update(&mut cx, |repo, _| repo.reset("HEAD^", ResetMode::Soft))?
1178                        .await??;
1179
1180                    Ok(Some(prior_head))
1181                }
1182            })
1183            .await;
1184
1185            this.update_in(&mut cx, |this, window, cx| {
1186                this.pending_commit.take();
1187                match result {
1188                    Ok(None) => {}
1189                    Ok(Some(prior_commit)) => {
1190                        this.commit_editor.update(cx, |editor, cx| {
1191                            editor.set_text(prior_commit.message, window, cx)
1192                        });
1193                    }
1194                    Err(e) => this.show_err_toast(e, cx),
1195                }
1196            })
1197            .ok();
1198        });
1199
1200        self.pending_commit = Some(task);
1201    }
1202
1203    fn fetch(&mut self, _: &git::Fetch, _window: &mut Window, cx: &mut Context<Self>) {
1204        let Some(repo) = self.active_repository.clone() else {
1205            return;
1206        };
1207        let guard = self.start_remote_operation();
1208        let fetch = repo.read(cx).fetch();
1209        cx.spawn(|_, _| async move {
1210            fetch.await??;
1211            drop(guard);
1212            anyhow::Ok(())
1213        })
1214        .detach_and_log_err(cx);
1215    }
1216
1217    fn pull(&mut self, _: &git::Pull, window: &mut Window, cx: &mut Context<Self>) {
1218        let guard = self.start_remote_operation();
1219        let remote = self.get_current_remote(window, cx);
1220        cx.spawn(move |this, mut cx| async move {
1221            let remote = remote.await?;
1222
1223            this.update(&mut cx, |this, cx| {
1224                let Some(repo) = this.active_repository.clone() else {
1225                    return Err(anyhow::anyhow!("No active repository"));
1226                };
1227
1228                let Some(branch) = repo.read(cx).current_branch() else {
1229                    return Err(anyhow::anyhow!("No active branch"));
1230                };
1231
1232                Ok(repo.read(cx).pull(branch.name.clone(), remote.name))
1233            })??
1234            .await??;
1235
1236            drop(guard);
1237            anyhow::Ok(())
1238        })
1239        .detach_and_log_err(cx);
1240    }
1241
1242    fn push(&mut self, action: &git::Push, window: &mut Window, cx: &mut Context<Self>) {
1243        let guard = self.start_remote_operation();
1244        let options = action.options;
1245        let remote = self.get_current_remote(window, cx);
1246        cx.spawn(move |this, mut cx| async move {
1247            let remote = remote.await?;
1248
1249            this.update(&mut cx, |this, cx| {
1250                let Some(repo) = this.active_repository.clone() else {
1251                    return Err(anyhow::anyhow!("No active repository"));
1252                };
1253
1254                let Some(branch) = repo.read(cx).current_branch() else {
1255                    return Err(anyhow::anyhow!("No active branch"));
1256                };
1257
1258                Ok(repo
1259                    .read(cx)
1260                    .push(branch.name.clone(), remote.name, options))
1261            })??
1262            .await??;
1263
1264            drop(guard);
1265            anyhow::Ok(())
1266        })
1267        .detach_and_log_err(cx);
1268    }
1269
1270    fn get_current_remote(
1271        &mut self,
1272        window: &mut Window,
1273        cx: &mut Context<Self>,
1274    ) -> impl Future<Output = Result<Remote>> {
1275        let repo = self.active_repository.clone();
1276        let workspace = self.workspace.clone();
1277        let mut cx = window.to_async(cx);
1278
1279        async move {
1280            let Some(repo) = repo else {
1281                return Err(anyhow::anyhow!("No active repository"));
1282            };
1283
1284            let mut current_remotes: Vec<Remote> = repo
1285                .update(&mut cx, |repo, cx| {
1286                    let Some(current_branch) = repo.current_branch() else {
1287                        return Err(anyhow::anyhow!("No active branch"));
1288                    };
1289
1290                    Ok(repo.get_remotes(Some(current_branch.name.to_string()), cx))
1291                })??
1292                .await?;
1293
1294            if current_remotes.len() == 0 {
1295                return Err(anyhow::anyhow!("No active remote"));
1296            } else if current_remotes.len() == 1 {
1297                return Ok(current_remotes.pop().unwrap());
1298            } else {
1299                let current_remotes: Vec<_> = current_remotes
1300                    .into_iter()
1301                    .map(|remotes| remotes.name)
1302                    .collect();
1303                let selection = cx
1304                    .update(|window, cx| {
1305                        picker_prompt::prompt(
1306                            "Pick which remote to push to",
1307                            current_remotes.clone(),
1308                            workspace,
1309                            window,
1310                            cx,
1311                        )
1312                    })?
1313                    .await?;
1314
1315                return Ok(Remote {
1316                    name: current_remotes[selection].clone(),
1317                });
1318            }
1319        }
1320    }
1321
1322    fn potential_co_authors(&self, cx: &App) -> Vec<(String, String)> {
1323        let mut new_co_authors = Vec::new();
1324        let project = self.project.read(cx);
1325
1326        let Some(room) = self
1327            .workspace
1328            .upgrade()
1329            .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned())
1330        else {
1331            return Vec::default();
1332        };
1333
1334        let room = room.read(cx);
1335
1336        for (peer_id, collaborator) in project.collaborators() {
1337            if collaborator.is_host {
1338                continue;
1339            }
1340
1341            let Some(participant) = room.remote_participant_for_peer_id(*peer_id) else {
1342                continue;
1343            };
1344            if participant.can_write() && participant.user.email.is_some() {
1345                let email = participant.user.email.clone().unwrap();
1346
1347                new_co_authors.push((
1348                    participant
1349                        .user
1350                        .name
1351                        .clone()
1352                        .unwrap_or_else(|| participant.user.github_login.clone()),
1353                    email,
1354                ))
1355            }
1356        }
1357        if !project.is_local() && !project.is_read_only(cx) {
1358            if let Some(user) = room.local_participant_user(cx) {
1359                if let Some(email) = user.email.clone() {
1360                    new_co_authors.push((
1361                        user.name
1362                            .clone()
1363                            .unwrap_or_else(|| user.github_login.clone()),
1364                        email.clone(),
1365                    ))
1366                }
1367            }
1368        }
1369        new_co_authors
1370    }
1371
1372    fn toggle_fill_co_authors(
1373        &mut self,
1374        _: &ToggleFillCoAuthors,
1375        _: &mut Window,
1376        cx: &mut Context<Self>,
1377    ) {
1378        self.add_coauthors = !self.add_coauthors;
1379        cx.notify();
1380    }
1381
1382    fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context<Self>) {
1383        const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
1384
1385        let existing_text = message.to_ascii_lowercase();
1386        let lowercase_co_author_prefix = CO_AUTHOR_PREFIX.to_lowercase();
1387        let mut ends_with_co_authors = false;
1388        let existing_co_authors = existing_text
1389            .lines()
1390            .filter_map(|line| {
1391                let line = line.trim();
1392                if line.starts_with(&lowercase_co_author_prefix) {
1393                    ends_with_co_authors = true;
1394                    Some(line)
1395                } else {
1396                    ends_with_co_authors = false;
1397                    None
1398                }
1399            })
1400            .collect::<HashSet<_>>();
1401
1402        let new_co_authors = self
1403            .potential_co_authors(cx)
1404            .into_iter()
1405            .filter(|(_, email)| {
1406                !existing_co_authors
1407                    .iter()
1408                    .any(|existing| existing.contains(email.as_str()))
1409            })
1410            .collect::<Vec<_>>();
1411
1412        if new_co_authors.is_empty() {
1413            return;
1414        }
1415
1416        if !ends_with_co_authors {
1417            message.push('\n');
1418        }
1419        for (name, email) in new_co_authors {
1420            message.push('\n');
1421            message.push_str(CO_AUTHOR_PREFIX);
1422            message.push_str(&name);
1423            message.push_str(" <");
1424            message.push_str(&email);
1425            message.push('>');
1426        }
1427        message.push('\n');
1428    }
1429
1430    fn schedule_update(
1431        &mut self,
1432        clear_pending: bool,
1433        window: &mut Window,
1434        cx: &mut Context<Self>,
1435    ) {
1436        let handle = cx.entity().downgrade();
1437        self.reopen_commit_buffer(window, cx);
1438        self.update_visible_entries_task = cx.spawn_in(window, |_, mut cx| async move {
1439            cx.background_executor().timer(UPDATE_DEBOUNCE).await;
1440            if let Some(git_panel) = handle.upgrade() {
1441                git_panel
1442                    .update_in(&mut cx, |git_panel, _, cx| {
1443                        if clear_pending {
1444                            git_panel.clear_pending();
1445                        }
1446                        git_panel.update_visible_entries(cx);
1447                    })
1448                    .ok();
1449            }
1450        });
1451    }
1452
1453    fn reopen_commit_buffer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1454        let Some(active_repo) = self.active_repository.as_ref() else {
1455            return;
1456        };
1457        let load_buffer = active_repo.update(cx, |active_repo, cx| {
1458            let project = self.project.read(cx);
1459            active_repo.open_commit_buffer(
1460                Some(project.languages().clone()),
1461                project.buffer_store().clone(),
1462                cx,
1463            )
1464        });
1465
1466        cx.spawn_in(window, |git_panel, mut cx| async move {
1467            let buffer = load_buffer.await?;
1468            git_panel.update_in(&mut cx, |git_panel, window, cx| {
1469                if git_panel
1470                    .commit_editor
1471                    .read(cx)
1472                    .buffer()
1473                    .read(cx)
1474                    .as_singleton()
1475                    .as_ref()
1476                    != Some(&buffer)
1477                {
1478                    git_panel.commit_editor = cx.new(|cx| {
1479                        commit_message_editor(buffer, git_panel.project.clone(), true, window, cx)
1480                    });
1481                }
1482            })
1483        })
1484        .detach_and_log_err(cx);
1485    }
1486
1487    fn clear_pending(&mut self) {
1488        self.pending.retain(|v| !v.finished)
1489    }
1490
1491    fn update_visible_entries(&mut self, cx: &mut Context<Self>) {
1492        self.entries.clear();
1493        self.entries_by_path.clear();
1494        let mut changed_entries = Vec::new();
1495        let mut new_entries = Vec::new();
1496        let mut conflict_entries = Vec::new();
1497
1498        let Some(repo) = self.active_repository.as_ref() else {
1499            // Just clear entries if no repository is active.
1500            cx.notify();
1501            return;
1502        };
1503
1504        // First pass - collect all paths
1505        let repo = repo.read(cx);
1506        let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path));
1507
1508        // Second pass - create entries with proper depth calculation
1509        for entry in repo.status() {
1510            let (depth, difference) =
1511                Self::calculate_depth_and_difference(&entry.repo_path, &path_set);
1512
1513            let is_conflict = repo.has_conflict(&entry.repo_path);
1514            let is_new = entry.status.is_created();
1515            let is_staged = entry.status.is_staged();
1516
1517            if self.pending.iter().any(|pending| {
1518                pending.target_status == TargetStatus::Reverted
1519                    && !pending.finished
1520                    && pending.repo_paths.contains(&entry.repo_path)
1521            }) {
1522                continue;
1523            }
1524
1525            let display_name = if difference > 1 {
1526                // Show partial path for deeply nested files
1527                entry
1528                    .repo_path
1529                    .as_ref()
1530                    .iter()
1531                    .skip(entry.repo_path.components().count() - difference)
1532                    .collect::<PathBuf>()
1533                    .to_string_lossy()
1534                    .into_owned()
1535            } else {
1536                // Just show filename
1537                entry
1538                    .repo_path
1539                    .file_name()
1540                    .map(|name| name.to_string_lossy().into_owned())
1541                    .unwrap_or_default()
1542            };
1543
1544            let entry = GitStatusEntry {
1545                depth,
1546                display_name,
1547                repo_path: entry.repo_path.clone(),
1548                status: entry.status,
1549                is_staged,
1550            };
1551
1552            if is_conflict {
1553                conflict_entries.push(entry);
1554            } else if is_new {
1555                new_entries.push(entry);
1556            } else {
1557                changed_entries.push(entry);
1558            }
1559        }
1560
1561        // Sort entries by path to maintain consistent order
1562        conflict_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
1563        changed_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
1564        new_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
1565
1566        if conflict_entries.len() > 0 {
1567            self.entries.push(GitListEntry::Header(GitHeaderEntry {
1568                header: Section::Conflict,
1569            }));
1570            self.entries.extend(
1571                conflict_entries
1572                    .into_iter()
1573                    .map(GitListEntry::GitStatusEntry),
1574            );
1575        }
1576
1577        if changed_entries.len() > 0 {
1578            self.entries.push(GitListEntry::Header(GitHeaderEntry {
1579                header: Section::Tracked,
1580            }));
1581            self.entries.extend(
1582                changed_entries
1583                    .into_iter()
1584                    .map(GitListEntry::GitStatusEntry),
1585            );
1586        }
1587        if new_entries.len() > 0 {
1588            self.entries.push(GitListEntry::Header(GitHeaderEntry {
1589                header: Section::New,
1590            }));
1591            self.entries
1592                .extend(new_entries.into_iter().map(GitListEntry::GitStatusEntry));
1593        }
1594
1595        for (ix, entry) in self.entries.iter().enumerate() {
1596            if let Some(status_entry) = entry.status_entry() {
1597                self.entries_by_path
1598                    .insert(status_entry.repo_path.clone(), ix);
1599            }
1600        }
1601        self.update_counts(repo);
1602
1603        self.select_first_entry_if_none(cx);
1604
1605        cx.notify();
1606    }
1607
1608    fn header_state(&self, header_type: Section) -> ToggleState {
1609        let (staged_count, count) = match header_type {
1610            Section::New => (self.new_staged_count, self.new_count),
1611            Section::Tracked => (self.tracked_staged_count, self.tracked_count),
1612            Section::Conflict => (self.conflicted_staged_count, self.conflicted_count),
1613        };
1614        if staged_count == 0 {
1615            ToggleState::Unselected
1616        } else if count == staged_count {
1617            ToggleState::Selected
1618        } else {
1619            ToggleState::Indeterminate
1620        }
1621    }
1622
1623    fn update_counts(&mut self, repo: &Repository) {
1624        self.conflicted_count = 0;
1625        self.conflicted_staged_count = 0;
1626        self.new_count = 0;
1627        self.tracked_count = 0;
1628        self.new_staged_count = 0;
1629        self.tracked_staged_count = 0;
1630        for entry in &self.entries {
1631            let Some(status_entry) = entry.status_entry() else {
1632                continue;
1633            };
1634            if repo.has_conflict(&status_entry.repo_path) {
1635                self.conflicted_count += 1;
1636                if self.entry_is_staged(status_entry) != Some(false) {
1637                    self.conflicted_staged_count += 1;
1638                }
1639            } else if status_entry.status.is_created() {
1640                self.new_count += 1;
1641                if self.entry_is_staged(status_entry) != Some(false) {
1642                    self.new_staged_count += 1;
1643                }
1644            } else {
1645                self.tracked_count += 1;
1646                if self.entry_is_staged(status_entry) != Some(false) {
1647                    self.tracked_staged_count += 1;
1648                }
1649            }
1650        }
1651    }
1652
1653    fn entry_is_staged(&self, entry: &GitStatusEntry) -> Option<bool> {
1654        for pending in self.pending.iter().rev() {
1655            if pending.repo_paths.contains(&entry.repo_path) {
1656                match pending.target_status {
1657                    TargetStatus::Staged => return Some(true),
1658                    TargetStatus::Unstaged => return Some(false),
1659                    TargetStatus::Reverted => continue,
1660                    TargetStatus::Unchanged => continue,
1661                }
1662            }
1663        }
1664        entry.is_staged
1665    }
1666
1667    pub(crate) fn has_staged_changes(&self) -> bool {
1668        self.tracked_staged_count > 0
1669            || self.new_staged_count > 0
1670            || self.conflicted_staged_count > 0
1671    }
1672
1673    pub(crate) fn has_unstaged_changes(&self) -> bool {
1674        self.tracked_count > self.tracked_staged_count
1675            || self.new_count > self.new_staged_count
1676            || self.conflicted_count > self.conflicted_staged_count
1677    }
1678
1679    fn has_conflicts(&self) -> bool {
1680        self.conflicted_count > 0
1681    }
1682
1683    fn has_tracked_changes(&self) -> bool {
1684        self.tracked_count > 0
1685    }
1686
1687    pub fn has_unstaged_conflicts(&self) -> bool {
1688        self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count
1689    }
1690
1691    fn show_err_toast(&self, e: anyhow::Error, cx: &mut App) {
1692        let Some(workspace) = self.workspace.upgrade() else {
1693            return;
1694        };
1695        let notif_id = NotificationId::Named("git-operation-error".into());
1696
1697        let message = e.to_string();
1698        workspace.update(cx, |workspace, cx| {
1699            let toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| {
1700                window.dispatch_action(workspace::OpenLog.boxed_clone(), cx);
1701            });
1702            workspace.show_toast(toast, cx);
1703        });
1704    }
1705
1706    pub fn panel_button(
1707        &self,
1708        id: impl Into<SharedString>,
1709        label: impl Into<SharedString>,
1710    ) -> Button {
1711        let id = id.into().clone();
1712        let label = label.into().clone();
1713
1714        Button::new(id, label)
1715            .label_size(LabelSize::Small)
1716            .layer(ElevationIndex::ElevatedSurface)
1717            .size(ButtonSize::Compact)
1718            .style(ButtonStyle::Filled)
1719    }
1720
1721    pub fn indent_size(&self, window: &Window, cx: &mut Context<Self>) -> Pixels {
1722        Checkbox::container_size(cx).to_pixels(window.rem_size())
1723    }
1724
1725    pub fn render_divider(&self, _cx: &mut Context<Self>) -> impl IntoElement {
1726        h_flex()
1727            .items_center()
1728            .h(px(8.))
1729            .child(Divider::horizontal_dashed().color(DividerColor::Border))
1730    }
1731
1732    pub fn render_panel_header(
1733        &self,
1734        window: &mut Window,
1735        cx: &mut Context<Self>,
1736    ) -> Option<impl IntoElement> {
1737        let all_repositories = self
1738            .project
1739            .read(cx)
1740            .git_store()
1741            .read(cx)
1742            .all_repositories();
1743
1744        let has_repo_above = all_repositories.iter().any(|repo| {
1745            repo.read(cx)
1746                .repository_entry
1747                .work_directory
1748                .is_above_project()
1749        });
1750
1751        let has_visible_repo = all_repositories.len() > 0 || has_repo_above;
1752
1753        if has_visible_repo {
1754            Some(
1755                self.panel_header_container(window, cx)
1756                    .child(
1757                        Label::new("Repository")
1758                            .size(LabelSize::Small)
1759                            .color(Color::Muted),
1760                    )
1761                    .child(self.render_repository_selector(cx))
1762                    .child(div().flex_grow()) // spacer
1763                    .child(
1764                        div()
1765                            .h_flex()
1766                            .gap_1()
1767                            .children(self.render_spinner(cx))
1768                            .children(self.render_sync_button(cx))
1769                            .children(self.render_pull_button(cx))
1770                            .child(
1771                                Button::new("diff", "+/-")
1772                                    .tooltip(Tooltip::for_action_title("Open diff", &Diff))
1773                                    .on_click(|_, _, cx| {
1774                                        cx.defer(|cx| {
1775                                            cx.dispatch_action(&Diff);
1776                                        })
1777                                    }),
1778                            ),
1779                    ),
1780            )
1781        } else {
1782            None
1783        }
1784    }
1785
1786    pub fn render_spinner(&self, _cx: &mut Context<Self>) -> Option<impl IntoElement> {
1787        (!self.pending_remote_operations.borrow().is_empty()).then(|| {
1788            Icon::new(IconName::ArrowCircle)
1789                .size(IconSize::XSmall)
1790                .color(Color::Info)
1791                .with_animation(
1792                    "arrow-circle",
1793                    Animation::new(Duration::from_secs(2)).repeat(),
1794                    |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
1795                )
1796                .into_any_element()
1797        })
1798    }
1799
1800    pub fn render_sync_button(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
1801        let active_repository = self.project.read(cx).active_repository(cx);
1802        active_repository.as_ref().map(|_| {
1803            panel_filled_button("Fetch")
1804                .icon(IconName::ArrowCircle)
1805                .icon_size(IconSize::Small)
1806                .icon_color(Color::Muted)
1807                .icon_position(IconPosition::Start)
1808                .tooltip(Tooltip::for_action_title("git fetch", &git::Fetch))
1809                .on_click(
1810                    cx.listener(move |this, _, window, cx| this.fetch(&git::Fetch, window, cx)),
1811                )
1812                .into_any_element()
1813        })
1814    }
1815
1816    pub fn render_pull_button(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
1817        let active_repository = self.project.read(cx).active_repository(cx);
1818        active_repository
1819            .as_ref()
1820            .and_then(|repo| repo.read(cx).current_branch())
1821            .and_then(|branch| {
1822                branch.upstream.as_ref().map(|upstream| {
1823                    let status = &upstream.tracking;
1824
1825                    let disabled = status.is_gone();
1826
1827                    panel_filled_button(match status {
1828                        git::repository::UpstreamTracking::Tracked(status) if status.behind > 0 => {
1829                            format!("Pull ({})", status.behind)
1830                        }
1831                        _ => "Pull".to_string(),
1832                    })
1833                    .icon(IconName::ArrowDown)
1834                    .icon_size(IconSize::Small)
1835                    .icon_color(Color::Muted)
1836                    .icon_position(IconPosition::Start)
1837                    .disabled(status.is_gone())
1838                    .tooltip(move |window, cx| {
1839                        if disabled {
1840                            Tooltip::simple("Upstream is gone", cx)
1841                        } else {
1842                            // TODO: Add <origin> and <branch> argument substitutions to this
1843                            Tooltip::for_action("git pull", &git::Pull, window, cx)
1844                        }
1845                    })
1846                    .on_click(
1847                        cx.listener(move |this, _, window, cx| this.pull(&git::Pull, window, cx)),
1848                    )
1849                    .into_any_element()
1850                })
1851            })
1852    }
1853
1854    pub fn render_repository_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
1855        let active_repository = self.project.read(cx).active_repository(cx);
1856        let repository_display_name = active_repository
1857            .as_ref()
1858            .map(|repo| repo.read(cx).display_name(self.project.read(cx), cx))
1859            .unwrap_or_default();
1860
1861        RepositorySelectorPopoverMenu::new(
1862            self.repository_selector.clone(),
1863            ButtonLike::new("active-repository")
1864                .style(ButtonStyle::Subtle)
1865                .child(Label::new(repository_display_name).size(LabelSize::Small)),
1866            Tooltip::text("Select a repository"),
1867        )
1868    }
1869
1870    pub fn can_commit(&self) -> bool {
1871        (self.has_staged_changes() || self.has_tracked_changes()) && !self.has_unstaged_conflicts()
1872    }
1873
1874    pub fn can_stage_all(&self) -> bool {
1875        self.has_unstaged_changes()
1876    }
1877
1878    pub fn can_unstage_all(&self) -> bool {
1879        self.has_staged_changes()
1880    }
1881
1882    pub(crate) fn render_co_authors(&self, cx: &Context<Self>) -> Option<AnyElement> {
1883        let potential_co_authors = self.potential_co_authors(cx);
1884        if potential_co_authors.is_empty() {
1885            None
1886        } else {
1887            Some(
1888                IconButton::new("co-authors", IconName::Person)
1889                    .icon_color(Color::Disabled)
1890                    .selected_icon_color(Color::Selected)
1891                    .toggle_state(self.add_coauthors)
1892                    .tooltip(move |_, cx| {
1893                        let title = format!(
1894                            "Add co-authored-by:{}{}",
1895                            if potential_co_authors.len() == 1 {
1896                                ""
1897                            } else {
1898                                "\n"
1899                            },
1900                            potential_co_authors
1901                                .iter()
1902                                .map(|(name, email)| format!(" {} <{}>", name, email))
1903                                .join("\n")
1904                        );
1905                        Tooltip::simple(title, cx)
1906                    })
1907                    .on_click(cx.listener(|this, _, _, cx| {
1908                        this.add_coauthors = !this.add_coauthors;
1909                        cx.notify();
1910                    }))
1911                    .into_any_element(),
1912            )
1913        }
1914    }
1915
1916    pub fn render_commit_editor(
1917        &self,
1918        window: &mut Window,
1919        cx: &mut Context<Self>,
1920    ) -> impl IntoElement {
1921        let editor = self.commit_editor.clone();
1922        let can_commit = self.can_commit()
1923            && self.pending_commit.is_none()
1924            && !editor.read(cx).is_empty(cx)
1925            && self.has_write_access(cx);
1926
1927        let panel_editor_style = panel_editor_style(true, window, cx);
1928        let enable_coauthors = self.render_co_authors(cx);
1929
1930        let tooltip = if self.has_staged_changes() {
1931            "git commit"
1932        } else {
1933            "git commit --all"
1934        };
1935        let title = if self.has_staged_changes() {
1936            "Commit"
1937        } else {
1938            "Commit Tracked"
1939        };
1940        let editor_focus_handle = self.commit_editor.focus_handle(cx);
1941
1942        let commit_button = panel_filled_button(title)
1943            .tooltip(move |window, cx| {
1944                Tooltip::for_action_in(tooltip, &Commit, &editor_focus_handle, window, cx)
1945            })
1946            .disabled(!can_commit)
1947            .on_click({
1948                cx.listener(move |this, _: &ClickEvent, window, cx| this.commit_changes(window, cx))
1949            });
1950
1951        let branch = self
1952            .active_repository
1953            .as_ref()
1954            .and_then(|repo| repo.read(cx).current_branch().map(|b| b.name.clone()))
1955            .unwrap_or_else(|| "<no branch>".into());
1956
1957        let branch_selector = Button::new("branch-selector", branch)
1958            .color(Color::Muted)
1959            .style(ButtonStyle::Subtle)
1960            .icon(IconName::GitBranch)
1961            .icon_size(IconSize::Small)
1962            .icon_color(Color::Muted)
1963            .size(ButtonSize::Compact)
1964            .icon_position(IconPosition::Start)
1965            .tooltip(Tooltip::for_action_title(
1966                "Switch Branch",
1967                &zed_actions::git::Branch,
1968            ))
1969            .on_click(cx.listener(|_, _, window, cx| {
1970                window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
1971            }))
1972            .style(ButtonStyle::Transparent);
1973
1974        let footer_size = px(32.);
1975        let gap = px(16.0);
1976
1977        let max_height = window.line_height() * 6. + gap + footer_size;
1978
1979        panel_editor_container(window, cx)
1980            .id("commit-editor-container")
1981            .relative()
1982            .h(max_height)
1983            .w_full()
1984            .border_t_1()
1985            .border_color(cx.theme().colors().border)
1986            .bg(cx.theme().colors().editor_background)
1987            .cursor_text()
1988            .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
1989                window.focus(&this.commit_editor.focus_handle(cx));
1990            }))
1991            .when(!self.modal_open, |el| {
1992                el.child(EditorElement::new(&self.commit_editor, panel_editor_style))
1993                    .child(
1994                        h_flex()
1995                            .absolute()
1996                            .bottom_0()
1997                            .left_2()
1998                            .h(footer_size)
1999                            .flex_none()
2000                            .child(branch_selector),
2001                    )
2002                    .child(
2003                        h_flex()
2004                            .absolute()
2005                            .bottom_0()
2006                            .right_2()
2007                            .h(footer_size)
2008                            .flex_none()
2009                            .children(enable_coauthors)
2010                            .child(commit_button),
2011                    )
2012            })
2013    }
2014
2015    fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
2016        let active_repository = self.active_repository.as_ref()?;
2017        let branch = active_repository.read(cx).current_branch()?;
2018        let commit = branch.most_recent_commit.as_ref()?.clone();
2019
2020        let this = cx.entity();
2021        Some(
2022            h_flex()
2023                .items_center()
2024                .py_1p5()
2025                .px(px(8.))
2026                .bg(cx.theme().colors().background)
2027                .border_t_1()
2028                .border_color(cx.theme().colors().border)
2029                .gap_1p5()
2030                .child(
2031                    div()
2032                        .flex_grow()
2033                        .overflow_hidden()
2034                        .max_w(relative(0.6))
2035                        .h_full()
2036                        .child(
2037                            Label::new(commit.subject.clone())
2038                                .size(LabelSize::Small)
2039                                .text_ellipsis(),
2040                        )
2041                        .id("commit-msg-hover")
2042                        .hoverable_tooltip(move |window, cx| {
2043                            GitPanelMessageTooltip::new(
2044                                this.clone(),
2045                                commit.sha.clone(),
2046                                window,
2047                                cx,
2048                            )
2049                            .into()
2050                        }),
2051                )
2052                .child(div().flex_1())
2053                .child(
2054                    panel_filled_button("Uncommit")
2055                        .icon(IconName::Undo)
2056                        .icon_size(IconSize::Small)
2057                        .icon_color(Color::Muted)
2058                        .icon_position(IconPosition::Start)
2059                        .tooltip(Tooltip::for_action_title(
2060                            if self.has_staged_changes() {
2061                                "git reset HEAD^ --soft"
2062                            } else {
2063                                "git reset HEAD^"
2064                            },
2065                            &git::Uncommit,
2066                        ))
2067                        .on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))),
2068                )
2069                .child(self.render_push_button(branch, cx)),
2070        )
2071    }
2072
2073    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
2074        h_flex()
2075            .h_full()
2076            .flex_grow()
2077            .justify_center()
2078            .items_center()
2079            .child(
2080                v_flex()
2081                    .gap_3()
2082                    .child(if self.active_repository.is_some() {
2083                        "No changes to commit"
2084                    } else {
2085                        "No Git repositories"
2086                    })
2087                    .text_ui_sm(cx)
2088                    .mx_auto()
2089                    .text_color(Color::Placeholder.color(cx)),
2090            )
2091    }
2092
2093    fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
2094        let scroll_bar_style = self.show_scrollbar(cx);
2095        let show_container = matches!(scroll_bar_style, ShowScrollbar::Always);
2096
2097        if !self.should_show_scrollbar(cx)
2098            || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
2099        {
2100            return None;
2101        }
2102
2103        Some(
2104            div()
2105                .id("git-panel-vertical-scroll")
2106                .occlude()
2107                .flex_none()
2108                .h_full()
2109                .cursor_default()
2110                .when(show_container, |this| this.pl_1().px_1p5())
2111                .when(!show_container, |this| {
2112                    this.absolute().right_1().top_1().bottom_1().w(px(12.))
2113                })
2114                .on_mouse_move(cx.listener(|_, _, _, cx| {
2115                    cx.notify();
2116                    cx.stop_propagation()
2117                }))
2118                .on_hover(|_, _, cx| {
2119                    cx.stop_propagation();
2120                })
2121                .on_any_mouse_down(|_, _, cx| {
2122                    cx.stop_propagation();
2123                })
2124                .on_mouse_up(
2125                    MouseButton::Left,
2126                    cx.listener(|this, _, window, cx| {
2127                        if !this.scrollbar_state.is_dragging()
2128                            && !this.focus_handle.contains_focused(window, cx)
2129                        {
2130                            this.hide_scrollbar(window, cx);
2131                            cx.notify();
2132                        }
2133
2134                        cx.stop_propagation();
2135                    }),
2136                )
2137                .on_scroll_wheel(cx.listener(|_, _, _, cx| {
2138                    cx.notify();
2139                }))
2140                .children(Scrollbar::vertical(
2141                    // percentage as f32..end_offset as f32,
2142                    self.scrollbar_state.clone(),
2143                )),
2144        )
2145    }
2146
2147    pub fn render_buffer_header_controls(
2148        &self,
2149        entity: &Entity<Self>,
2150        file: &Arc<dyn File>,
2151        _: &Window,
2152        cx: &App,
2153    ) -> Option<AnyElement> {
2154        let repo = self.active_repository.as_ref()?.read(cx);
2155        let repo_path = repo.worktree_id_path_to_repo_path(file.worktree_id(cx), file.path())?;
2156        let ix = self.entries_by_path.get(&repo_path)?;
2157        let entry = self.entries.get(*ix)?;
2158
2159        let is_staged = self.entry_is_staged(entry.status_entry()?);
2160
2161        let checkbox = Checkbox::new("stage-file", is_staged.into())
2162            .disabled(!self.has_write_access(cx))
2163            .fill()
2164            .elevation(ElevationIndex::Surface)
2165            .on_click({
2166                let entry = entry.clone();
2167                let git_panel = entity.downgrade();
2168                move |_, window, cx| {
2169                    git_panel
2170                        .update(cx, |this, cx| {
2171                            this.toggle_staged_for_entry(&entry, window, cx);
2172                            cx.stop_propagation();
2173                        })
2174                        .ok();
2175                }
2176            });
2177        Some(
2178            h_flex()
2179                .id("start-slot")
2180                .text_lg()
2181                .child(checkbox)
2182                .on_mouse_down(MouseButton::Left, |_, _, cx| {
2183                    // prevent the list item active state triggering when toggling checkbox
2184                    cx.stop_propagation();
2185                })
2186                .into_any_element(),
2187        )
2188    }
2189
2190    fn render_entries(
2191        &self,
2192        has_write_access: bool,
2193        _: &Window,
2194        cx: &mut Context<Self>,
2195    ) -> impl IntoElement {
2196        let entry_count = self.entries.len();
2197
2198        v_flex()
2199            .size_full()
2200            .flex_grow()
2201            .overflow_hidden()
2202            .child(
2203                uniform_list(cx.entity().clone(), "entries", entry_count, {
2204                    move |this, range, window, cx| {
2205                        let mut items = Vec::with_capacity(range.end - range.start);
2206
2207                        for ix in range {
2208                            match &this.entries.get(ix) {
2209                                Some(GitListEntry::GitStatusEntry(entry)) => {
2210                                    items.push(this.render_entry(
2211                                        ix,
2212                                        entry,
2213                                        has_write_access,
2214                                        window,
2215                                        cx,
2216                                    ));
2217                                }
2218                                Some(GitListEntry::Header(header)) => {
2219                                    items.push(this.render_list_header(
2220                                        ix,
2221                                        header,
2222                                        has_write_access,
2223                                        window,
2224                                        cx,
2225                                    ));
2226                                }
2227                                None => {}
2228                            }
2229                        }
2230
2231                        items
2232                    }
2233                })
2234                .size_full()
2235                .with_sizing_behavior(ListSizingBehavior::Infer)
2236                .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
2237                .track_scroll(self.scroll_handle.clone()),
2238            )
2239            .on_mouse_down(
2240                MouseButton::Right,
2241                cx.listener(move |this, event: &MouseDownEvent, window, cx| {
2242                    this.deploy_panel_context_menu(event.position, window, cx)
2243                }),
2244            )
2245            .children(self.render_scrollbar(cx))
2246    }
2247
2248    fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
2249        Label::new(label.into()).color(color).single_line()
2250    }
2251
2252    fn render_list_header(
2253        &self,
2254        ix: usize,
2255        header: &GitHeaderEntry,
2256        _: bool,
2257        _: &Window,
2258        _: &Context<Self>,
2259    ) -> AnyElement {
2260        div()
2261            .w_full()
2262            .child(
2263                ListItem::new(ix)
2264                    .spacing(ListItemSpacing::Sparse)
2265                    .disabled(true)
2266                    .child(
2267                        Label::new(header.title())
2268                            .color(Color::Muted)
2269                            .size(LabelSize::Small)
2270                            .single_line(),
2271                    ),
2272            )
2273            .into_any_element()
2274    }
2275
2276    fn load_commit_details(
2277        &self,
2278        sha: &str,
2279        cx: &mut Context<Self>,
2280    ) -> Task<Result<CommitDetails>> {
2281        let Some(repo) = self.active_repository.clone() else {
2282            return Task::ready(Err(anyhow::anyhow!("no active repo")));
2283        };
2284        repo.update(cx, |repo, cx| repo.show(sha, cx))
2285    }
2286
2287    fn deploy_entry_context_menu(
2288        &mut self,
2289        position: Point<Pixels>,
2290        ix: usize,
2291        window: &mut Window,
2292        cx: &mut Context<Self>,
2293    ) {
2294        let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else {
2295            return;
2296        };
2297        let stage_title = if entry.status.is_staged() == Some(true) {
2298            "Unstage File"
2299        } else {
2300            "Stage File"
2301        };
2302        let revert_title = if entry.status.is_deleted() {
2303            "Restore file"
2304        } else if entry.status.is_created() {
2305            "Trash file"
2306        } else {
2307            "Discard changes"
2308        };
2309        let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
2310            context_menu
2311                .action(stage_title, ToggleStaged.boxed_clone())
2312                .action(revert_title, git::RestoreFile.boxed_clone())
2313                .separator()
2314                .action("Open Diff", Confirm.boxed_clone())
2315                .action("Open File", SecondaryConfirm.boxed_clone())
2316        });
2317        self.selected_entry = Some(ix);
2318        self.set_context_menu(context_menu, position, window, cx);
2319    }
2320
2321    fn deploy_panel_context_menu(
2322        &mut self,
2323        position: Point<Pixels>,
2324        window: &mut Window,
2325        cx: &mut Context<Self>,
2326    ) {
2327        let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
2328            context_menu
2329                .action("Stage All", StageAll.boxed_clone())
2330                .action("Unstage All", UnstageAll.boxed_clone())
2331                .action("Open Diff", project_diff::Diff.boxed_clone())
2332                .separator()
2333                .action("Discard Tracked Changes", RestoreTrackedFiles.boxed_clone())
2334                .action("Trash Untracked Files", TrashUntrackedFiles.boxed_clone())
2335        });
2336        self.set_context_menu(context_menu, position, window, cx);
2337    }
2338
2339    fn set_context_menu(
2340        &mut self,
2341        context_menu: Entity<ContextMenu>,
2342        position: Point<Pixels>,
2343        window: &Window,
2344        cx: &mut Context<Self>,
2345    ) {
2346        let subscription = cx.subscribe_in(
2347            &context_menu,
2348            window,
2349            |this, _, _: &DismissEvent, window, cx| {
2350                if this.context_menu.as_ref().is_some_and(|context_menu| {
2351                    context_menu.0.focus_handle(cx).contains_focused(window, cx)
2352                }) {
2353                    cx.focus_self(window);
2354                }
2355                this.context_menu.take();
2356                cx.notify();
2357            },
2358        );
2359        self.context_menu = Some((context_menu, position, subscription));
2360        cx.notify();
2361    }
2362
2363    fn render_entry(
2364        &self,
2365        ix: usize,
2366        entry: &GitStatusEntry,
2367        has_write_access: bool,
2368        window: &Window,
2369        cx: &Context<Self>,
2370    ) -> AnyElement {
2371        let display_name = entry
2372            .repo_path
2373            .file_name()
2374            .map(|name| name.to_string_lossy().into_owned())
2375            .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
2376
2377        let repo_path = entry.repo_path.clone();
2378        let selected = self.selected_entry == Some(ix);
2379        let status_style = GitPanelSettings::get_global(cx).status_style;
2380        let status = entry.status;
2381        let has_conflict = status.is_conflicted();
2382        let is_modified = status.is_modified();
2383        let is_deleted = status.is_deleted();
2384
2385        let label_color = if status_style == StatusStyle::LabelColor {
2386            if has_conflict {
2387                Color::Conflict
2388            } else if is_modified {
2389                Color::Modified
2390            } else if is_deleted {
2391                // We don't want a bunch of red labels in the list
2392                Color::Disabled
2393            } else {
2394                Color::Created
2395            }
2396        } else {
2397            Color::Default
2398        };
2399
2400        let path_color = if status.is_deleted() {
2401            Color::Disabled
2402        } else {
2403            Color::Muted
2404        };
2405
2406        let id: ElementId = ElementId::Name(format!("entry_{}", display_name).into());
2407
2408        let mut is_staged: ToggleState = self.entry_is_staged(entry).into();
2409
2410        if !self.has_staged_changes() && !self.has_conflicts() && !entry.status.is_created() {
2411            is_staged = ToggleState::Selected;
2412        }
2413
2414        let checkbox = Checkbox::new(id, is_staged)
2415            .disabled(!has_write_access)
2416            .fill()
2417            .placeholder(!self.has_staged_changes() && !self.has_conflicts())
2418            .elevation(ElevationIndex::Surface)
2419            .on_click({
2420                let entry = entry.clone();
2421                cx.listener(move |this, _, window, cx| {
2422                    this.toggle_staged_for_entry(
2423                        &GitListEntry::GitStatusEntry(entry.clone()),
2424                        window,
2425                        cx,
2426                    );
2427                    cx.stop_propagation();
2428                })
2429            });
2430
2431        let start_slot = h_flex()
2432            .id(("start-slot", ix))
2433            .gap(DynamicSpacing::Base04.rems(cx))
2434            .child(checkbox)
2435            .tooltip(|window, cx| Tooltip::for_action("Stage File", &ToggleStaged, window, cx))
2436            .child(git_status_icon(status, cx))
2437            .on_mouse_down(MouseButton::Left, |_, _, cx| {
2438                // prevent the list item active state triggering when toggling checkbox
2439                cx.stop_propagation();
2440            });
2441
2442        div()
2443            .w_full()
2444            .child(
2445                ListItem::new(ix)
2446                    .spacing(ListItemSpacing::Sparse)
2447                    .start_slot(start_slot)
2448                    .toggle_state(selected)
2449                    .focused(selected && self.focus_handle(cx).is_focused(window))
2450                    .disabled(!has_write_access)
2451                    .on_click({
2452                        cx.listener(move |this, event: &ClickEvent, window, cx| {
2453                            this.selected_entry = Some(ix);
2454                            cx.notify();
2455                            if event.modifiers().secondary() {
2456                                this.open_file(&Default::default(), window, cx)
2457                            } else {
2458                                this.open_diff(&Default::default(), window, cx);
2459                            }
2460                        })
2461                    })
2462                    .on_secondary_mouse_down(cx.listener(
2463                        move |this, event: &MouseDownEvent, window, cx| {
2464                            this.deploy_entry_context_menu(event.position, ix, window, cx);
2465                            cx.stop_propagation();
2466                        },
2467                    ))
2468                    .child(
2469                        h_flex()
2470                            .when_some(repo_path.parent(), |this, parent| {
2471                                let parent_str = parent.to_string_lossy();
2472                                if !parent_str.is_empty() {
2473                                    this.child(
2474                                        self.entry_label(format!("{}/", parent_str), path_color)
2475                                            .when(status.is_deleted(), |this| this.strikethrough()),
2476                                    )
2477                                } else {
2478                                    this
2479                                }
2480                            })
2481                            .child(
2482                                self.entry_label(display_name.clone(), label_color)
2483                                    .when(status.is_deleted(), |this| this.strikethrough()),
2484                            ),
2485                    ),
2486            )
2487            .into_any_element()
2488    }
2489
2490    fn render_push_button(&self, branch: &Branch, cx: &Context<Self>) -> AnyElement {
2491        let mut disabled = false;
2492
2493        // TODO: Add <origin> and <branch> argument substitutions to this
2494        let button: SharedString;
2495        let tooltip: SharedString;
2496        let action: Option<Push>;
2497        if let Some(upstream) = &branch.upstream {
2498            match upstream.tracking {
2499                UpstreamTracking::Gone => {
2500                    button = "Republish".into();
2501                    tooltip = "git push --set-upstream".into();
2502                    action = Some(git::Push {
2503                        options: Some(PushOptions::SetUpstream),
2504                    });
2505                }
2506                UpstreamTracking::Tracked(tracking) => {
2507                    if tracking.behind > 0 {
2508                        disabled = true;
2509                        button = "Push".into();
2510                        tooltip = "Upstream is ahead of local branch".into();
2511                        action = None;
2512                    } else if tracking.ahead > 0 {
2513                        button = format!("Push ({})", tracking.ahead).into();
2514                        tooltip = "git push".into();
2515                        action = Some(git::Push { options: None });
2516                    } else {
2517                        disabled = true;
2518                        button = "Push".into();
2519                        tooltip = "Upstream matches local branch".into();
2520                        action = None;
2521                    }
2522                }
2523            }
2524        } else {
2525            button = "Publish".into();
2526            tooltip = "git push --set-upstream".into();
2527            action = Some(git::Push {
2528                options: Some(PushOptions::SetUpstream),
2529            });
2530        };
2531
2532        panel_filled_button(button)
2533            .icon(IconName::ArrowUp)
2534            .icon_size(IconSize::Small)
2535            .icon_color(Color::Muted)
2536            .icon_position(IconPosition::Start)
2537            .disabled(disabled)
2538            .when_some(action, |this, action| {
2539                this.on_click(
2540                    cx.listener(move |this, _, window, cx| this.push(&action, window, cx)),
2541                )
2542            })
2543            .tooltip(move |window, cx| {
2544                if let Some(action) = action.as_ref() {
2545                    Tooltip::for_action(tooltip.clone(), action, window, cx)
2546                } else {
2547                    Tooltip::simple(tooltip.clone(), cx)
2548                }
2549            })
2550            .into_any_element()
2551    }
2552
2553    fn has_write_access(&self, cx: &App) -> bool {
2554        !self.project.read(cx).is_read_only(cx)
2555    }
2556}
2557
2558impl Render for GitPanel {
2559    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2560        let project = self.project.read(cx);
2561        let has_entries = self.entries.len() > 0;
2562        let room = self
2563            .workspace
2564            .upgrade()
2565            .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
2566
2567        let has_write_access = self.has_write_access(cx);
2568
2569        let has_co_authors = room.map_or(false, |room| {
2570            room.read(cx)
2571                .remote_participants()
2572                .values()
2573                .any(|remote_participant| remote_participant.can_write())
2574        });
2575
2576        v_flex()
2577            .id("git_panel")
2578            .key_context(self.dispatch_context(window, cx))
2579            .track_focus(&self.focus_handle)
2580            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
2581            .when(has_write_access && !project.is_read_only(cx), |this| {
2582                this.on_action(cx.listener(|this, &ToggleStaged, window, cx| {
2583                    this.toggle_staged_for_selected(&ToggleStaged, window, cx)
2584                }))
2585                .on_action(cx.listener(GitPanel::commit))
2586            })
2587            .on_action(cx.listener(Self::select_first))
2588            .on_action(cx.listener(Self::select_next))
2589            .on_action(cx.listener(Self::select_prev))
2590            .on_action(cx.listener(Self::select_last))
2591            .on_action(cx.listener(Self::close_panel))
2592            .on_action(cx.listener(Self::open_diff))
2593            .on_action(cx.listener(Self::open_file))
2594            .on_action(cx.listener(Self::revert_selected))
2595            .on_action(cx.listener(Self::focus_changes_list))
2596            .on_action(cx.listener(Self::focus_editor))
2597            .on_action(cx.listener(Self::toggle_staged_for_selected))
2598            .on_action(cx.listener(Self::stage_all))
2599            .on_action(cx.listener(Self::unstage_all))
2600            .on_action(cx.listener(Self::discard_tracked_changes))
2601            .on_action(cx.listener(Self::clean_all))
2602            .on_action(cx.listener(Self::fetch))
2603            .on_action(cx.listener(Self::pull))
2604            .on_action(cx.listener(Self::push))
2605            .when(has_write_access && has_co_authors, |git_panel| {
2606                git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
2607            })
2608            // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx)))
2609            .on_hover(cx.listener(|this, hovered, window, cx| {
2610                if *hovered {
2611                    this.show_scrollbar = true;
2612                    this.hide_scrollbar_task.take();
2613                    cx.notify();
2614                } else if !this.focus_handle.contains_focused(window, cx) {
2615                    this.hide_scrollbar(window, cx);
2616                }
2617            }))
2618            .size_full()
2619            .overflow_hidden()
2620            .bg(ElevationIndex::Surface.bg(cx))
2621            .child(
2622                v_flex()
2623                    .size_full()
2624                    .children(self.render_panel_header(window, cx))
2625                    .map(|this| {
2626                        if has_entries {
2627                            this.child(self.render_entries(has_write_access, window, cx))
2628                        } else {
2629                            this.child(self.render_empty_state(cx).into_any_element())
2630                        }
2631                    })
2632                    .children(self.render_previous_commit(cx))
2633                    .child(self.render_commit_editor(window, cx))
2634                    .into_any_element(),
2635            )
2636            .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2637                deferred(
2638                    anchored()
2639                        .position(*position)
2640                        .anchor(gpui::Corner::TopLeft)
2641                        .child(menu.clone()),
2642                )
2643                .with_priority(1)
2644            }))
2645    }
2646}
2647
2648impl Focusable for GitPanel {
2649    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
2650        self.focus_handle.clone()
2651    }
2652}
2653
2654impl EventEmitter<Event> for GitPanel {}
2655
2656impl EventEmitter<PanelEvent> for GitPanel {}
2657
2658pub(crate) struct GitPanelAddon {
2659    pub(crate) workspace: WeakEntity<Workspace>,
2660}
2661
2662impl editor::Addon for GitPanelAddon {
2663    fn to_any(&self) -> &dyn std::any::Any {
2664        self
2665    }
2666
2667    fn render_buffer_header_controls(
2668        &self,
2669        excerpt_info: &ExcerptInfo,
2670        window: &Window,
2671        cx: &App,
2672    ) -> Option<AnyElement> {
2673        let file = excerpt_info.buffer.file()?;
2674        let git_panel = self.workspace.upgrade()?.read(cx).panel::<GitPanel>(cx)?;
2675
2676        git_panel
2677            .read(cx)
2678            .render_buffer_header_controls(&git_panel, &file, window, cx)
2679    }
2680}
2681
2682impl Panel for GitPanel {
2683    fn persistent_name() -> &'static str {
2684        "GitPanel"
2685    }
2686
2687    fn position(&self, _: &Window, cx: &App) -> DockPosition {
2688        GitPanelSettings::get_global(cx).dock
2689    }
2690
2691    fn position_is_valid(&self, position: DockPosition) -> bool {
2692        matches!(position, DockPosition::Left | DockPosition::Right)
2693    }
2694
2695    fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
2696        settings::update_settings_file::<GitPanelSettings>(
2697            self.fs.clone(),
2698            cx,
2699            move |settings, _| settings.dock = Some(position),
2700        );
2701    }
2702
2703    fn size(&self, _: &Window, cx: &App) -> Pixels {
2704        self.width
2705            .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
2706    }
2707
2708    fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
2709        self.width = size;
2710        self.serialize(cx);
2711        cx.notify();
2712    }
2713
2714    fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
2715        Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
2716    }
2717
2718    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
2719        Some("Git Panel")
2720    }
2721
2722    fn toggle_action(&self) -> Box<dyn Action> {
2723        Box::new(ToggleFocus)
2724    }
2725
2726    fn activation_priority(&self) -> u32 {
2727        2
2728    }
2729}
2730
2731impl PanelHeader for GitPanel {}
2732
2733struct GitPanelMessageTooltip {
2734    commit_tooltip: Option<Entity<CommitTooltip>>,
2735}
2736
2737impl GitPanelMessageTooltip {
2738    fn new(
2739        git_panel: Entity<GitPanel>,
2740        sha: SharedString,
2741        window: &mut Window,
2742        cx: &mut App,
2743    ) -> Entity<Self> {
2744        cx.new(|cx| {
2745            cx.spawn_in(window, |this, mut cx| async move {
2746                let details = git_panel
2747                    .update(&mut cx, |git_panel, cx| {
2748                        git_panel.load_commit_details(&sha, cx)
2749                    })?
2750                    .await?;
2751
2752                let commit_details = editor::commit_tooltip::CommitDetails {
2753                    sha: details.sha.clone(),
2754                    committer_name: details.committer_name.clone(),
2755                    committer_email: details.committer_email.clone(),
2756                    commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
2757                    message: Some(editor::commit_tooltip::ParsedCommitMessage {
2758                        message: details.message.clone(),
2759                        ..Default::default()
2760                    }),
2761                };
2762
2763                this.update_in(&mut cx, |this: &mut GitPanelMessageTooltip, window, cx| {
2764                    this.commit_tooltip =
2765                        Some(cx.new(move |cx| CommitTooltip::new(commit_details, window, cx)));
2766                    cx.notify();
2767                })
2768            })
2769            .detach();
2770
2771            Self {
2772                commit_tooltip: None,
2773            }
2774        })
2775    }
2776}
2777
2778impl Render for GitPanelMessageTooltip {
2779    fn render(&mut self, _window: &mut Window, _cx: &mut Context<'_, Self>) -> impl IntoElement {
2780        if let Some(commit_tooltip) = &self.commit_tooltip {
2781            commit_tooltip.clone().into_any_element()
2782        } else {
2783            gpui::Empty.into_any_element()
2784        }
2785    }
2786}