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, 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                    ),
1845            )
1846        } else {
1847            None
1848        }
1849    }
1850
1851    pub fn render_spinner(&self, _cx: &mut Context<Self>) -> Option<impl IntoElement> {
1852        (!self.pending_remote_operations.borrow().is_empty()).then(|| {
1853            Icon::new(IconName::ArrowCircle)
1854                .size(IconSize::XSmall)
1855                .color(Color::Info)
1856                .with_animation(
1857                    "arrow-circle",
1858                    Animation::new(Duration::from_secs(2)).repeat(),
1859                    |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
1860                )
1861                .into_any_element()
1862        })
1863    }
1864
1865    pub fn render_sync_button(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
1866        let active_repository = self.project.read(cx).active_repository(cx);
1867        active_repository.as_ref().map(|_| {
1868            panel_filled_button("Fetch")
1869                .icon(IconName::ArrowCircle)
1870                .icon_size(IconSize::Small)
1871                .icon_color(Color::Muted)
1872                .icon_position(IconPosition::Start)
1873                .tooltip(Tooltip::for_action_title("git fetch", &git::Fetch))
1874                .on_click(
1875                    cx.listener(move |this, _, window, cx| this.fetch(&git::Fetch, window, cx)),
1876                )
1877                .into_any_element()
1878        })
1879    }
1880
1881    pub fn render_pull_button(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
1882        let active_repository = self.project.read(cx).active_repository(cx);
1883        active_repository
1884            .as_ref()
1885            .and_then(|repo| repo.read(cx).current_branch())
1886            .and_then(|branch| {
1887                branch.upstream.as_ref().map(|upstream| {
1888                    let status = &upstream.tracking;
1889
1890                    let disabled = status.is_gone();
1891
1892                    panel_filled_button(match status {
1893                        git::repository::UpstreamTracking::Tracked(status) if status.behind > 0 => {
1894                            format!("Pull ({})", status.behind)
1895                        }
1896                        _ => "Pull".to_string(),
1897                    })
1898                    .icon(IconName::ArrowDown)
1899                    .icon_size(IconSize::Small)
1900                    .icon_color(Color::Muted)
1901                    .icon_position(IconPosition::Start)
1902                    .disabled(status.is_gone())
1903                    .tooltip(move |window, cx| {
1904                        if disabled {
1905                            Tooltip::simple("Upstream is gone", cx)
1906                        } else {
1907                            // TODO: Add <origin> and <branch> argument substitutions to this
1908                            Tooltip::for_action("git pull", &git::Pull, window, cx)
1909                        }
1910                    })
1911                    .on_click(
1912                        cx.listener(move |this, _, window, cx| this.pull(&git::Pull, window, cx)),
1913                    )
1914                    .into_any_element()
1915                })
1916            })
1917    }
1918
1919    pub fn render_repository_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
1920        let active_repository = self.project.read(cx).active_repository(cx);
1921        let repository_display_name = active_repository
1922            .as_ref()
1923            .map(|repo| repo.read(cx).display_name(self.project.read(cx), cx))
1924            .unwrap_or_default();
1925
1926        RepositorySelectorPopoverMenu::new(
1927            self.repository_selector.clone(),
1928            ButtonLike::new("active-repository")
1929                .style(ButtonStyle::Subtle)
1930                .child(Label::new(repository_display_name).size(LabelSize::Small)),
1931            Tooltip::text("Select a repository"),
1932        )
1933    }
1934
1935    pub fn can_commit(&self) -> bool {
1936        (self.has_staged_changes() || self.has_tracked_changes()) && !self.has_unstaged_conflicts()
1937    }
1938
1939    pub fn can_stage_all(&self) -> bool {
1940        self.has_unstaged_changes()
1941    }
1942
1943    pub fn can_unstage_all(&self) -> bool {
1944        self.has_staged_changes()
1945    }
1946
1947    pub(crate) fn render_co_authors(&self, cx: &Context<Self>) -> Option<AnyElement> {
1948        let potential_co_authors = self.potential_co_authors(cx);
1949        if potential_co_authors.is_empty() {
1950            None
1951        } else {
1952            Some(
1953                IconButton::new("co-authors", IconName::Person)
1954                    .icon_color(Color::Disabled)
1955                    .selected_icon_color(Color::Selected)
1956                    .toggle_state(self.add_coauthors)
1957                    .tooltip(move |_, cx| {
1958                        let title = format!(
1959                            "Add co-authored-by:{}{}",
1960                            if potential_co_authors.len() == 1 {
1961                                ""
1962                            } else {
1963                                "\n"
1964                            },
1965                            potential_co_authors
1966                                .iter()
1967                                .map(|(name, email)| format!(" {} <{}>", name, email))
1968                                .join("\n")
1969                        );
1970                        Tooltip::simple(title, cx)
1971                    })
1972                    .on_click(cx.listener(|this, _, _, cx| {
1973                        this.add_coauthors = !this.add_coauthors;
1974                        cx.notify();
1975                    }))
1976                    .into_any_element(),
1977            )
1978        }
1979    }
1980
1981    pub fn render_commit_editor(
1982        &self,
1983        window: &mut Window,
1984        cx: &mut Context<Self>,
1985    ) -> impl IntoElement {
1986        let editor = self.commit_editor.clone();
1987        let can_commit = self.can_commit()
1988            && self.pending_commit.is_none()
1989            && !editor.read(cx).is_empty(cx)
1990            && self.has_write_access(cx);
1991
1992        let panel_editor_style = panel_editor_style(true, window, cx);
1993        let enable_coauthors = self.render_co_authors(cx);
1994
1995        let tooltip = if self.has_staged_changes() {
1996            "git commit"
1997        } else {
1998            "git commit --all"
1999        };
2000        let title = if self.has_staged_changes() {
2001            "Commit"
2002        } else {
2003            "Commit Tracked"
2004        };
2005        let editor_focus_handle = self.commit_editor.focus_handle(cx);
2006
2007        let commit_button = panel_filled_button(title)
2008            .tooltip(move |window, cx| {
2009                Tooltip::for_action_in(tooltip, &Commit, &editor_focus_handle, window, cx)
2010            })
2011            .disabled(!can_commit)
2012            .on_click({
2013                cx.listener(move |this, _: &ClickEvent, window, cx| this.commit_changes(window, cx))
2014            });
2015
2016        let branch = self
2017            .active_repository
2018            .as_ref()
2019            .and_then(|repo| repo.read(cx).current_branch().map(|b| b.name.clone()))
2020            .unwrap_or_else(|| "<no branch>".into());
2021
2022        let branch_selector = Button::new("branch-selector", branch)
2023            .color(Color::Muted)
2024            .style(ButtonStyle::Subtle)
2025            .icon(IconName::GitBranch)
2026            .icon_size(IconSize::Small)
2027            .icon_color(Color::Muted)
2028            .size(ButtonSize::Compact)
2029            .icon_position(IconPosition::Start)
2030            .tooltip(Tooltip::for_action_title(
2031                "Switch Branch",
2032                &zed_actions::git::Branch,
2033            ))
2034            .on_click(cx.listener(|_, _, window, cx| {
2035                window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
2036            }))
2037            .style(ButtonStyle::Transparent);
2038
2039        let footer_size = px(32.);
2040        let gap = px(16.0);
2041
2042        let max_height = window.line_height() * 6. + gap + footer_size;
2043
2044        panel_editor_container(window, cx)
2045            .id("commit-editor-container")
2046            .relative()
2047            .h(max_height)
2048            .w_full()
2049            .border_t_1()
2050            .border_color(cx.theme().colors().border)
2051            .bg(cx.theme().colors().editor_background)
2052            .cursor_text()
2053            .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
2054                window.focus(&this.commit_editor.focus_handle(cx));
2055            }))
2056            .when(!self.modal_open, |el| {
2057                el.child(EditorElement::new(&self.commit_editor, panel_editor_style))
2058                    .child(
2059                        h_flex()
2060                            .absolute()
2061                            .bottom_0()
2062                            .left_2()
2063                            .h(footer_size)
2064                            .flex_none()
2065                            .child(branch_selector),
2066                    )
2067                    .child(
2068                        h_flex()
2069                            .absolute()
2070                            .bottom_0()
2071                            .right_2()
2072                            .h(footer_size)
2073                            .flex_none()
2074                            .children(enable_coauthors)
2075                            .child(commit_button),
2076                    )
2077            })
2078    }
2079
2080    fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
2081        let active_repository = self.active_repository.as_ref()?;
2082        let branch = active_repository.read(cx).current_branch()?;
2083        let commit = branch.most_recent_commit.as_ref()?.clone();
2084
2085        let this = cx.entity();
2086        Some(
2087            h_flex()
2088                .items_center()
2089                .py_1p5()
2090                .px(px(8.))
2091                .bg(cx.theme().colors().background)
2092                .border_t_1()
2093                .border_color(cx.theme().colors().border)
2094                .gap_1p5()
2095                .child(
2096                    div()
2097                        .flex_grow()
2098                        .overflow_hidden()
2099                        .max_w(relative(0.6))
2100                        .h_full()
2101                        .child(
2102                            Label::new(commit.subject.clone())
2103                                .size(LabelSize::Small)
2104                                .text_ellipsis(),
2105                        )
2106                        .id("commit-msg-hover")
2107                        .hoverable_tooltip(move |window, cx| {
2108                            GitPanelMessageTooltip::new(
2109                                this.clone(),
2110                                commit.sha.clone(),
2111                                window,
2112                                cx,
2113                            )
2114                            .into()
2115                        }),
2116                )
2117                .child(div().flex_1())
2118                .child(
2119                    panel_filled_button("Uncommit")
2120                        .icon(IconName::Undo)
2121                        .icon_size(IconSize::Small)
2122                        .icon_color(Color::Muted)
2123                        .icon_position(IconPosition::Start)
2124                        .tooltip(Tooltip::for_action_title(
2125                            if self.has_staged_changes() {
2126                                "git reset HEAD^ --soft"
2127                            } else {
2128                                "git reset HEAD^"
2129                            },
2130                            &git::Uncommit,
2131                        ))
2132                        .on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))),
2133                )
2134                .child(self.render_push_button(branch, cx)),
2135        )
2136    }
2137
2138    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
2139        h_flex()
2140            .h_full()
2141            .flex_grow()
2142            .justify_center()
2143            .items_center()
2144            .child(
2145                v_flex()
2146                    .gap_3()
2147                    .child(if self.active_repository.is_some() {
2148                        "No changes to commit"
2149                    } else {
2150                        "No Git repositories"
2151                    })
2152                    .text_ui_sm(cx)
2153                    .mx_auto()
2154                    .text_color(Color::Placeholder.color(cx)),
2155            )
2156    }
2157
2158    fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
2159        let scroll_bar_style = self.show_scrollbar(cx);
2160        let show_container = matches!(scroll_bar_style, ShowScrollbar::Always);
2161
2162        if !self.should_show_scrollbar(cx)
2163            || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
2164        {
2165            return None;
2166        }
2167
2168        Some(
2169            div()
2170                .id("git-panel-vertical-scroll")
2171                .occlude()
2172                .flex_none()
2173                .h_full()
2174                .cursor_default()
2175                .when(show_container, |this| this.pl_1().px_1p5())
2176                .when(!show_container, |this| {
2177                    this.absolute().right_1().top_1().bottom_1().w(px(12.))
2178                })
2179                .on_mouse_move(cx.listener(|_, _, _, cx| {
2180                    cx.notify();
2181                    cx.stop_propagation()
2182                }))
2183                .on_hover(|_, _, cx| {
2184                    cx.stop_propagation();
2185                })
2186                .on_any_mouse_down(|_, _, cx| {
2187                    cx.stop_propagation();
2188                })
2189                .on_mouse_up(
2190                    MouseButton::Left,
2191                    cx.listener(|this, _, window, cx| {
2192                        if !this.scrollbar_state.is_dragging()
2193                            && !this.focus_handle.contains_focused(window, cx)
2194                        {
2195                            this.hide_scrollbar(window, cx);
2196                            cx.notify();
2197                        }
2198
2199                        cx.stop_propagation();
2200                    }),
2201                )
2202                .on_scroll_wheel(cx.listener(|_, _, _, cx| {
2203                    cx.notify();
2204                }))
2205                .children(Scrollbar::vertical(
2206                    // percentage as f32..end_offset as f32,
2207                    self.scrollbar_state.clone(),
2208                )),
2209        )
2210    }
2211
2212    pub fn render_buffer_header_controls(
2213        &self,
2214        entity: &Entity<Self>,
2215        file: &Arc<dyn File>,
2216        _: &Window,
2217        cx: &App,
2218    ) -> Option<AnyElement> {
2219        let repo = self.active_repository.as_ref()?.read(cx);
2220        let repo_path = repo.worktree_id_path_to_repo_path(file.worktree_id(cx), file.path())?;
2221        let ix = self.entry_by_path(&repo_path)?;
2222        let entry = self.entries.get(ix)?;
2223
2224        let is_staged = self.entry_is_staged(entry.status_entry()?);
2225
2226        let checkbox = Checkbox::new("stage-file", is_staged.into())
2227            .disabled(!self.has_write_access(cx))
2228            .fill()
2229            .elevation(ElevationIndex::Surface)
2230            .on_click({
2231                let entry = entry.clone();
2232                let git_panel = entity.downgrade();
2233                move |_, window, cx| {
2234                    git_panel
2235                        .update(cx, |this, cx| {
2236                            this.toggle_staged_for_entry(&entry, window, cx);
2237                            cx.stop_propagation();
2238                        })
2239                        .ok();
2240                }
2241            });
2242        Some(
2243            h_flex()
2244                .id("start-slot")
2245                .text_lg()
2246                .child(checkbox)
2247                .on_mouse_down(MouseButton::Left, |_, _, cx| {
2248                    // prevent the list item active state triggering when toggling checkbox
2249                    cx.stop_propagation();
2250                })
2251                .into_any_element(),
2252        )
2253    }
2254
2255    fn render_entries(
2256        &self,
2257        has_write_access: bool,
2258        _: &Window,
2259        cx: &mut Context<Self>,
2260    ) -> impl IntoElement {
2261        let entry_count = self.entries.len();
2262
2263        v_flex()
2264            .size_full()
2265            .flex_grow()
2266            .overflow_hidden()
2267            .child(
2268                uniform_list(cx.entity().clone(), "entries", entry_count, {
2269                    move |this, range, window, cx| {
2270                        let mut items = Vec::with_capacity(range.end - range.start);
2271
2272                        for ix in range {
2273                            match &this.entries.get(ix) {
2274                                Some(GitListEntry::GitStatusEntry(entry)) => {
2275                                    items.push(this.render_entry(
2276                                        ix,
2277                                        entry,
2278                                        has_write_access,
2279                                        window,
2280                                        cx,
2281                                    ));
2282                                }
2283                                Some(GitListEntry::Header(header)) => {
2284                                    items.push(this.render_list_header(
2285                                        ix,
2286                                        header,
2287                                        has_write_access,
2288                                        window,
2289                                        cx,
2290                                    ));
2291                                }
2292                                None => {}
2293                            }
2294                        }
2295
2296                        items
2297                    }
2298                })
2299                .size_full()
2300                .with_sizing_behavior(ListSizingBehavior::Infer)
2301                .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
2302                .track_scroll(self.scroll_handle.clone()),
2303            )
2304            .on_mouse_down(
2305                MouseButton::Right,
2306                cx.listener(move |this, event: &MouseDownEvent, window, cx| {
2307                    this.deploy_panel_context_menu(event.position, window, cx)
2308                }),
2309            )
2310            .children(self.render_scrollbar(cx))
2311    }
2312
2313    fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
2314        Label::new(label.into()).color(color).single_line()
2315    }
2316
2317    fn render_list_header(
2318        &self,
2319        ix: usize,
2320        header: &GitHeaderEntry,
2321        _: bool,
2322        _: &Window,
2323        _: &Context<Self>,
2324    ) -> AnyElement {
2325        div()
2326            .w_full()
2327            .child(
2328                ListItem::new(ix)
2329                    .spacing(ListItemSpacing::Sparse)
2330                    .disabled(true)
2331                    .child(
2332                        Label::new(header.title())
2333                            .color(Color::Muted)
2334                            .size(LabelSize::Small)
2335                            .single_line(),
2336                    ),
2337            )
2338            .into_any_element()
2339    }
2340
2341    fn load_commit_details(
2342        &self,
2343        sha: &str,
2344        cx: &mut Context<Self>,
2345    ) -> Task<Result<CommitDetails>> {
2346        let Some(repo) = self.active_repository.clone() else {
2347            return Task::ready(Err(anyhow::anyhow!("no active repo")));
2348        };
2349        repo.update(cx, |repo, cx| repo.show(sha, cx))
2350    }
2351
2352    fn deploy_entry_context_menu(
2353        &mut self,
2354        position: Point<Pixels>,
2355        ix: usize,
2356        window: &mut Window,
2357        cx: &mut Context<Self>,
2358    ) {
2359        let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else {
2360            return;
2361        };
2362        let stage_title = if entry.status.is_staged() == Some(true) {
2363            "Unstage File"
2364        } else {
2365            "Stage File"
2366        };
2367        let revert_title = if entry.status.is_deleted() {
2368            "Restore file"
2369        } else if entry.status.is_created() {
2370            "Trash file"
2371        } else {
2372            "Discard changes"
2373        };
2374        let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
2375            context_menu
2376                .action(stage_title, ToggleStaged.boxed_clone())
2377                .action(revert_title, git::RestoreFile.boxed_clone())
2378                .separator()
2379                .action("Open Diff", Confirm.boxed_clone())
2380                .action("Open File", SecondaryConfirm.boxed_clone())
2381        });
2382        self.selected_entry = Some(ix);
2383        self.set_context_menu(context_menu, position, window, cx);
2384    }
2385
2386    fn deploy_panel_context_menu(
2387        &mut self,
2388        position: Point<Pixels>,
2389        window: &mut Window,
2390        cx: &mut Context<Self>,
2391    ) {
2392        let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
2393            context_menu
2394                .action("Stage All", StageAll.boxed_clone())
2395                .action("Unstage All", UnstageAll.boxed_clone())
2396                .action("Open Diff", project_diff::Diff.boxed_clone())
2397                .separator()
2398                .action("Discard Tracked Changes", RestoreTrackedFiles.boxed_clone())
2399                .action("Trash Untracked Files", TrashUntrackedFiles.boxed_clone())
2400        });
2401        self.set_context_menu(context_menu, position, window, cx);
2402    }
2403
2404    fn set_context_menu(
2405        &mut self,
2406        context_menu: Entity<ContextMenu>,
2407        position: Point<Pixels>,
2408        window: &Window,
2409        cx: &mut Context<Self>,
2410    ) {
2411        let subscription = cx.subscribe_in(
2412            &context_menu,
2413            window,
2414            |this, _, _: &DismissEvent, window, cx| {
2415                if this.context_menu.as_ref().is_some_and(|context_menu| {
2416                    context_menu.0.focus_handle(cx).contains_focused(window, cx)
2417                }) {
2418                    cx.focus_self(window);
2419                }
2420                this.context_menu.take();
2421                cx.notify();
2422            },
2423        );
2424        self.context_menu = Some((context_menu, position, subscription));
2425        cx.notify();
2426    }
2427
2428    fn render_entry(
2429        &self,
2430        ix: usize,
2431        entry: &GitStatusEntry,
2432        has_write_access: bool,
2433        window: &Window,
2434        cx: &Context<Self>,
2435    ) -> AnyElement {
2436        let display_name = entry
2437            .repo_path
2438            .file_name()
2439            .map(|name| name.to_string_lossy().into_owned())
2440            .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
2441
2442        let repo_path = entry.repo_path.clone();
2443        let selected = self.selected_entry == Some(ix);
2444        let status_style = GitPanelSettings::get_global(cx).status_style;
2445        let status = entry.status;
2446        let has_conflict = status.is_conflicted();
2447        let is_modified = status.is_modified();
2448        let is_deleted = status.is_deleted();
2449
2450        let label_color = if status_style == StatusStyle::LabelColor {
2451            if has_conflict {
2452                Color::Conflict
2453            } else if is_modified {
2454                Color::Modified
2455            } else if is_deleted {
2456                // We don't want a bunch of red labels in the list
2457                Color::Disabled
2458            } else {
2459                Color::Created
2460            }
2461        } else {
2462            Color::Default
2463        };
2464
2465        let path_color = if status.is_deleted() {
2466            Color::Disabled
2467        } else {
2468            Color::Muted
2469        };
2470
2471        let id: ElementId = ElementId::Name(format!("entry_{}", display_name).into());
2472
2473        let mut is_staged: ToggleState = self.entry_is_staged(entry).into();
2474
2475        if !self.has_staged_changes() && !self.has_conflicts() && !entry.status.is_created() {
2476            is_staged = ToggleState::Selected;
2477        }
2478
2479        let checkbox = Checkbox::new(id, is_staged)
2480            .disabled(!has_write_access)
2481            .fill()
2482            .placeholder(!self.has_staged_changes() && !self.has_conflicts())
2483            .elevation(ElevationIndex::Surface)
2484            .on_click({
2485                let entry = entry.clone();
2486                cx.listener(move |this, _, window, cx| {
2487                    this.toggle_staged_for_entry(
2488                        &GitListEntry::GitStatusEntry(entry.clone()),
2489                        window,
2490                        cx,
2491                    );
2492                    cx.stop_propagation();
2493                })
2494            });
2495
2496        let start_slot =
2497            h_flex()
2498                .id(("start-slot", ix))
2499                .gap(DynamicSpacing::Base04.rems(cx))
2500                .child(checkbox.tooltip(|window, cx| {
2501                    Tooltip::for_action("Stage File", &ToggleStaged, window, cx)
2502                }))
2503                .child(git_status_icon(status, cx))
2504                .on_mouse_down(MouseButton::Left, |_, _, cx| {
2505                    // prevent the list item active state triggering when toggling checkbox
2506                    cx.stop_propagation();
2507                });
2508
2509        div()
2510            .w_full()
2511            .child(
2512                ListItem::new(ix)
2513                    .spacing(ListItemSpacing::Sparse)
2514                    .start_slot(start_slot)
2515                    .toggle_state(selected)
2516                    .focused(selected && self.focus_handle(cx).is_focused(window))
2517                    .disabled(!has_write_access)
2518                    .on_click({
2519                        cx.listener(move |this, event: &ClickEvent, window, cx| {
2520                            this.selected_entry = Some(ix);
2521                            cx.notify();
2522                            if event.modifiers().secondary() {
2523                                this.open_file(&Default::default(), window, cx)
2524                            } else {
2525                                this.open_diff(&Default::default(), window, cx);
2526                            }
2527                        })
2528                    })
2529                    .on_secondary_mouse_down(cx.listener(
2530                        move |this, event: &MouseDownEvent, window, cx| {
2531                            this.deploy_entry_context_menu(event.position, ix, window, cx);
2532                            cx.stop_propagation();
2533                        },
2534                    ))
2535                    .child(
2536                        h_flex()
2537                            .when_some(repo_path.parent(), |this, parent| {
2538                                let parent_str = parent.to_string_lossy();
2539                                if !parent_str.is_empty() {
2540                                    this.child(
2541                                        self.entry_label(format!("{}/", parent_str), path_color)
2542                                            .when(status.is_deleted(), |this| this.strikethrough()),
2543                                    )
2544                                } else {
2545                                    this
2546                                }
2547                            })
2548                            .child(
2549                                self.entry_label(display_name.clone(), label_color)
2550                                    .when(status.is_deleted(), |this| this.strikethrough()),
2551                            ),
2552                    ),
2553            )
2554            .into_any_element()
2555    }
2556
2557    fn render_push_button(&self, branch: &Branch, cx: &Context<Self>) -> AnyElement {
2558        let mut disabled = false;
2559
2560        // TODO: Add <origin> and <branch> argument substitutions to this
2561        let button: SharedString;
2562        let tooltip: SharedString;
2563        let action: Option<Push>;
2564        if let Some(upstream) = &branch.upstream {
2565            match upstream.tracking {
2566                UpstreamTracking::Gone => {
2567                    button = "Republish".into();
2568                    tooltip = "git push --set-upstream".into();
2569                    action = Some(git::Push {
2570                        options: Some(PushOptions::SetUpstream),
2571                    });
2572                }
2573                UpstreamTracking::Tracked(tracking) => {
2574                    if tracking.behind > 0 {
2575                        disabled = true;
2576                        button = "Push".into();
2577                        tooltip = "Upstream is ahead of local branch".into();
2578                        action = None;
2579                    } else if tracking.ahead > 0 {
2580                        button = format!("Push ({})", tracking.ahead).into();
2581                        tooltip = "git push".into();
2582                        action = Some(git::Push { options: None });
2583                    } else {
2584                        disabled = true;
2585                        button = "Push".into();
2586                        tooltip = "Upstream matches local branch".into();
2587                        action = None;
2588                    }
2589                }
2590            }
2591        } else {
2592            button = "Publish".into();
2593            tooltip = "git push --set-upstream".into();
2594            action = Some(git::Push {
2595                options: Some(PushOptions::SetUpstream),
2596            });
2597        };
2598
2599        panel_filled_button(button)
2600            .icon(IconName::ArrowUp)
2601            .icon_size(IconSize::Small)
2602            .icon_color(Color::Muted)
2603            .icon_position(IconPosition::Start)
2604            .disabled(disabled)
2605            .when_some(action, |this, action| {
2606                this.on_click(
2607                    cx.listener(move |this, _, window, cx| this.push(&action, window, cx)),
2608                )
2609            })
2610            .tooltip(move |window, cx| {
2611                if let Some(action) = action.as_ref() {
2612                    Tooltip::for_action(tooltip.clone(), action, window, cx)
2613                } else {
2614                    Tooltip::simple(tooltip.clone(), cx)
2615                }
2616            })
2617            .into_any_element()
2618    }
2619
2620    fn has_write_access(&self, cx: &App) -> bool {
2621        !self.project.read(cx).is_read_only(cx)
2622    }
2623}
2624
2625impl Render for GitPanel {
2626    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2627        let project = self.project.read(cx);
2628        let has_entries = self.entries.len() > 0;
2629        let room = self
2630            .workspace
2631            .upgrade()
2632            .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
2633
2634        let has_write_access = self.has_write_access(cx);
2635
2636        let has_co_authors = room.map_or(false, |room| {
2637            room.read(cx)
2638                .remote_participants()
2639                .values()
2640                .any(|remote_participant| remote_participant.can_write())
2641        });
2642
2643        v_flex()
2644            .id("git_panel")
2645            .key_context(self.dispatch_context(window, cx))
2646            .track_focus(&self.focus_handle)
2647            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
2648            .when(has_write_access && !project.is_read_only(cx), |this| {
2649                this.on_action(cx.listener(|this, &ToggleStaged, window, cx| {
2650                    this.toggle_staged_for_selected(&ToggleStaged, window, cx)
2651                }))
2652                .on_action(cx.listener(GitPanel::commit))
2653            })
2654            .on_action(cx.listener(Self::select_first))
2655            .on_action(cx.listener(Self::select_next))
2656            .on_action(cx.listener(Self::select_prev))
2657            .on_action(cx.listener(Self::select_last))
2658            .on_action(cx.listener(Self::close_panel))
2659            .on_action(cx.listener(Self::open_diff))
2660            .on_action(cx.listener(Self::open_file))
2661            .on_action(cx.listener(Self::revert_selected))
2662            .on_action(cx.listener(Self::focus_changes_list))
2663            .on_action(cx.listener(Self::focus_editor))
2664            .on_action(cx.listener(Self::toggle_staged_for_selected))
2665            .on_action(cx.listener(Self::stage_all))
2666            .on_action(cx.listener(Self::unstage_all))
2667            .on_action(cx.listener(Self::discard_tracked_changes))
2668            .on_action(cx.listener(Self::clean_all))
2669            .on_action(cx.listener(Self::fetch))
2670            .on_action(cx.listener(Self::pull))
2671            .on_action(cx.listener(Self::push))
2672            .when(has_write_access && has_co_authors, |git_panel| {
2673                git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
2674            })
2675            // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx)))
2676            .on_hover(cx.listener(|this, hovered, window, cx| {
2677                if *hovered {
2678                    this.show_scrollbar = true;
2679                    this.hide_scrollbar_task.take();
2680                    cx.notify();
2681                } else if !this.focus_handle.contains_focused(window, cx) {
2682                    this.hide_scrollbar(window, cx);
2683                }
2684            }))
2685            .size_full()
2686            .overflow_hidden()
2687            .bg(ElevationIndex::Surface.bg(cx))
2688            .child(
2689                v_flex()
2690                    .size_full()
2691                    .children(self.render_panel_header(window, cx))
2692                    .map(|this| {
2693                        if has_entries {
2694                            this.child(self.render_entries(has_write_access, window, cx))
2695                        } else {
2696                            this.child(self.render_empty_state(cx).into_any_element())
2697                        }
2698                    })
2699                    .children(self.render_previous_commit(cx))
2700                    .child(self.render_commit_editor(window, cx))
2701                    .into_any_element(),
2702            )
2703            .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2704                deferred(
2705                    anchored()
2706                        .position(*position)
2707                        .anchor(gpui::Corner::TopLeft)
2708                        .child(menu.clone()),
2709                )
2710                .with_priority(1)
2711            }))
2712    }
2713}
2714
2715impl Focusable for GitPanel {
2716    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
2717        self.focus_handle.clone()
2718    }
2719}
2720
2721impl EventEmitter<Event> for GitPanel {}
2722
2723impl EventEmitter<PanelEvent> for GitPanel {}
2724
2725pub(crate) struct GitPanelAddon {
2726    pub(crate) workspace: WeakEntity<Workspace>,
2727}
2728
2729impl editor::Addon for GitPanelAddon {
2730    fn to_any(&self) -> &dyn std::any::Any {
2731        self
2732    }
2733
2734    fn render_buffer_header_controls(
2735        &self,
2736        excerpt_info: &ExcerptInfo,
2737        window: &Window,
2738        cx: &App,
2739    ) -> Option<AnyElement> {
2740        let file = excerpt_info.buffer.file()?;
2741        let git_panel = self.workspace.upgrade()?.read(cx).panel::<GitPanel>(cx)?;
2742
2743        git_panel
2744            .read(cx)
2745            .render_buffer_header_controls(&git_panel, &file, window, cx)
2746    }
2747}
2748
2749impl Panel for GitPanel {
2750    fn persistent_name() -> &'static str {
2751        "GitPanel"
2752    }
2753
2754    fn position(&self, _: &Window, cx: &App) -> DockPosition {
2755        GitPanelSettings::get_global(cx).dock
2756    }
2757
2758    fn position_is_valid(&self, position: DockPosition) -> bool {
2759        matches!(position, DockPosition::Left | DockPosition::Right)
2760    }
2761
2762    fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
2763        settings::update_settings_file::<GitPanelSettings>(
2764            self.fs.clone(),
2765            cx,
2766            move |settings, _| settings.dock = Some(position),
2767        );
2768    }
2769
2770    fn size(&self, _: &Window, cx: &App) -> Pixels {
2771        self.width
2772            .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
2773    }
2774
2775    fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
2776        self.width = size;
2777        self.serialize(cx);
2778        cx.notify();
2779    }
2780
2781    fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
2782        Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
2783    }
2784
2785    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
2786        Some("Git Panel")
2787    }
2788
2789    fn toggle_action(&self) -> Box<dyn Action> {
2790        Box::new(ToggleFocus)
2791    }
2792
2793    fn activation_priority(&self) -> u32 {
2794        2
2795    }
2796}
2797
2798impl PanelHeader for GitPanel {}
2799
2800struct GitPanelMessageTooltip {
2801    commit_tooltip: Option<Entity<CommitTooltip>>,
2802}
2803
2804impl GitPanelMessageTooltip {
2805    fn new(
2806        git_panel: Entity<GitPanel>,
2807        sha: SharedString,
2808        window: &mut Window,
2809        cx: &mut App,
2810    ) -> Entity<Self> {
2811        cx.new(|cx| {
2812            cx.spawn_in(window, |this, mut cx| async move {
2813                let details = git_panel
2814                    .update(&mut cx, |git_panel, cx| {
2815                        git_panel.load_commit_details(&sha, cx)
2816                    })?
2817                    .await?;
2818
2819                let commit_details = editor::commit_tooltip::CommitDetails {
2820                    sha: details.sha.clone(),
2821                    committer_name: details.committer_name.clone(),
2822                    committer_email: details.committer_email.clone(),
2823                    commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
2824                    message: Some(editor::commit_tooltip::ParsedCommitMessage {
2825                        message: details.message.clone(),
2826                        ..Default::default()
2827                    }),
2828                };
2829
2830                this.update_in(&mut cx, |this: &mut GitPanelMessageTooltip, window, cx| {
2831                    this.commit_tooltip =
2832                        Some(cx.new(move |cx| CommitTooltip::new(commit_details, window, cx)));
2833                    cx.notify();
2834                })
2835            })
2836            .detach();
2837
2838            Self {
2839                commit_tooltip: None,
2840            }
2841        })
2842    }
2843}
2844
2845impl Render for GitPanelMessageTooltip {
2846    fn render(&mut self, _window: &mut Window, _cx: &mut Context<'_, Self>) -> impl IntoElement {
2847        if let Some(commit_tooltip) = &self.commit_tooltip {
2848            commit_tooltip.clone().into_any_element()
2849        } else {
2850            gpui::Empty.into_any_element()
2851        }
2852    }
2853}