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