git_panel.rs

   1use crate::git_panel_settings::StatusStyle;
   2use crate::repository_selector::RepositorySelectorPopoverMenu;
   3use crate::ProjectDiff;
   4use crate::{
   5    git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
   6};
   7use collections::HashMap;
   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::{CommitDetails, ResetMode};
  15use git::{repository::RepoPath, status::FileStatus, Commit, ToggleStaged};
  16use gpui::*;
  17use itertools::Itertools;
  18use language::{markdown, Buffer, File, ParsedMarkdown};
  19use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev};
  20use multi_buffer::ExcerptInfo;
  21use panel::{panel_editor_container, panel_editor_style, panel_filled_button, PanelHeader};
  22use project::{
  23    git::{GitEvent, Repository},
  24    Fs, Project, ProjectPath,
  25};
  26use serde::{Deserialize, Serialize};
  27use settings::Settings as _;
  28use std::{collections::HashSet, path::PathBuf, sync::Arc, time::Duration, usize};
  29use time::OffsetDateTime;
  30use ui::{
  31    prelude::*, ButtonLike, Checkbox, Divider, DividerColor, ElevationIndex, IndentGuideColors,
  32    ListItem, ListItemSpacing, Scrollbar, ScrollbarState, Tooltip,
  33};
  34use util::{maybe, ResultExt, TryFutureExt};
  35use workspace::{
  36    dock::{DockPosition, Panel, PanelEvent},
  37    notifications::{DetachAndPromptErr, NotificationId},
  38    Toast, Workspace,
  39};
  40
  41actions!(
  42    git_panel,
  43    [
  44        Close,
  45        ToggleFocus,
  46        OpenMenu,
  47        FocusEditor,
  48        FocusChanges,
  49        ToggleFillCoAuthors,
  50    ]
  51);
  52
  53const GIT_PANEL_KEY: &str = "GitPanel";
  54
  55const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
  56
  57pub fn init(cx: &mut App) {
  58    cx.observe_new(
  59        |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
  60            workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
  61                workspace.toggle_panel_focus::<GitPanel>(window, cx);
  62            });
  63
  64            workspace.register_action(|workspace, _: &Commit, window, cx| {
  65                workspace.open_panel::<GitPanel>(window, cx);
  66                if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
  67                    git_panel
  68                        .read(cx)
  69                        .commit_editor
  70                        .focus_handle(cx)
  71                        .focus(window);
  72                }
  73            });
  74        },
  75    )
  76    .detach();
  77}
  78
  79#[derive(Debug, Clone)]
  80pub enum Event {
  81    Focus,
  82    OpenedEntry { path: ProjectPath },
  83}
  84
  85#[derive(Serialize, Deserialize)]
  86struct SerializedGitPanel {
  87    width: Option<Pixels>,
  88}
  89
  90#[derive(Debug, PartialEq, Eq, Clone, Copy)]
  91enum Section {
  92    Conflict,
  93    Tracked,
  94    New,
  95}
  96
  97#[derive(Debug, PartialEq, Eq, Clone)]
  98struct GitHeaderEntry {
  99    header: Section,
 100}
 101
 102impl GitHeaderEntry {
 103    pub fn contains(&self, status_entry: &GitStatusEntry, repo: &Repository) -> bool {
 104        let this = &self.header;
 105        let status = status_entry.status;
 106        match this {
 107            Section::Conflict => repo.has_conflict(&status_entry.repo_path),
 108            Section::Tracked => !status.is_created(),
 109            Section::New => status.is_created(),
 110        }
 111    }
 112    pub fn title(&self) -> &'static str {
 113        match self.header {
 114            Section::Conflict => "Conflicts",
 115            Section::Tracked => "Changed",
 116            Section::New => "New",
 117        }
 118    }
 119}
 120
 121#[derive(Debug, PartialEq, Eq, Clone)]
 122enum GitListEntry {
 123    GitStatusEntry(GitStatusEntry),
 124    Header(GitHeaderEntry),
 125}
 126
 127impl GitListEntry {
 128    fn status_entry(&self) -> Option<&GitStatusEntry> {
 129        match self {
 130            GitListEntry::GitStatusEntry(entry) => Some(entry),
 131            _ => None,
 132        }
 133    }
 134}
 135
 136#[derive(Debug, PartialEq, Eq, Clone)]
 137pub struct GitStatusEntry {
 138    pub(crate) depth: usize,
 139    pub(crate) display_name: String,
 140    pub(crate) repo_path: RepoPath,
 141    pub(crate) status: FileStatus,
 142    pub(crate) is_staged: Option<bool>,
 143}
 144
 145struct PendingOperation {
 146    finished: bool,
 147    will_become_staged: bool,
 148    repo_paths: HashSet<RepoPath>,
 149    op_id: usize,
 150}
 151
 152pub struct GitPanel {
 153    active_repository: Option<Entity<Repository>>,
 154    commit_editor: Entity<Editor>,
 155    conflicted_count: usize,
 156    conflicted_staged_count: usize,
 157    current_modifiers: Modifiers,
 158    add_coauthors: bool,
 159    entries: Vec<GitListEntry>,
 160    entries_by_path: collections::HashMap<RepoPath, usize>,
 161    focus_handle: FocusHandle,
 162    fs: Arc<dyn Fs>,
 163    hide_scrollbar_task: Option<Task<()>>,
 164    new_count: usize,
 165    new_staged_count: usize,
 166    pending: Vec<PendingOperation>,
 167    pending_commit: Option<Task<()>>,
 168    pending_serialization: Task<Option<()>>,
 169    project: Entity<Project>,
 170    repository_selector: Entity<RepositorySelector>,
 171    scroll_handle: UniformListScrollHandle,
 172    scrollbar_state: ScrollbarState,
 173    selected_entry: Option<usize>,
 174    show_scrollbar: bool,
 175    tracked_count: usize,
 176    tracked_staged_count: usize,
 177    update_visible_entries_task: Task<()>,
 178    width: Option<Pixels>,
 179    workspace: WeakEntity<Workspace>,
 180}
 181
 182fn commit_message_editor(
 183    commit_message_buffer: Entity<Buffer>,
 184    project: Entity<Project>,
 185    window: &mut Window,
 186    cx: &mut Context<'_, Editor>,
 187) -> Editor {
 188    let buffer = cx.new(|cx| MultiBuffer::singleton(commit_message_buffer, cx));
 189    let mut commit_editor = Editor::new(
 190        EditorMode::AutoHeight { max_lines: 6 },
 191        buffer,
 192        None,
 193        false,
 194        window,
 195        cx,
 196    );
 197    commit_editor.set_collaboration_hub(Box::new(project));
 198    commit_editor.set_use_autoclose(false);
 199    commit_editor.set_show_gutter(false, cx);
 200    commit_editor.set_show_wrap_guides(false, cx);
 201    commit_editor.set_show_indent_guides(false, cx);
 202    commit_editor.set_placeholder_text("Enter commit message", cx);
 203    commit_editor
 204}
 205
 206impl GitPanel {
 207    pub fn new(
 208        workspace: &mut Workspace,
 209        window: &mut Window,
 210        cx: &mut Context<Workspace>,
 211    ) -> Entity<Self> {
 212        let fs = workspace.app_state().fs.clone();
 213        let project = workspace.project().clone();
 214        let git_store = project.read(cx).git_store().clone();
 215        let active_repository = project.read(cx).active_repository(cx);
 216        let workspace = cx.entity().downgrade();
 217
 218        let git_panel = cx.new(|cx| {
 219            let focus_handle = cx.focus_handle();
 220            cx.on_focus(&focus_handle, window, Self::focus_in).detach();
 221            cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
 222                this.hide_scrollbar(window, cx);
 223            })
 224            .detach();
 225
 226            // just to let us render a placeholder editor.
 227            // Once the active git repo is set, this buffer will be replaced.
 228            let temporary_buffer = cx.new(|cx| Buffer::local("", cx));
 229            let commit_editor =
 230                cx.new(|cx| commit_message_editor(temporary_buffer, project.clone(), window, cx));
 231            commit_editor.update(cx, |editor, cx| {
 232                editor.clear(window, cx);
 233            });
 234
 235            let scroll_handle = UniformListScrollHandle::new();
 236
 237            cx.subscribe_in(
 238                &git_store,
 239                window,
 240                move |this, git_store, event, window, cx| match event {
 241                    GitEvent::FileSystemUpdated => {
 242                        this.schedule_update(false, window, cx);
 243                    }
 244                    GitEvent::ActiveRepositoryChanged | GitEvent::GitStateUpdated => {
 245                        this.active_repository = git_store.read(cx).active_repository();
 246                        this.schedule_update(true, window, cx);
 247                    }
 248                },
 249            )
 250            .detach();
 251
 252            let scrollbar_state =
 253                ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity());
 254
 255            let repository_selector =
 256                cx.new(|cx| RepositorySelector::new(project.clone(), window, cx));
 257
 258            let mut git_panel = Self {
 259                active_repository,
 260                commit_editor,
 261                conflicted_count: 0,
 262                conflicted_staged_count: 0,
 263                current_modifiers: window.modifiers(),
 264                add_coauthors: true,
 265                entries: Vec::new(),
 266                entries_by_path: HashMap::default(),
 267                focus_handle: cx.focus_handle(),
 268                fs,
 269                hide_scrollbar_task: None,
 270                new_count: 0,
 271                new_staged_count: 0,
 272                pending: Vec::new(),
 273                pending_commit: None,
 274                pending_serialization: Task::ready(None),
 275                project,
 276                repository_selector,
 277                scroll_handle,
 278                scrollbar_state,
 279                selected_entry: None,
 280                show_scrollbar: false,
 281                tracked_count: 0,
 282                tracked_staged_count: 0,
 283                update_visible_entries_task: Task::ready(()),
 284                width: Some(px(360.)),
 285                workspace,
 286            };
 287            git_panel.schedule_update(false, window, cx);
 288            git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx);
 289            git_panel
 290        });
 291
 292        cx.subscribe_in(
 293            &git_panel,
 294            window,
 295            move |workspace, _, event: &Event, window, cx| match event.clone() {
 296                Event::OpenedEntry { path } => {
 297                    workspace
 298                        .open_path_preview(path, None, false, false, window, cx)
 299                        .detach_and_prompt_err("Failed to open file", window, cx, |e, _, _| {
 300                            Some(format!("{e}"))
 301                        });
 302                }
 303                Event::Focus => { /* TODO */ }
 304            },
 305        )
 306        .detach();
 307
 308        git_panel
 309    }
 310
 311    pub fn select_entry_by_path(
 312        &mut self,
 313        path: ProjectPath,
 314        _: &mut Window,
 315        cx: &mut Context<Self>,
 316    ) {
 317        let Some(git_repo) = self.active_repository.as_ref() else {
 318            return;
 319        };
 320        let Some(repo_path) = git_repo.read(cx).project_path_to_repo_path(&path) else {
 321            return;
 322        };
 323        let Some(ix) = self.entries_by_path.get(&repo_path) else {
 324            return;
 325        };
 326        self.selected_entry = Some(*ix);
 327        cx.notify();
 328    }
 329
 330    fn serialize(&mut self, cx: &mut Context<Self>) {
 331        let width = self.width;
 332        self.pending_serialization = cx.background_executor().spawn(
 333            async move {
 334                KEY_VALUE_STORE
 335                    .write_kvp(
 336                        GIT_PANEL_KEY.into(),
 337                        serde_json::to_string(&SerializedGitPanel { width })?,
 338                    )
 339                    .await?;
 340                anyhow::Ok(())
 341            }
 342            .log_err(),
 343        );
 344    }
 345
 346    fn dispatch_context(&self, window: &mut Window, cx: &Context<Self>) -> KeyContext {
 347        let mut dispatch_context = KeyContext::new_with_defaults();
 348        dispatch_context.add("GitPanel");
 349
 350        if self.is_focused(window, cx) {
 351            dispatch_context.add("menu");
 352            dispatch_context.add("ChangesList");
 353        }
 354
 355        if self.commit_editor.read(cx).is_focused(window) {
 356            dispatch_context.add("CommitEditor");
 357        }
 358
 359        dispatch_context
 360    }
 361
 362    fn is_focused(&self, window: &Window, cx: &Context<Self>) -> bool {
 363        window
 364            .focused(cx)
 365            .map_or(false, |focused| self.focus_handle == focused)
 366    }
 367
 368    fn close_panel(&mut self, _: &Close, _window: &mut Window, cx: &mut Context<Self>) {
 369        cx.emit(PanelEvent::Close);
 370    }
 371
 372    fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 373        if !self.focus_handle.contains_focused(window, cx) {
 374            cx.emit(Event::Focus);
 375        }
 376    }
 377
 378    fn show_scrollbar(&self, cx: &mut Context<Self>) -> ShowScrollbar {
 379        GitPanelSettings::get_global(cx)
 380            .scrollbar
 381            .show
 382            .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show)
 383    }
 384
 385    fn should_show_scrollbar(&self, cx: &mut Context<Self>) -> bool {
 386        let show = self.show_scrollbar(cx);
 387        match show {
 388            ShowScrollbar::Auto => true,
 389            ShowScrollbar::System => true,
 390            ShowScrollbar::Always => true,
 391            ShowScrollbar::Never => false,
 392        }
 393    }
 394
 395    fn should_autohide_scrollbar(&self, cx: &mut Context<Self>) -> bool {
 396        let show = self.show_scrollbar(cx);
 397        match show {
 398            ShowScrollbar::Auto => true,
 399            ShowScrollbar::System => cx
 400                .try_global::<ScrollbarAutoHide>()
 401                .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
 402            ShowScrollbar::Always => false,
 403            ShowScrollbar::Never => true,
 404        }
 405    }
 406
 407    fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 408        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
 409        if !self.should_autohide_scrollbar(cx) {
 410            return;
 411        }
 412        self.hide_scrollbar_task = Some(cx.spawn_in(window, |panel, mut cx| async move {
 413            cx.background_executor()
 414                .timer(SCROLLBAR_SHOW_INTERVAL)
 415                .await;
 416            panel
 417                .update(&mut cx, |panel, cx| {
 418                    panel.show_scrollbar = false;
 419                    cx.notify();
 420                })
 421                .log_err();
 422        }))
 423    }
 424
 425    fn handle_modifiers_changed(
 426        &mut self,
 427        event: &ModifiersChangedEvent,
 428        _: &mut Window,
 429        cx: &mut Context<Self>,
 430    ) {
 431        self.current_modifiers = event.modifiers;
 432        cx.notify();
 433    }
 434
 435    fn calculate_depth_and_difference(
 436        repo_path: &RepoPath,
 437        visible_entries: &HashSet<RepoPath>,
 438    ) -> (usize, usize) {
 439        let ancestors = repo_path.ancestors().skip(1);
 440        for ancestor in ancestors {
 441            if let Some(parent_entry) = visible_entries.get(ancestor) {
 442                let entry_component_count = repo_path.components().count();
 443                let parent_component_count = parent_entry.components().count();
 444
 445                let difference = entry_component_count - parent_component_count;
 446
 447                let parent_depth = parent_entry
 448                    .ancestors()
 449                    .skip(1) // Skip the parent itself
 450                    .filter(|ancestor| visible_entries.contains(*ancestor))
 451                    .count();
 452
 453                return (parent_depth + 1, difference);
 454            }
 455        }
 456
 457        (0, 0)
 458    }
 459
 460    fn scroll_to_selected_entry(&mut self, cx: &mut Context<Self>) {
 461        if let Some(selected_entry) = self.selected_entry {
 462            self.scroll_handle
 463                .scroll_to_item(selected_entry, ScrollStrategy::Center);
 464        }
 465
 466        cx.notify();
 467    }
 468
 469    fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
 470        if self.entries.first().is_some() {
 471            self.selected_entry = Some(0);
 472            self.scroll_to_selected_entry(cx);
 473        }
 474    }
 475
 476    fn select_prev(&mut self, _: &SelectPrev, _window: &mut Window, cx: &mut Context<Self>) {
 477        let item_count = self.entries.len();
 478        if item_count == 0 {
 479            return;
 480        }
 481
 482        if let Some(selected_entry) = self.selected_entry {
 483            let new_selected_entry = if selected_entry > 0 {
 484                selected_entry - 1
 485            } else {
 486                selected_entry
 487            };
 488
 489            self.selected_entry = Some(new_selected_entry);
 490
 491            self.scroll_to_selected_entry(cx);
 492        }
 493
 494        cx.notify();
 495    }
 496
 497    fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
 498        let item_count = self.entries.len();
 499        if item_count == 0 {
 500            return;
 501        }
 502
 503        if let Some(selected_entry) = self.selected_entry {
 504            let new_selected_entry = if selected_entry < item_count - 1 {
 505                selected_entry + 1
 506            } else {
 507                selected_entry
 508            };
 509
 510            self.selected_entry = Some(new_selected_entry);
 511
 512            self.scroll_to_selected_entry(cx);
 513        }
 514
 515        cx.notify();
 516    }
 517
 518    fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
 519        if self.entries.last().is_some() {
 520            self.selected_entry = Some(self.entries.len() - 1);
 521            self.scroll_to_selected_entry(cx);
 522        }
 523    }
 524
 525    fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
 526        self.commit_editor.update(cx, |editor, cx| {
 527            window.focus(&editor.focus_handle(cx));
 528        });
 529        cx.notify();
 530    }
 531
 532    fn select_first_entry_if_none(&mut self, cx: &mut Context<Self>) {
 533        let have_entries = self
 534            .active_repository
 535            .as_ref()
 536            .map_or(false, |active_repository| {
 537                active_repository.read(cx).entry_count() > 0
 538            });
 539        if have_entries && self.selected_entry.is_none() {
 540            self.selected_entry = Some(0);
 541            self.scroll_to_selected_entry(cx);
 542            cx.notify();
 543        }
 544    }
 545
 546    fn focus_changes_list(
 547        &mut self,
 548        _: &FocusChanges,
 549        window: &mut Window,
 550        cx: &mut Context<Self>,
 551    ) {
 552        self.select_first_entry_if_none(cx);
 553
 554        cx.focus_self(window);
 555        cx.notify();
 556    }
 557
 558    fn get_selected_entry(&self) -> Option<&GitListEntry> {
 559        self.selected_entry.and_then(|i| self.entries.get(i))
 560    }
 561
 562    fn open_selected(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
 563        maybe!({
 564            let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
 565
 566            self.workspace
 567                .update(cx, |workspace, cx| {
 568                    ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
 569                })
 570                .ok()
 571        });
 572        self.focus_handle.focus(window);
 573    }
 574
 575    fn toggle_staged_for_entry(
 576        &mut self,
 577        entry: &GitListEntry,
 578        _window: &mut Window,
 579        cx: &mut Context<Self>,
 580    ) {
 581        let Some(active_repository) = self.active_repository.as_ref() else {
 582            return;
 583        };
 584        let (stage, repo_paths) = match entry {
 585            GitListEntry::GitStatusEntry(status_entry) => {
 586                if status_entry.status.is_staged().unwrap_or(false) {
 587                    (false, vec![status_entry.repo_path.clone()])
 588                } else {
 589                    (true, vec![status_entry.repo_path.clone()])
 590                }
 591            }
 592            GitListEntry::Header(section) => {
 593                let goal_staged_state = !self.header_state(section.header).selected();
 594                let repository = active_repository.read(cx);
 595                let entries = self
 596                    .entries
 597                    .iter()
 598                    .filter_map(|entry| entry.status_entry())
 599                    .filter(|status_entry| {
 600                        section.contains(&status_entry, repository)
 601                            && status_entry.is_staged != Some(goal_staged_state)
 602                    })
 603                    .map(|status_entry| status_entry.repo_path.clone())
 604                    .collect::<Vec<_>>();
 605
 606                (goal_staged_state, entries)
 607            }
 608        };
 609
 610        let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1;
 611        self.pending.push(PendingOperation {
 612            op_id,
 613            will_become_staged: stage,
 614            repo_paths: repo_paths.iter().cloned().collect(),
 615            finished: false,
 616        });
 617        let repo_paths = repo_paths.clone();
 618        let active_repository = active_repository.clone();
 619        let repository = active_repository.read(cx);
 620        self.update_counts(repository);
 621        cx.notify();
 622
 623        cx.spawn({
 624            |this, mut cx| async move {
 625                let result = cx
 626                    .update(|cx| {
 627                        if stage {
 628                            active_repository.read(cx).stage_entries(repo_paths.clone())
 629                        } else {
 630                            active_repository
 631                                .read(cx)
 632                                .unstage_entries(repo_paths.clone())
 633                        }
 634                    })?
 635                    .await?;
 636
 637                this.update(&mut cx, |this, cx| {
 638                    for pending in this.pending.iter_mut() {
 639                        if pending.op_id == op_id {
 640                            pending.finished = true
 641                        }
 642                    }
 643                    result
 644                        .map_err(|e| {
 645                            this.show_err_toast(e, cx);
 646                        })
 647                        .ok();
 648                    cx.notify();
 649                })
 650            }
 651        })
 652        .detach();
 653    }
 654
 655    fn toggle_staged_for_selected(
 656        &mut self,
 657        _: &git::ToggleStaged,
 658        window: &mut Window,
 659        cx: &mut Context<Self>,
 660    ) {
 661        if let Some(selected_entry) = self.get_selected_entry().cloned() {
 662            self.toggle_staged_for_entry(&selected_entry, window, cx);
 663        }
 664    }
 665
 666    /// Commit all staged changes
 667    fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
 668        let editor = self.commit_editor.read(cx);
 669        if editor.is_empty(cx) {
 670            if !editor.focus_handle(cx).contains_focused(window, cx) {
 671                editor.focus_handle(cx).focus(window);
 672                return;
 673            }
 674        }
 675
 676        self.commit_changes(window, cx)
 677    }
 678
 679    fn commit_changes(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 680        let Some(active_repository) = self.active_repository.clone() else {
 681            return;
 682        };
 683        let error_spawn = |message, window: &mut Window, cx: &mut App| {
 684            let prompt = window.prompt(PromptLevel::Warning, message, None, &["Ok"], cx);
 685            cx.spawn(|_| async move {
 686                prompt.await.ok();
 687            })
 688            .detach();
 689        };
 690
 691        if self.has_unstaged_conflicts() {
 692            error_spawn(
 693                "There are still conflicts. You must stage these before committing",
 694                window,
 695                cx,
 696            );
 697            return;
 698        }
 699
 700        let mut message = self.commit_editor.read(cx).text(cx);
 701        if message.trim().is_empty() {
 702            self.commit_editor.read(cx).focus_handle(cx).focus(window);
 703            return;
 704        }
 705        if self.add_coauthors {
 706            self.fill_co_authors(&mut message, cx);
 707        }
 708
 709        let task = if self.has_staged_changes() {
 710            // Repository serializes all git operations, so we can just send a commit immediately
 711            let commit_task = active_repository.read(cx).commit(message.into(), None);
 712            cx.background_executor()
 713                .spawn(async move { commit_task.await? })
 714        } else {
 715            let changed_files = self
 716                .entries
 717                .iter()
 718                .filter_map(|entry| entry.status_entry())
 719                .filter(|status_entry| !status_entry.status.is_created())
 720                .map(|status_entry| status_entry.repo_path.clone())
 721                .collect::<Vec<_>>();
 722
 723            if changed_files.is_empty() {
 724                error_spawn("No changes to commit", window, cx);
 725                return;
 726            }
 727
 728            let stage_task = active_repository.read(cx).stage_entries(changed_files);
 729            cx.spawn(|_, mut cx| async move {
 730                stage_task.await??;
 731                let commit_task = active_repository
 732                    .update(&mut cx, |repo, _| repo.commit(message.into(), None))?;
 733                commit_task.await?
 734            })
 735        };
 736        let task = cx.spawn_in(window, |this, mut cx| async move {
 737            let result = task.await;
 738            this.update_in(&mut cx, |this, window, cx| {
 739                this.pending_commit.take();
 740                match result {
 741                    Ok(()) => {
 742                        this.commit_editor
 743                            .update(cx, |editor, cx| editor.clear(window, cx));
 744                    }
 745                    Err(e) => this.show_err_toast(e, cx),
 746                }
 747            })
 748            .ok();
 749        });
 750
 751        self.pending_commit = Some(task);
 752    }
 753
 754    fn uncommit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 755        let Some(repo) = self.active_repository.clone() else {
 756            return;
 757        };
 758        let prior_head = self.load_commit_details("HEAD", cx);
 759
 760        let task = cx.spawn(|_, mut cx| async move {
 761            let prior_head = prior_head.await?;
 762
 763            repo.update(&mut cx, |repo, _| repo.reset("HEAD^", ResetMode::Soft))?
 764                .await??;
 765
 766            Ok(prior_head)
 767        });
 768
 769        let task = cx.spawn_in(window, |this, mut cx| async move {
 770            let result = task.await;
 771            this.update_in(&mut cx, |this, window, cx| {
 772                this.pending_commit.take();
 773                match result {
 774                    Ok(prior_commit) => {
 775                        this.commit_editor.update(cx, |editor, cx| {
 776                            editor.set_text(prior_commit.message, window, cx)
 777                        });
 778                    }
 779                    Err(e) => this.show_err_toast(e, cx),
 780                }
 781            })
 782            .ok();
 783        });
 784
 785        self.pending_commit = Some(task);
 786    }
 787
 788    fn potential_co_authors(&self, cx: &App) -> Vec<(String, String)> {
 789        let mut new_co_authors = Vec::new();
 790        let project = self.project.read(cx);
 791
 792        let Some(room) = self
 793            .workspace
 794            .upgrade()
 795            .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned())
 796        else {
 797            return Vec::default();
 798        };
 799
 800        let room = room.read(cx);
 801
 802        for (peer_id, collaborator) in project.collaborators() {
 803            if collaborator.is_host {
 804                continue;
 805            }
 806
 807            let Some(participant) = room.remote_participant_for_peer_id(*peer_id) else {
 808                continue;
 809            };
 810            if participant.can_write() && participant.user.email.is_some() {
 811                let email = participant.user.email.clone().unwrap();
 812
 813                new_co_authors.push((
 814                    participant
 815                        .user
 816                        .name
 817                        .clone()
 818                        .unwrap_or_else(|| participant.user.github_login.clone()),
 819                    email,
 820                ))
 821            }
 822        }
 823        if !project.is_local() && !project.is_read_only(cx) {
 824            if let Some(user) = room.local_participant_user(cx) {
 825                if let Some(email) = user.email.clone() {
 826                    new_co_authors.push((
 827                        user.name
 828                            .clone()
 829                            .unwrap_or_else(|| user.github_login.clone()),
 830                        email.clone(),
 831                    ))
 832                }
 833            }
 834        }
 835        new_co_authors
 836    }
 837
 838    fn toggle_fill_co_authors(
 839        &mut self,
 840        _: &ToggleFillCoAuthors,
 841        _: &mut Window,
 842        cx: &mut Context<Self>,
 843    ) {
 844        self.add_coauthors = !self.add_coauthors;
 845        cx.notify();
 846    }
 847
 848    fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context<Self>) {
 849        const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
 850
 851        let existing_text = message.to_ascii_lowercase();
 852        let lowercase_co_author_prefix = CO_AUTHOR_PREFIX.to_lowercase();
 853        let mut ends_with_co_authors = false;
 854        let existing_co_authors = existing_text
 855            .lines()
 856            .filter_map(|line| {
 857                let line = line.trim();
 858                if line.starts_with(&lowercase_co_author_prefix) {
 859                    ends_with_co_authors = true;
 860                    Some(line)
 861                } else {
 862                    ends_with_co_authors = false;
 863                    None
 864                }
 865            })
 866            .collect::<HashSet<_>>();
 867
 868        let new_co_authors = self
 869            .potential_co_authors(cx)
 870            .into_iter()
 871            .filter(|(_, email)| {
 872                !existing_co_authors
 873                    .iter()
 874                    .any(|existing| existing.contains(email.as_str()))
 875            })
 876            .collect::<Vec<_>>();
 877
 878        if new_co_authors.is_empty() {
 879            return;
 880        }
 881
 882        if !ends_with_co_authors {
 883            message.push('\n');
 884        }
 885        for (name, email) in new_co_authors {
 886            message.push('\n');
 887            message.push_str(CO_AUTHOR_PREFIX);
 888            message.push_str(&name);
 889            message.push_str(" <");
 890            message.push_str(&email);
 891            message.push('>');
 892        }
 893        message.push('\n');
 894    }
 895
 896    fn schedule_update(
 897        &mut self,
 898        clear_pending: bool,
 899        window: &mut Window,
 900        cx: &mut Context<Self>,
 901    ) {
 902        let handle = cx.entity().downgrade();
 903        self.reopen_commit_buffer(window, cx);
 904        self.update_visible_entries_task = cx.spawn_in(window, |_, mut cx| async move {
 905            cx.background_executor().timer(UPDATE_DEBOUNCE).await;
 906            if let Some(git_panel) = handle.upgrade() {
 907                git_panel
 908                    .update_in(&mut cx, |git_panel, _, cx| {
 909                        if clear_pending {
 910                            git_panel.clear_pending();
 911                        }
 912                        git_panel.update_visible_entries(cx);
 913                    })
 914                    .ok();
 915            }
 916        });
 917    }
 918
 919    fn reopen_commit_buffer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 920        let Some(active_repo) = self.active_repository.as_ref() else {
 921            return;
 922        };
 923        let load_buffer = active_repo.update(cx, |active_repo, cx| {
 924            let project = self.project.read(cx);
 925            active_repo.open_commit_buffer(
 926                Some(project.languages().clone()),
 927                project.buffer_store().clone(),
 928                cx,
 929            )
 930        });
 931
 932        cx.spawn_in(window, |git_panel, mut cx| async move {
 933            let buffer = load_buffer.await?;
 934            git_panel.update_in(&mut cx, |git_panel, window, cx| {
 935                if git_panel
 936                    .commit_editor
 937                    .read(cx)
 938                    .buffer()
 939                    .read(cx)
 940                    .as_singleton()
 941                    .as_ref()
 942                    != Some(&buffer)
 943                {
 944                    git_panel.commit_editor = cx.new(|cx| {
 945                        commit_message_editor(buffer, git_panel.project.clone(), window, cx)
 946                    });
 947                }
 948            })
 949        })
 950        .detach_and_log_err(cx);
 951    }
 952
 953    fn clear_pending(&mut self) {
 954        self.pending.retain(|v| !v.finished)
 955    }
 956
 957    fn update_visible_entries(&mut self, cx: &mut Context<Self>) {
 958        self.entries.clear();
 959        self.entries_by_path.clear();
 960        let mut changed_entries = Vec::new();
 961        let mut new_entries = Vec::new();
 962        let mut conflict_entries = Vec::new();
 963
 964        let Some(repo) = self.active_repository.as_ref() else {
 965            // Just clear entries if no repository is active.
 966            cx.notify();
 967            return;
 968        };
 969
 970        // First pass - collect all paths
 971        let repo = repo.read(cx);
 972        let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path));
 973
 974        // Second pass - create entries with proper depth calculation
 975        for entry in repo.status() {
 976            let (depth, difference) =
 977                Self::calculate_depth_and_difference(&entry.repo_path, &path_set);
 978
 979            let is_conflict = repo.has_conflict(&entry.repo_path);
 980            let is_new = entry.status.is_created();
 981            let is_staged = entry.status.is_staged();
 982
 983            let display_name = if difference > 1 {
 984                // Show partial path for deeply nested files
 985                entry
 986                    .repo_path
 987                    .as_ref()
 988                    .iter()
 989                    .skip(entry.repo_path.components().count() - difference)
 990                    .collect::<PathBuf>()
 991                    .to_string_lossy()
 992                    .into_owned()
 993            } else {
 994                // Just show filename
 995                entry
 996                    .repo_path
 997                    .file_name()
 998                    .map(|name| name.to_string_lossy().into_owned())
 999                    .unwrap_or_default()
1000            };
1001
1002            let entry = GitStatusEntry {
1003                depth,
1004                display_name,
1005                repo_path: entry.repo_path.clone(),
1006                status: entry.status,
1007                is_staged,
1008            };
1009
1010            if is_conflict {
1011                conflict_entries.push(entry);
1012            } else if is_new {
1013                new_entries.push(entry);
1014            } else {
1015                changed_entries.push(entry);
1016            }
1017        }
1018
1019        // Sort entries by path to maintain consistent order
1020        conflict_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
1021        changed_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
1022        new_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
1023
1024        if conflict_entries.len() > 0 {
1025            self.entries.push(GitListEntry::Header(GitHeaderEntry {
1026                header: Section::Conflict,
1027            }));
1028            self.entries.extend(
1029                conflict_entries
1030                    .into_iter()
1031                    .map(GitListEntry::GitStatusEntry),
1032            );
1033        }
1034
1035        if changed_entries.len() > 0 {
1036            self.entries.push(GitListEntry::Header(GitHeaderEntry {
1037                header: Section::Tracked,
1038            }));
1039            self.entries.extend(
1040                changed_entries
1041                    .into_iter()
1042                    .map(GitListEntry::GitStatusEntry),
1043            );
1044        }
1045        if new_entries.len() > 0 {
1046            self.entries.push(GitListEntry::Header(GitHeaderEntry {
1047                header: Section::New,
1048            }));
1049            self.entries
1050                .extend(new_entries.into_iter().map(GitListEntry::GitStatusEntry));
1051        }
1052
1053        for (ix, entry) in self.entries.iter().enumerate() {
1054            if let Some(status_entry) = entry.status_entry() {
1055                self.entries_by_path
1056                    .insert(status_entry.repo_path.clone(), ix);
1057            }
1058        }
1059        self.update_counts(repo);
1060
1061        self.select_first_entry_if_none(cx);
1062
1063        cx.notify();
1064    }
1065
1066    fn header_state(&self, header_type: Section) -> ToggleState {
1067        let (staged_count, count) = match header_type {
1068            Section::New => (self.new_staged_count, self.new_count),
1069            Section::Tracked => (self.tracked_staged_count, self.tracked_count),
1070            Section::Conflict => (self.conflicted_staged_count, self.conflicted_count),
1071        };
1072        if staged_count == 0 {
1073            ToggleState::Unselected
1074        } else if count == staged_count {
1075            ToggleState::Selected
1076        } else {
1077            ToggleState::Indeterminate
1078        }
1079    }
1080
1081    fn update_counts(&mut self, repo: &Repository) {
1082        self.conflicted_count = 0;
1083        self.conflicted_staged_count = 0;
1084        self.new_count = 0;
1085        self.tracked_count = 0;
1086        self.new_staged_count = 0;
1087        self.tracked_staged_count = 0;
1088        for entry in &self.entries {
1089            let Some(status_entry) = entry.status_entry() else {
1090                continue;
1091            };
1092            if repo.has_conflict(&status_entry.repo_path) {
1093                self.conflicted_count += 1;
1094                if self.entry_is_staged(status_entry) != Some(false) {
1095                    self.conflicted_staged_count += 1;
1096                }
1097            } else if status_entry.status.is_created() {
1098                self.new_count += 1;
1099                if self.entry_is_staged(status_entry) != Some(false) {
1100                    self.new_staged_count += 1;
1101                }
1102            } else {
1103                self.tracked_count += 1;
1104                if self.entry_is_staged(status_entry) != Some(false) {
1105                    self.tracked_staged_count += 1;
1106                }
1107            }
1108        }
1109    }
1110
1111    fn entry_is_staged(&self, entry: &GitStatusEntry) -> Option<bool> {
1112        for pending in self.pending.iter().rev() {
1113            if pending.repo_paths.contains(&entry.repo_path) {
1114                return Some(pending.will_become_staged);
1115            }
1116        }
1117        entry.is_staged
1118    }
1119
1120    fn has_staged_changes(&self) -> bool {
1121        self.tracked_staged_count > 0
1122            || self.new_staged_count > 0
1123            || self.conflicted_staged_count > 0
1124    }
1125
1126    fn has_tracked_changes(&self) -> bool {
1127        self.tracked_count > 0
1128    }
1129
1130    fn has_unstaged_conflicts(&self) -> bool {
1131        self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count
1132    }
1133
1134    fn show_err_toast(&self, e: anyhow::Error, cx: &mut App) {
1135        let Some(workspace) = self.workspace.upgrade() else {
1136            return;
1137        };
1138        let notif_id = NotificationId::Named("git-operation-error".into());
1139
1140        let message = e.to_string();
1141        workspace.update(cx, |workspace, cx| {
1142            let toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| {
1143                window.dispatch_action(workspace::OpenLog.boxed_clone(), cx);
1144            });
1145            workspace.show_toast(toast, cx);
1146        });
1147    }
1148
1149    pub fn panel_button(
1150        &self,
1151        id: impl Into<SharedString>,
1152        label: impl Into<SharedString>,
1153    ) -> Button {
1154        let id = id.into().clone();
1155        let label = label.into().clone();
1156
1157        Button::new(id, label)
1158            .label_size(LabelSize::Small)
1159            .layer(ElevationIndex::ElevatedSurface)
1160            .size(ButtonSize::Compact)
1161            .style(ButtonStyle::Filled)
1162    }
1163
1164    pub fn indent_size(&self, window: &Window, cx: &mut Context<Self>) -> Pixels {
1165        Checkbox::container_size(cx).to_pixels(window.rem_size())
1166    }
1167
1168    pub fn render_divider(&self, _cx: &mut Context<Self>) -> impl IntoElement {
1169        h_flex()
1170            .items_center()
1171            .h(px(8.))
1172            .child(Divider::horizontal_dashed().color(DividerColor::Border))
1173    }
1174
1175    pub fn render_panel_header(
1176        &self,
1177        window: &mut Window,
1178        cx: &mut Context<Self>,
1179    ) -> impl IntoElement {
1180        let all_repositories = self
1181            .project
1182            .read(cx)
1183            .git_store()
1184            .read(cx)
1185            .all_repositories();
1186
1187        let has_repo_above = all_repositories.iter().any(|repo| {
1188            repo.read(cx)
1189                .repository_entry
1190                .work_directory
1191                .is_above_project()
1192        });
1193
1194        self.panel_header_container(window, cx)
1195            .when(all_repositories.len() > 1 || has_repo_above, |el| {
1196                el.child(self.render_repository_selector(cx))
1197            })
1198    }
1199
1200    pub fn render_repository_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
1201        let active_repository = self.project.read(cx).active_repository(cx);
1202        let repository_display_name = active_repository
1203            .as_ref()
1204            .map(|repo| repo.read(cx).display_name(self.project.read(cx), cx))
1205            .unwrap_or_default();
1206
1207        RepositorySelectorPopoverMenu::new(
1208            self.repository_selector.clone(),
1209            ButtonLike::new("active-repository")
1210                .style(ButtonStyle::Subtle)
1211                .child(Label::new(repository_display_name).size(LabelSize::Small)),
1212            Tooltip::text("Select a repository"),
1213        )
1214    }
1215
1216    pub fn render_commit_editor(
1217        &self,
1218        window: &mut Window,
1219        cx: &mut Context<Self>,
1220    ) -> impl IntoElement {
1221        let editor = self.commit_editor.clone();
1222        let can_commit = (self.has_staged_changes() || self.has_tracked_changes())
1223            && self.pending_commit.is_none()
1224            && !editor.read(cx).is_empty(cx)
1225            && !self.has_unstaged_conflicts()
1226            && self.has_write_access(cx);
1227
1228        // let can_commit_all =
1229        //     !self.commit_pending && self.can_commit_all && !editor.read(cx).is_empty(cx);
1230        let panel_editor_style = panel_editor_style(true, window, cx);
1231
1232        let editor_focus_handle = editor.read(cx).focus_handle(cx).clone();
1233
1234        let focus_handle_1 = self.focus_handle(cx).clone();
1235        let tooltip = if self.has_staged_changes() {
1236            "Commit staged changes"
1237        } else {
1238            "Commit changes to tracked files"
1239        };
1240        let title = if self.has_staged_changes() {
1241            "Commit"
1242        } else {
1243            "Commit All"
1244        };
1245
1246        let commit_button = panel_filled_button(title)
1247            .tooltip(move |window, cx| {
1248                let focus_handle = focus_handle_1.clone();
1249                Tooltip::for_action_in(tooltip, &Commit, &focus_handle, window, cx)
1250            })
1251            .disabled(!can_commit)
1252            .on_click({
1253                cx.listener(move |this, _: &ClickEvent, window, cx| this.commit_changes(window, cx))
1254            });
1255
1256        let potential_co_authors = self.potential_co_authors(cx);
1257        let enable_coauthors = if potential_co_authors.is_empty() {
1258            None
1259        } else {
1260            Some(
1261                IconButton::new("co-authors", IconName::Person)
1262                    .icon_color(Color::Disabled)
1263                    .selected_icon_color(Color::Selected)
1264                    .toggle_state(self.add_coauthors)
1265                    .tooltip(move |_, cx| {
1266                        let title = format!(
1267                            "Add co-authored-by:{}{}",
1268                            if potential_co_authors.len() == 1 {
1269                                ""
1270                            } else {
1271                                "\n"
1272                            },
1273                            potential_co_authors
1274                                .iter()
1275                                .map(|(name, email)| format!(" {} <{}>", name, email))
1276                                .join("\n")
1277                        );
1278                        Tooltip::simple(title, cx)
1279                    })
1280                    .on_click(cx.listener(|this, _, _, cx| {
1281                        this.add_coauthors = !this.add_coauthors;
1282                        cx.notify();
1283                    })),
1284            )
1285        };
1286
1287        let branch = self
1288            .active_repository
1289            .as_ref()
1290            .and_then(|repo| repo.read(cx).branch().map(|b| b.name.clone()))
1291            .unwrap_or_else(|| "<no branch>".into());
1292
1293        let branch_selector = Button::new("branch-selector", branch)
1294            .color(Color::Muted)
1295            .style(ButtonStyle::Subtle)
1296            .icon(IconName::GitBranch)
1297            .icon_size(IconSize::Small)
1298            .icon_color(Color::Muted)
1299            .size(ButtonSize::Compact)
1300            .icon_position(IconPosition::Start)
1301            .tooltip(Tooltip::for_action_title(
1302                "Switch Branch",
1303                &zed_actions::git::Branch,
1304            ))
1305            .on_click(cx.listener(|_, _, window, cx| {
1306                window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
1307            }))
1308            .style(ButtonStyle::Transparent);
1309
1310        let footer_size = px(32.);
1311        let gap = px(16.0);
1312
1313        let max_height = window.line_height() * 6. + gap + footer_size;
1314
1315        panel_editor_container(window, cx)
1316            .id("commit-editor-container")
1317            .relative()
1318            .h(max_height)
1319            .w_full()
1320            .border_t_1()
1321            .border_color(cx.theme().colors().border)
1322            .bg(cx.theme().colors().editor_background)
1323            .on_click(cx.listener(move |_, _: &ClickEvent, window, _cx| {
1324                window.focus(&editor_focus_handle);
1325            }))
1326            .child(EditorElement::new(&self.commit_editor, panel_editor_style))
1327            .child(
1328                h_flex()
1329                    .absolute()
1330                    .bottom_0()
1331                    .left_2()
1332                    .h(footer_size)
1333                    .flex_none()
1334                    .child(branch_selector),
1335            )
1336            .child(
1337                h_flex()
1338                    .absolute()
1339                    .bottom_0()
1340                    .right_2()
1341                    .h(footer_size)
1342                    .flex_none()
1343                    .children(enable_coauthors)
1344                    .child(commit_button),
1345            )
1346    }
1347
1348    fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
1349        let active_repository = self.active_repository.as_ref()?;
1350        let branch = active_repository.read(cx).branch()?;
1351        let commit = branch.most_recent_commit.as_ref()?.clone();
1352
1353        if branch.upstream.as_ref().is_some_and(|upstream| {
1354            if let Some(tracking) = &upstream.tracking {
1355                tracking.ahead == 0
1356            } else {
1357                true
1358            }
1359        }) {
1360            return None;
1361        }
1362        let tooltip = if self.has_staged_changes() {
1363            "git reset HEAD^ --soft"
1364        } else {
1365            "git reset HEAD^"
1366        };
1367
1368        let this = cx.entity();
1369        Some(
1370            h_flex()
1371                .items_center()
1372                .py_1p5()
1373                .px(px(8.))
1374                .bg(cx.theme().colors().background)
1375                .border_t_1()
1376                .border_color(cx.theme().colors().border)
1377                .gap_1p5()
1378                .child(
1379                    div()
1380                        .flex_grow()
1381                        .overflow_hidden()
1382                        .max_w(relative(0.6))
1383                        .h_full()
1384                        .child(
1385                            Label::new(commit.subject.clone())
1386                                .size(LabelSize::Small)
1387                                .text_ellipsis(),
1388                        )
1389                        .id("commit-msg-hover")
1390                        .hoverable_tooltip(move |window, cx| {
1391                            GitPanelMessageTooltip::new(
1392                                this.clone(),
1393                                commit.sha.clone(),
1394                                window,
1395                                cx,
1396                            )
1397                            .into()
1398                        }),
1399                )
1400                .child(div().flex_1())
1401                .child(
1402                    panel_filled_button("Uncommit")
1403                        .icon(IconName::Undo)
1404                        .icon_size(IconSize::Small)
1405                        .icon_color(Color::Muted)
1406                        .icon_position(IconPosition::Start)
1407                        .tooltip(Tooltip::for_action_title(tooltip, &git::Uncommit))
1408                        .on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))),
1409                ),
1410        )
1411    }
1412
1413    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1414        h_flex()
1415            .h_full()
1416            .flex_grow()
1417            .justify_center()
1418            .items_center()
1419            .child(
1420                v_flex()
1421                    .gap_3()
1422                    .child(if self.active_repository.is_some() {
1423                        "No changes to commit"
1424                    } else {
1425                        "No Git repositories"
1426                    })
1427                    .text_ui_sm(cx)
1428                    .mx_auto()
1429                    .text_color(Color::Placeholder.color(cx)),
1430            )
1431    }
1432
1433    fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
1434        let scroll_bar_style = self.show_scrollbar(cx);
1435        let show_container = matches!(scroll_bar_style, ShowScrollbar::Always);
1436
1437        if !self.should_show_scrollbar(cx)
1438            || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
1439        {
1440            return None;
1441        }
1442
1443        Some(
1444            div()
1445                .id("git-panel-vertical-scroll")
1446                .occlude()
1447                .flex_none()
1448                .h_full()
1449                .cursor_default()
1450                .when(show_container, |this| this.pl_1().px_1p5())
1451                .when(!show_container, |this| {
1452                    this.absolute().right_1().top_1().bottom_1().w(px(12.))
1453                })
1454                .on_mouse_move(cx.listener(|_, _, _, cx| {
1455                    cx.notify();
1456                    cx.stop_propagation()
1457                }))
1458                .on_hover(|_, _, cx| {
1459                    cx.stop_propagation();
1460                })
1461                .on_any_mouse_down(|_, _, cx| {
1462                    cx.stop_propagation();
1463                })
1464                .on_mouse_up(
1465                    MouseButton::Left,
1466                    cx.listener(|this, _, window, cx| {
1467                        if !this.scrollbar_state.is_dragging()
1468                            && !this.focus_handle.contains_focused(window, cx)
1469                        {
1470                            this.hide_scrollbar(window, cx);
1471                            cx.notify();
1472                        }
1473
1474                        cx.stop_propagation();
1475                    }),
1476                )
1477                .on_scroll_wheel(cx.listener(|_, _, _, cx| {
1478                    cx.notify();
1479                }))
1480                .children(Scrollbar::vertical(
1481                    // percentage as f32..end_offset as f32,
1482                    self.scrollbar_state.clone(),
1483                )),
1484        )
1485    }
1486
1487    pub fn render_buffer_header_controls(
1488        &self,
1489        entity: &Entity<Self>,
1490        file: &Arc<dyn File>,
1491        _: &Window,
1492        cx: &App,
1493    ) -> Option<AnyElement> {
1494        let repo = self.active_repository.as_ref()?.read(cx);
1495        let repo_path = repo.worktree_id_path_to_repo_path(file.worktree_id(cx), file.path())?;
1496        let ix = self.entries_by_path.get(&repo_path)?;
1497        let entry = self.entries.get(*ix)?;
1498
1499        let is_staged = self.entry_is_staged(entry.status_entry()?);
1500
1501        let checkbox = Checkbox::new("stage-file", is_staged.into())
1502            .disabled(!self.has_write_access(cx))
1503            .fill()
1504            .elevation(ElevationIndex::Surface)
1505            .on_click({
1506                let entry = entry.clone();
1507                let git_panel = entity.downgrade();
1508                move |_, window, cx| {
1509                    git_panel
1510                        .update(cx, |this, cx| {
1511                            this.toggle_staged_for_entry(&entry, window, cx);
1512                            cx.stop_propagation();
1513                        })
1514                        .ok();
1515                }
1516            });
1517        Some(
1518            h_flex()
1519                .id("start-slot")
1520                .child(checkbox)
1521                .child(git_status_icon(entry.status_entry()?.status, cx))
1522                .on_mouse_down(MouseButton::Left, |_, _, cx| {
1523                    // prevent the list item active state triggering when toggling checkbox
1524                    cx.stop_propagation();
1525                })
1526                .into_any_element(),
1527        )
1528    }
1529
1530    fn render_entries(
1531        &self,
1532        has_write_access: bool,
1533        window: &Window,
1534        cx: &mut Context<Self>,
1535    ) -> impl IntoElement {
1536        let entry_count = self.entries.len();
1537
1538        v_flex()
1539            .size_full()
1540            .flex_grow()
1541            .overflow_hidden()
1542            .child(
1543                uniform_list(cx.entity().clone(), "entries", entry_count, {
1544                    move |this, range, window, cx| {
1545                        let mut items = Vec::with_capacity(range.end - range.start);
1546
1547                        for ix in range {
1548                            match &this.entries.get(ix) {
1549                                Some(GitListEntry::GitStatusEntry(entry)) => {
1550                                    items.push(this.render_entry(
1551                                        ix,
1552                                        entry,
1553                                        has_write_access,
1554                                        window,
1555                                        cx,
1556                                    ));
1557                                }
1558                                Some(GitListEntry::Header(header)) => {
1559                                    items.push(this.render_list_header(
1560                                        ix,
1561                                        header,
1562                                        has_write_access,
1563                                        window,
1564                                        cx,
1565                                    ));
1566                                }
1567                                None => {}
1568                            }
1569                        }
1570
1571                        items
1572                    }
1573                })
1574                .with_decoration(
1575                    ui::indent_guides(
1576                        cx.entity().clone(),
1577                        self.indent_size(window, cx),
1578                        IndentGuideColors::panel(cx),
1579                        |this, range, _windows, _cx| {
1580                            this.entries
1581                                .iter()
1582                                .skip(range.start)
1583                                .map(|entry| match entry {
1584                                    GitListEntry::GitStatusEntry(_) => 1,
1585                                    GitListEntry::Header(_) => 0,
1586                                })
1587                                .collect()
1588                        },
1589                    )
1590                    .with_render_fn(
1591                        cx.entity().clone(),
1592                        move |_, params, _, _| {
1593                            let indent_size = params.indent_size;
1594                            let left_offset = indent_size - px(3.0);
1595                            let item_height = params.item_height;
1596
1597                            params
1598                                .indent_guides
1599                                .into_iter()
1600                                .enumerate()
1601                                .map(|(_, layout)| {
1602                                    let offset = if layout.continues_offscreen {
1603                                        px(0.)
1604                                    } else {
1605                                        px(4.0)
1606                                    };
1607                                    let bounds = Bounds::new(
1608                                        point(
1609                                            px(layout.offset.x as f32) * indent_size + left_offset,
1610                                            px(layout.offset.y as f32) * item_height + offset,
1611                                        ),
1612                                        size(
1613                                            px(1.),
1614                                            px(layout.length as f32) * item_height
1615                                                - px(offset.0 * 2.),
1616                                        ),
1617                                    );
1618                                    ui::RenderedIndentGuide {
1619                                        bounds,
1620                                        layout,
1621                                        is_active: false,
1622                                        hitbox: None,
1623                                    }
1624                                })
1625                                .collect()
1626                        },
1627                    ),
1628                )
1629                .size_full()
1630                .with_sizing_behavior(ListSizingBehavior::Infer)
1631                .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
1632                .track_scroll(self.scroll_handle.clone()),
1633            )
1634            .children(self.render_scrollbar(cx))
1635    }
1636
1637    fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
1638        Label::new(label.into()).color(color).single_line()
1639    }
1640
1641    fn render_list_header(
1642        &self,
1643        ix: usize,
1644        header: &GitHeaderEntry,
1645        has_write_access: bool,
1646        window: &Window,
1647        cx: &Context<Self>,
1648    ) -> AnyElement {
1649        let selected = self.selected_entry == Some(ix);
1650        let header_state = if self.has_staged_changes() {
1651            self.header_state(header.header)
1652        } else {
1653            match header.header {
1654                Section::Tracked | Section::Conflict => ToggleState::Selected,
1655                Section::New => ToggleState::Unselected,
1656            }
1657        };
1658
1659        let checkbox = Checkbox::new(("checkbox", ix), header_state)
1660            .disabled(!has_write_access)
1661            .fill()
1662            .placeholder(!self.has_staged_changes())
1663            .elevation(ElevationIndex::Surface)
1664            .on_click({
1665                let header = header.clone();
1666                cx.listener(move |this, _, window, cx| {
1667                    this.toggle_staged_for_entry(&GitListEntry::Header(header.clone()), window, cx);
1668                    cx.stop_propagation();
1669                })
1670            });
1671
1672        let start_slot = h_flex()
1673            .id(("start-slot", ix))
1674            .gap(DynamicSpacing::Base04.rems(cx))
1675            .child(checkbox)
1676            .tooltip(|window, cx| Tooltip::for_action("Stage File", &ToggleStaged, window, cx))
1677            .on_mouse_down(MouseButton::Left, |_, _, cx| {
1678                // prevent the list item active state triggering when toggling checkbox
1679                cx.stop_propagation();
1680            });
1681
1682        div()
1683            .w_full()
1684            .child(
1685                ListItem::new(ix)
1686                    .spacing(ListItemSpacing::Sparse)
1687                    .start_slot(start_slot)
1688                    .toggle_state(selected)
1689                    .focused(selected && self.focus_handle(cx).is_focused(window))
1690                    .disabled(!has_write_access)
1691                    .on_click({
1692                        cx.listener(move |this, _, _, cx| {
1693                            this.selected_entry = Some(ix);
1694                            cx.notify();
1695                        })
1696                    })
1697                    .child(h_flex().child(self.entry_label(header.title(), Color::Muted))),
1698            )
1699            .into_any_element()
1700    }
1701
1702    fn load_commit_details(
1703        &self,
1704        sha: &str,
1705        cx: &mut Context<Self>,
1706    ) -> Task<Result<CommitDetails>> {
1707        let Some(repo) = self.active_repository.clone() else {
1708            return Task::ready(Err(anyhow::anyhow!("no active repo")));
1709        };
1710        repo.update(cx, |repo, cx| repo.show(sha, cx))
1711    }
1712
1713    fn render_entry(
1714        &self,
1715        ix: usize,
1716        entry: &GitStatusEntry,
1717        has_write_access: bool,
1718        window: &Window,
1719        cx: &Context<Self>,
1720    ) -> AnyElement {
1721        let display_name = entry
1722            .repo_path
1723            .file_name()
1724            .map(|name| name.to_string_lossy().into_owned())
1725            .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
1726
1727        let repo_path = entry.repo_path.clone();
1728        let selected = self.selected_entry == Some(ix);
1729        let status_style = GitPanelSettings::get_global(cx).status_style;
1730        let status = entry.status;
1731        let has_conflict = status.is_conflicted();
1732        let is_modified = status.is_modified();
1733        let is_deleted = status.is_deleted();
1734
1735        let label_color = if status_style == StatusStyle::LabelColor {
1736            if has_conflict {
1737                Color::Conflict
1738            } else if is_modified {
1739                Color::Modified
1740            } else if is_deleted {
1741                // We don't want a bunch of red labels in the list
1742                Color::Disabled
1743            } else {
1744                Color::Created
1745            }
1746        } else {
1747            Color::Default
1748        };
1749
1750        let path_color = if status.is_deleted() {
1751            Color::Disabled
1752        } else {
1753            Color::Muted
1754        };
1755
1756        let id: ElementId = ElementId::Name(format!("entry_{}", display_name).into());
1757
1758        let mut is_staged: ToggleState = self.entry_is_staged(entry).into();
1759
1760        if !self.has_staged_changes() && !entry.status.is_created() {
1761            is_staged = ToggleState::Selected;
1762        }
1763
1764        let checkbox = Checkbox::new(id, is_staged)
1765            .disabled(!has_write_access)
1766            .fill()
1767            .placeholder(!self.has_staged_changes())
1768            .elevation(ElevationIndex::Surface)
1769            .on_click({
1770                let entry = entry.clone();
1771                cx.listener(move |this, _, window, cx| {
1772                    this.toggle_staged_for_entry(
1773                        &GitListEntry::GitStatusEntry(entry.clone()),
1774                        window,
1775                        cx,
1776                    );
1777                    cx.stop_propagation();
1778                })
1779            });
1780
1781        let start_slot = h_flex()
1782            .id(("start-slot", ix))
1783            .gap(DynamicSpacing::Base04.rems(cx))
1784            .child(checkbox)
1785            .tooltip(|window, cx| Tooltip::for_action("Stage File", &ToggleStaged, window, cx))
1786            .child(git_status_icon(status, cx))
1787            .on_mouse_down(MouseButton::Left, |_, _, cx| {
1788                // prevent the list item active state triggering when toggling checkbox
1789                cx.stop_propagation();
1790            });
1791
1792        let id = ElementId::Name(format!("entry_{}", display_name).into());
1793
1794        div()
1795            .w_full()
1796            .child(
1797                ListItem::new(id)
1798                    .indent_level(1)
1799                    .indent_step_size(Checkbox::container_size(cx).to_pixels(window.rem_size()))
1800                    .spacing(ListItemSpacing::Sparse)
1801                    .start_slot(start_slot)
1802                    .toggle_state(selected)
1803                    .focused(selected && self.focus_handle(cx).is_focused(window))
1804                    .disabled(!has_write_access)
1805                    .on_click({
1806                        cx.listener(move |this, _, window, cx| {
1807                            this.selected_entry = Some(ix);
1808                            cx.notify();
1809                            this.open_selected(&Default::default(), window, cx);
1810                        })
1811                    })
1812                    .child(
1813                        h_flex()
1814                            .when_some(repo_path.parent(), |this, parent| {
1815                                let parent_str = parent.to_string_lossy();
1816                                if !parent_str.is_empty() {
1817                                    this.child(
1818                                        self.entry_label(format!("{}/", parent_str), path_color)
1819                                            .when(status.is_deleted(), |this| this.strikethrough()),
1820                                    )
1821                                } else {
1822                                    this
1823                                }
1824                            })
1825                            .child(
1826                                self.entry_label(display_name.clone(), label_color)
1827                                    .when(status.is_deleted(), |this| this.strikethrough()),
1828                            ),
1829                    ),
1830            )
1831            .into_any_element()
1832    }
1833
1834    fn has_write_access(&self, cx: &App) -> bool {
1835        !self.project.read(cx).is_read_only(cx)
1836    }
1837}
1838
1839impl Render for GitPanel {
1840    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1841        let project = self.project.read(cx);
1842        let has_entries = self
1843            .active_repository
1844            .as_ref()
1845            .map_or(false, |active_repository| {
1846                active_repository.read(cx).entry_count() > 0
1847            });
1848        let room = self
1849            .workspace
1850            .upgrade()
1851            .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
1852
1853        let has_write_access = self.has_write_access(cx);
1854
1855        let has_co_authors = room.map_or(false, |room| {
1856            room.read(cx)
1857                .remote_participants()
1858                .values()
1859                .any(|remote_participant| remote_participant.can_write())
1860        });
1861
1862        v_flex()
1863            .id("git_panel")
1864            .key_context(self.dispatch_context(window, cx))
1865            .track_focus(&self.focus_handle)
1866            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
1867            .when(has_write_access && !project.is_read_only(cx), |this| {
1868                this.on_action(cx.listener(|this, &ToggleStaged, window, cx| {
1869                    this.toggle_staged_for_selected(&ToggleStaged, window, cx)
1870                }))
1871                .on_action(cx.listener(GitPanel::commit))
1872            })
1873            .when(self.is_focused(window, cx), |this| {
1874                this.on_action(cx.listener(Self::select_first))
1875                    .on_action(cx.listener(Self::select_next))
1876                    .on_action(cx.listener(Self::select_prev))
1877                    .on_action(cx.listener(Self::select_last))
1878                    .on_action(cx.listener(Self::close_panel))
1879            })
1880            .on_action(cx.listener(Self::open_selected))
1881            .on_action(cx.listener(Self::focus_changes_list))
1882            .on_action(cx.listener(Self::focus_editor))
1883            .on_action(cx.listener(Self::toggle_staged_for_selected))
1884            .when(has_write_access && has_co_authors, |git_panel| {
1885                git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
1886            })
1887            // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx)))
1888            .on_hover(cx.listener(|this, hovered, window, cx| {
1889                if *hovered {
1890                    this.show_scrollbar = true;
1891                    this.hide_scrollbar_task.take();
1892                    cx.notify();
1893                } else if !this.focus_handle.contains_focused(window, cx) {
1894                    this.hide_scrollbar(window, cx);
1895                }
1896            }))
1897            .size_full()
1898            .overflow_hidden()
1899            .bg(ElevationIndex::Surface.bg(cx))
1900            .child(self.render_panel_header(window, cx))
1901            .child(if has_entries {
1902                self.render_entries(has_write_access, window, cx)
1903                    .into_any_element()
1904            } else {
1905                self.render_empty_state(cx).into_any_element()
1906            })
1907            .children(self.render_previous_commit(cx))
1908            .child(self.render_commit_editor(window, cx))
1909    }
1910}
1911
1912impl Focusable for GitPanel {
1913    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
1914        self.focus_handle.clone()
1915    }
1916}
1917
1918impl EventEmitter<Event> for GitPanel {}
1919
1920impl EventEmitter<PanelEvent> for GitPanel {}
1921
1922pub(crate) struct GitPanelAddon {
1923    pub(crate) git_panel: Entity<GitPanel>,
1924}
1925
1926impl editor::Addon for GitPanelAddon {
1927    fn to_any(&self) -> &dyn std::any::Any {
1928        self
1929    }
1930
1931    fn render_buffer_header_controls(
1932        &self,
1933        excerpt_info: &ExcerptInfo,
1934        window: &Window,
1935        cx: &App,
1936    ) -> Option<AnyElement> {
1937        let file = excerpt_info.buffer.file()?;
1938        let git_panel = self.git_panel.read(cx);
1939
1940        git_panel.render_buffer_header_controls(&self.git_panel, &file, window, cx)
1941    }
1942}
1943
1944impl Panel for GitPanel {
1945    fn persistent_name() -> &'static str {
1946        "GitPanel"
1947    }
1948
1949    fn position(&self, _: &Window, cx: &App) -> DockPosition {
1950        GitPanelSettings::get_global(cx).dock
1951    }
1952
1953    fn position_is_valid(&self, position: DockPosition) -> bool {
1954        matches!(position, DockPosition::Left | DockPosition::Right)
1955    }
1956
1957    fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
1958        settings::update_settings_file::<GitPanelSettings>(
1959            self.fs.clone(),
1960            cx,
1961            move |settings, _| settings.dock = Some(position),
1962        );
1963    }
1964
1965    fn size(&self, _: &Window, cx: &App) -> Pixels {
1966        self.width
1967            .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
1968    }
1969
1970    fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
1971        self.width = size;
1972        self.serialize(cx);
1973        cx.notify();
1974    }
1975
1976    fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
1977        Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
1978    }
1979
1980    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
1981        Some("Git Panel")
1982    }
1983
1984    fn toggle_action(&self) -> Box<dyn Action> {
1985        Box::new(ToggleFocus)
1986    }
1987
1988    fn activation_priority(&self) -> u32 {
1989        2
1990    }
1991}
1992
1993impl PanelHeader for GitPanel {}
1994
1995struct GitPanelMessageTooltip {
1996    commit_tooltip: Option<Entity<CommitTooltip>>,
1997}
1998
1999impl GitPanelMessageTooltip {
2000    fn new(
2001        git_panel: Entity<GitPanel>,
2002        sha: SharedString,
2003        window: &mut Window,
2004        cx: &mut App,
2005    ) -> Entity<Self> {
2006        let workspace = git_panel.read(cx).workspace.clone();
2007        cx.new(|cx| {
2008            cx.spawn_in(window, |this, mut cx| async move {
2009                let language_registry = workspace.update(&mut cx, |workspace, _cx| {
2010                    workspace.app_state().languages.clone()
2011                })?;
2012
2013                let details = git_panel
2014                    .update(&mut cx, |git_panel, cx| {
2015                        git_panel.load_commit_details(&sha, cx)
2016                    })?
2017                    .await?;
2018
2019                let mut parsed_message = ParsedMarkdown::default();
2020                markdown::parse_markdown_block(
2021                    &details.message,
2022                    Some(&language_registry),
2023                    None,
2024                    &mut parsed_message.text,
2025                    &mut parsed_message.highlights,
2026                    &mut parsed_message.region_ranges,
2027                    &mut parsed_message.regions,
2028                )
2029                .await;
2030
2031                let commit_details = editor::commit_tooltip::CommitDetails {
2032                    sha: details.sha.clone(),
2033                    committer_name: details.committer_name.clone(),
2034                    committer_email: details.committer_email.clone(),
2035                    commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
2036                    message: Some(editor::commit_tooltip::ParsedCommitMessage {
2037                        message: details.message.clone(),
2038                        parsed_message,
2039                        ..Default::default()
2040                    }),
2041                };
2042
2043                this.update_in(&mut cx, |this: &mut GitPanelMessageTooltip, window, cx| {
2044                    this.commit_tooltip = Some(cx.new(move |cx| {
2045                        CommitTooltip::new(
2046                            commit_details,
2047                            panel_editor_style(true, window, cx),
2048                            Some(workspace),
2049                        )
2050                    }));
2051                    cx.notify();
2052                })
2053            })
2054            .detach();
2055
2056            Self {
2057                commit_tooltip: None,
2058            }
2059        })
2060    }
2061}
2062
2063impl Render for GitPanelMessageTooltip {
2064    fn render(&mut self, _window: &mut Window, _cx: &mut Context<'_, Self>) -> impl IntoElement {
2065        if let Some(commit_tooltip) = &self.commit_tooltip {
2066            commit_tooltip.clone().into_any_element()
2067        } else {
2068            gpui::Empty.into_any_element()
2069        }
2070    }
2071}