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