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