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