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