git_panel.rs

   1use crate::git_panel_settings::StatusStyle;
   2use crate::{git_panel_settings::GitPanelSettings, git_status_icon};
   3use anyhow::{Context as _, Result};
   4use db::kvp::KEY_VALUE_STORE;
   5use editor::scroll::ScrollbarAutoHide;
   6use editor::{Editor, EditorSettings, ShowScrollbar};
   7use git::repository::{GitRepository, RepoPath};
   8use git::status::FileStatus;
   9use git::{CommitAllChanges, CommitChanges, RevertAll, StageAll, ToggleStaged, UnstageAll};
  10use gpui::*;
  11use language::Buffer;
  12use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev};
  13use project::git::GitState;
  14use project::{Fs, Project, ProjectPath, WorktreeId};
  15use serde::{Deserialize, Serialize};
  16use settings::Settings as _;
  17use std::sync::atomic::{AtomicBool, Ordering};
  18use std::{collections::HashSet, ops::Range, path::PathBuf, sync::Arc, time::Duration, usize};
  19use theme::ThemeSettings;
  20use ui::{
  21    prelude::*, Checkbox, Divider, DividerColor, ElevationIndex, Scrollbar, ScrollbarState, Tooltip,
  22};
  23use util::{maybe, ResultExt, TryFutureExt};
  24use workspace::notifications::DetachAndPromptErr;
  25use workspace::{
  26    dock::{DockPosition, Panel, PanelEvent},
  27    Workspace,
  28};
  29use worktree::RepositoryEntry;
  30
  31actions!(
  32    git_panel,
  33    [
  34        Close,
  35        ToggleFocus,
  36        OpenMenu,
  37        OpenSelected,
  38        FocusEditor,
  39        FocusChanges
  40    ]
  41);
  42
  43const GIT_PANEL_KEY: &str = "GitPanel";
  44
  45const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
  46
  47pub fn init(cx: &mut AppContext) {
  48    cx.observe_new_views(
  49        |workspace: &mut Workspace, _cx: &mut ViewContext<Workspace>| {
  50            workspace.register_action(|workspace, _: &ToggleFocus, cx| {
  51                workspace.toggle_panel_focus::<GitPanel>(cx);
  52            });
  53        },
  54    )
  55    .detach();
  56}
  57
  58#[derive(Debug, Clone)]
  59pub enum Event {
  60    Focus,
  61    OpenedEntry { path: ProjectPath },
  62}
  63
  64#[derive(Serialize, Deserialize)]
  65struct SerializedGitPanel {
  66    width: Option<Pixels>,
  67}
  68
  69#[derive(Debug, PartialEq, Eq, Clone)]
  70pub struct GitListEntry {
  71    depth: usize,
  72    display_name: String,
  73    repo_path: RepoPath,
  74    status: FileStatus,
  75    is_staged: Option<bool>,
  76}
  77
  78pub struct GitPanel {
  79    current_modifiers: Modifiers,
  80    focus_handle: FocusHandle,
  81    fs: Arc<dyn Fs>,
  82    hide_scrollbar_task: Option<Task<()>>,
  83    pending_serialization: Task<Option<()>>,
  84    project: Model<Project>,
  85    scroll_handle: UniformListScrollHandle,
  86    scrollbar_state: ScrollbarState,
  87    selected_entry: Option<usize>,
  88    show_scrollbar: bool,
  89    rebuild_requested: Arc<AtomicBool>,
  90    commit_editor: View<Editor>,
  91    /// The visible entries in the list, accounting for folding & expanded state.
  92    ///
  93    /// At this point it doesn't matter what repository the entry belongs to,
  94    /// as only one repositories' entries are visible in the list at a time.
  95    visible_entries: Vec<GitListEntry>,
  96    all_staged: Option<bool>,
  97    width: Option<Pixels>,
  98    reveal_in_editor: Task<()>,
  99}
 100
 101fn first_worktree_repository(
 102    project: &Model<Project>,
 103    worktree_id: WorktreeId,
 104    cx: &mut AppContext,
 105) -> Option<(RepositoryEntry, Arc<dyn GitRepository>)> {
 106    project
 107        .read(cx)
 108        .worktree_for_id(worktree_id, cx)
 109        .and_then(|worktree| {
 110            let snapshot = worktree.read(cx).snapshot();
 111            let repo = snapshot.repositories().iter().next()?.clone();
 112            let git_repo = worktree
 113                .read(cx)
 114                .as_local()?
 115                .get_local_repo(&repo)?
 116                .repo()
 117                .clone();
 118            Some((repo, git_repo))
 119        })
 120}
 121
 122fn first_repository_in_project(
 123    project: &Model<Project>,
 124    cx: &mut AppContext,
 125) -> Option<(WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)> {
 126    project.read(cx).worktrees(cx).next().and_then(|worktree| {
 127        let snapshot = worktree.read(cx).snapshot();
 128        let repo = snapshot.repositories().iter().next()?.clone();
 129        let git_repo = worktree
 130            .read(cx)
 131            .as_local()?
 132            .get_local_repo(&repo)?
 133            .repo()
 134            .clone();
 135        Some((snapshot.id(), repo, git_repo))
 136    })
 137}
 138
 139impl GitPanel {
 140    pub fn load(
 141        workspace: WeakView<Workspace>,
 142        cx: AsyncWindowContext,
 143    ) -> Task<Result<View<Self>>> {
 144        cx.spawn(|mut cx| async move { workspace.update(&mut cx, Self::new) })
 145    }
 146
 147    pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
 148        let fs = workspace.app_state().fs.clone();
 149        let project = workspace.project().clone();
 150        let git_state = project.read(cx).git_state().cloned();
 151        let language_registry = workspace.app_state().languages.clone();
 152        let current_commit_message = git_state
 153            .as_ref()
 154            .and_then(|git_state| git_state.read(cx).commit_message.clone());
 155
 156        let git_panel = cx.new_view(|cx: &mut ViewContext<Self>| {
 157            let focus_handle = cx.focus_handle();
 158            cx.on_focus(&focus_handle, Self::focus_in).detach();
 159            cx.on_focus_out(&focus_handle, |this, _, cx| {
 160                this.hide_scrollbar(cx);
 161            })
 162            .detach();
 163            cx.subscribe(&project, move |this, project, event, cx| {
 164                use project::Event;
 165
 166                let first_worktree_id = project.read(cx).worktrees(cx).next().map(|worktree| {
 167                    let snapshot = worktree.read(cx).snapshot();
 168                    snapshot.id()
 169                });
 170                let first_repo_in_project = first_repository_in_project(&project, cx);
 171
 172                let Some(git_state) = project.read(cx).git_state().cloned() else {
 173                    return;
 174                };
 175                git_state.update(cx, |git_state, _| {
 176                    match event {
 177                        project::Event::WorktreeRemoved(id) => {
 178                            let Some((worktree_id, _, _)) = git_state.active_repository.as_ref()
 179                            else {
 180                                return;
 181                            };
 182                            if worktree_id == id {
 183                                git_state.active_repository = first_repo_in_project;
 184                                this.schedule_update();
 185                            }
 186                        }
 187                        project::Event::WorktreeOrderChanged => {
 188                            // activate the new first worktree if the first was moved
 189                            let Some(first_id) = first_worktree_id else {
 190                                return;
 191                            };
 192                            if !git_state
 193                                .active_repository
 194                                .as_ref()
 195                                .is_some_and(|(id, _, _)| id == &first_id)
 196                            {
 197                                git_state.active_repository = first_repo_in_project;
 198                                this.schedule_update();
 199                            }
 200                        }
 201                        Event::WorktreeAdded(_) => {
 202                            let Some(first_id) = first_worktree_id else {
 203                                return;
 204                            };
 205                            if !git_state
 206                                .active_repository
 207                                .as_ref()
 208                                .is_some_and(|(id, _, _)| id == &first_id)
 209                            {
 210                                git_state.active_repository = first_repo_in_project;
 211                                this.schedule_update();
 212                            }
 213                        }
 214                        project::Event::WorktreeUpdatedEntries(id, _) => {
 215                            if git_state
 216                                .active_repository
 217                                .as_ref()
 218                                .is_some_and(|(active_id, _, _)| active_id == id)
 219                            {
 220                                git_state.active_repository = first_repo_in_project;
 221                                this.schedule_update();
 222                            }
 223                        }
 224                        project::Event::WorktreeUpdatedGitRepositories(_) => {
 225                            let Some(first) = first_repo_in_project else {
 226                                return;
 227                            };
 228                            git_state.active_repository = Some(first);
 229                            this.schedule_update();
 230                        }
 231                        project::Event::Closed => {
 232                            this.reveal_in_editor = Task::ready(());
 233                            this.visible_entries.clear();
 234                        }
 235                        _ => {}
 236                    };
 237                });
 238            })
 239            .detach();
 240
 241            let commit_editor = cx.new_view(|cx| {
 242                let theme = ThemeSettings::get_global(cx);
 243
 244                let mut text_style = cx.text_style();
 245                let refinement = TextStyleRefinement {
 246                    font_family: Some(theme.buffer_font.family.clone()),
 247                    font_features: Some(FontFeatures::disable_ligatures()),
 248                    font_size: Some(px(12.).into()),
 249                    color: Some(cx.theme().colors().editor_foreground),
 250                    background_color: Some(gpui::transparent_black()),
 251                    ..Default::default()
 252                };
 253
 254                text_style.refine(&refinement);
 255
 256                let mut commit_editor = Editor::auto_height(10, cx);
 257                if let Some(message) = current_commit_message {
 258                    commit_editor.set_text(message, cx);
 259                } else {
 260                    commit_editor.set_text("", cx);
 261                }
 262                commit_editor.set_use_autoclose(false);
 263                commit_editor.set_show_gutter(false, cx);
 264                commit_editor.set_show_wrap_guides(false, cx);
 265                commit_editor.set_show_indent_guides(false, cx);
 266                commit_editor.set_text_style_refinement(refinement);
 267                commit_editor.set_placeholder_text("Enter commit message", cx);
 268                commit_editor
 269            });
 270
 271            let buffer = commit_editor
 272                .read(cx)
 273                .buffer()
 274                .read(cx)
 275                .as_singleton()
 276                .expect("commit editor must be singleton");
 277
 278            cx.subscribe(&buffer, Self::on_buffer_event).detach();
 279
 280            let markdown = language_registry.language_for_name("Markdown");
 281            cx.spawn(|_, mut cx| async move {
 282                let markdown = markdown.await.context("failed to load Markdown language")?;
 283                buffer.update(&mut cx, |buffer, cx| {
 284                    buffer.set_language(Some(markdown), cx)
 285                })
 286            })
 287            .detach_and_log_err(cx);
 288
 289            let scroll_handle = UniformListScrollHandle::new();
 290
 291            let mut visible_worktrees = project.read(cx).visible_worktrees(cx);
 292            let first_worktree = visible_worktrees.next();
 293            drop(visible_worktrees);
 294            if let Some(first_worktree) = first_worktree {
 295                let snapshot = first_worktree.read(cx).snapshot();
 296
 297                if let Some(((repo, git_repo), git_state)) =
 298                    first_worktree_repository(&project, snapshot.id(), cx).zip(git_state)
 299                {
 300                    git_state.update(cx, |git_state, _| {
 301                        git_state.activate_repository(snapshot.id(), repo, git_repo);
 302                    });
 303                }
 304            };
 305
 306            let rebuild_requested = Arc::new(AtomicBool::new(false));
 307            let flag = rebuild_requested.clone();
 308            let handle = cx.view().downgrade();
 309            cx.spawn(|_, mut cx| async move {
 310                loop {
 311                    cx.background_executor().timer(UPDATE_DEBOUNCE).await;
 312                    if flag.load(Ordering::Relaxed) {
 313                        if let Some(this) = handle.upgrade() {
 314                            this.update(&mut cx, |this, cx| {
 315                                this.update_visible_entries(cx);
 316                            })
 317                            .ok();
 318                        }
 319                        flag.store(false, Ordering::Relaxed);
 320                    }
 321                }
 322            })
 323            .detach();
 324
 325            let mut git_panel = Self {
 326                focus_handle: cx.focus_handle(),
 327                fs,
 328                pending_serialization: Task::ready(None),
 329                visible_entries: Vec::new(),
 330                all_staged: None,
 331                current_modifiers: cx.modifiers(),
 332                width: Some(px(360.)),
 333                scrollbar_state: ScrollbarState::new(scroll_handle.clone()).parent_view(cx.view()),
 334                scroll_handle,
 335                selected_entry: None,
 336                show_scrollbar: false,
 337                hide_scrollbar_task: None,
 338                rebuild_requested,
 339                commit_editor,
 340                reveal_in_editor: Task::ready(()),
 341                project,
 342            };
 343            git_panel.schedule_update();
 344            git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx);
 345            git_panel
 346        });
 347
 348        cx.subscribe(
 349            &git_panel,
 350            move |workspace, _, event: &Event, cx| match event.clone() {
 351                Event::OpenedEntry { path } => {
 352                    workspace
 353                        .open_path_preview(path, None, false, false, cx)
 354                        .detach_and_prompt_err("Failed to open file", cx, |e, _| {
 355                            Some(format!("{e}"))
 356                        });
 357                }
 358                Event::Focus => { /* TODO */ }
 359            },
 360        )
 361        .detach();
 362
 363        git_panel
 364    }
 365
 366    fn git_state<'a>(&self, cx: &'a AppContext) -> Option<&'a Model<GitState>> {
 367        self.project.read(cx).git_state()
 368    }
 369
 370    fn active_repository<'a>(
 371        &self,
 372        cx: &'a AppContext,
 373    ) -> Option<&'a (WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)> {
 374        let git_state = self.git_state(cx)?;
 375        let active_repository = git_state.read(cx).active_repository.as_ref()?;
 376        Some(active_repository)
 377    }
 378
 379    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
 380        // TODO: we can store stage status here
 381        let width = self.width;
 382        self.pending_serialization = cx.background_executor().spawn(
 383            async move {
 384                KEY_VALUE_STORE
 385                    .write_kvp(
 386                        GIT_PANEL_KEY.into(),
 387                        serde_json::to_string(&SerializedGitPanel { width })?,
 388                    )
 389                    .await?;
 390                anyhow::Ok(())
 391            }
 392            .log_err(),
 393        );
 394    }
 395
 396    fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
 397        let mut dispatch_context = KeyContext::new_with_defaults();
 398        dispatch_context.add("GitPanel");
 399
 400        if self.is_focused(cx) {
 401            dispatch_context.add("menu");
 402            dispatch_context.add("ChangesList");
 403        }
 404
 405        if self.commit_editor.read(cx).is_focused(cx) {
 406            dispatch_context.add("CommitEditor");
 407        }
 408
 409        dispatch_context
 410    }
 411
 412    fn is_focused(&self, cx: &ViewContext<Self>) -> bool {
 413        cx.focused()
 414            .map_or(false, |focused| self.focus_handle == focused)
 415    }
 416
 417    fn close_panel(&mut self, _: &Close, cx: &mut ViewContext<Self>) {
 418        cx.emit(PanelEvent::Close);
 419    }
 420
 421    fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
 422        if !self.focus_handle.contains_focused(cx) {
 423            cx.emit(Event::Focus);
 424        }
 425    }
 426
 427    fn show_scrollbar(&self, cx: &mut ViewContext<Self>) -> ShowScrollbar {
 428        GitPanelSettings::get_global(cx)
 429            .scrollbar
 430            .show
 431            .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show)
 432    }
 433
 434    fn should_show_scrollbar(&self, cx: &mut ViewContext<Self>) -> bool {
 435        let show = self.show_scrollbar(cx);
 436        match show {
 437            ShowScrollbar::Auto => true,
 438            ShowScrollbar::System => true,
 439            ShowScrollbar::Always => true,
 440            ShowScrollbar::Never => false,
 441        }
 442    }
 443
 444    fn should_autohide_scrollbar(&self, cx: &mut ViewContext<Self>) -> bool {
 445        let show = self.show_scrollbar(cx);
 446        match show {
 447            ShowScrollbar::Auto => true,
 448            ShowScrollbar::System => cx
 449                .try_global::<ScrollbarAutoHide>()
 450                .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
 451            ShowScrollbar::Always => false,
 452            ShowScrollbar::Never => true,
 453        }
 454    }
 455
 456    fn hide_scrollbar(&mut self, cx: &mut ViewContext<Self>) {
 457        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
 458        if !self.should_autohide_scrollbar(cx) {
 459            return;
 460        }
 461        self.hide_scrollbar_task = Some(cx.spawn(|panel, mut cx| async move {
 462            cx.background_executor()
 463                .timer(SCROLLBAR_SHOW_INTERVAL)
 464                .await;
 465            panel
 466                .update(&mut cx, |panel, cx| {
 467                    panel.show_scrollbar = false;
 468                    cx.notify();
 469                })
 470                .log_err();
 471        }))
 472    }
 473
 474    fn handle_modifiers_changed(
 475        &mut self,
 476        event: &ModifiersChangedEvent,
 477        cx: &mut ViewContext<Self>,
 478    ) {
 479        self.current_modifiers = event.modifiers;
 480        cx.notify();
 481    }
 482
 483    fn calculate_depth_and_difference(
 484        repo_path: &RepoPath,
 485        visible_entries: &HashSet<RepoPath>,
 486    ) -> (usize, usize) {
 487        let ancestors = repo_path.ancestors().skip(1);
 488        for ancestor in ancestors {
 489            if let Some(parent_entry) = visible_entries.get(ancestor) {
 490                let entry_component_count = repo_path.components().count();
 491                let parent_component_count = parent_entry.components().count();
 492
 493                let difference = entry_component_count - parent_component_count;
 494
 495                let parent_depth = parent_entry
 496                    .ancestors()
 497                    .skip(1) // Skip the parent itself
 498                    .filter(|ancestor| visible_entries.contains(*ancestor))
 499                    .count();
 500
 501                return (parent_depth + 1, difference);
 502            }
 503        }
 504
 505        (0, 0)
 506    }
 507
 508    fn scroll_to_selected_entry(&mut self, cx: &mut ViewContext<Self>) {
 509        if let Some(selected_entry) = self.selected_entry {
 510            self.scroll_handle
 511                .scroll_to_item(selected_entry, ScrollStrategy::Center);
 512        }
 513
 514        cx.notify();
 515    }
 516
 517    fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
 518        if self.visible_entries.first().is_some() {
 519            self.selected_entry = Some(0);
 520            self.scroll_to_selected_entry(cx);
 521        }
 522    }
 523
 524    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
 525        let item_count = self.visible_entries.len();
 526        if item_count == 0 {
 527            return;
 528        }
 529
 530        if let Some(selected_entry) = self.selected_entry {
 531            let new_selected_entry = if selected_entry > 0 {
 532                selected_entry - 1
 533            } else {
 534                selected_entry
 535            };
 536
 537            self.selected_entry = Some(new_selected_entry);
 538
 539            self.scroll_to_selected_entry(cx);
 540        }
 541
 542        cx.notify();
 543    }
 544
 545    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
 546        let item_count = self.visible_entries.len();
 547        if item_count == 0 {
 548            return;
 549        }
 550
 551        if let Some(selected_entry) = self.selected_entry {
 552            let new_selected_entry = if selected_entry < item_count - 1 {
 553                selected_entry + 1
 554            } else {
 555                selected_entry
 556            };
 557
 558            self.selected_entry = Some(new_selected_entry);
 559
 560            self.scroll_to_selected_entry(cx);
 561        }
 562
 563        cx.notify();
 564    }
 565
 566    fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
 567        if self.visible_entries.last().is_some() {
 568            self.selected_entry = Some(self.visible_entries.len() - 1);
 569            self.scroll_to_selected_entry(cx);
 570        }
 571    }
 572
 573    fn focus_editor(&mut self, _: &FocusEditor, cx: &mut ViewContext<Self>) {
 574        self.commit_editor.update(cx, |editor, cx| {
 575            editor.focus(cx);
 576        });
 577        cx.notify();
 578    }
 579
 580    fn select_first_entry_if_none(&mut self, cx: &mut ViewContext<Self>) {
 581        if !self.no_entries() && self.selected_entry.is_none() {
 582            self.selected_entry = Some(0);
 583            self.scroll_to_selected_entry(cx);
 584            cx.notify();
 585        }
 586    }
 587
 588    fn focus_changes_list(&mut self, _: &FocusChanges, cx: &mut ViewContext<Self>) {
 589        self.select_first_entry_if_none(cx);
 590
 591        cx.focus_self();
 592        cx.notify();
 593    }
 594
 595    fn get_selected_entry(&self) -> Option<&GitListEntry> {
 596        self.selected_entry
 597            .and_then(|i| self.visible_entries.get(i))
 598    }
 599
 600    fn toggle_staged_for_entry(&mut self, entry: &GitListEntry, cx: &mut ViewContext<Self>) {
 601        let Some(git_state) = self.git_state(cx).cloned() else {
 602            return;
 603        };
 604        git_state.update(cx, |git_state, _| {
 605            if entry.status.is_staged().unwrap_or(false) {
 606                git_state.unstage_entry(entry.repo_path.clone());
 607            } else {
 608                git_state.stage_entry(entry.repo_path.clone());
 609            }
 610        });
 611        cx.notify();
 612    }
 613
 614    fn toggle_staged_for_selected(&mut self, _: &git::ToggleStaged, cx: &mut ViewContext<Self>) {
 615        if let Some(selected_entry) = self.get_selected_entry().cloned() {
 616            self.toggle_staged_for_entry(&selected_entry, cx);
 617        }
 618    }
 619
 620    fn open_selected(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
 621        if let Some(entry) = self
 622            .selected_entry
 623            .and_then(|i| self.visible_entries.get(i))
 624        {
 625            self.open_entry(entry, cx);
 626        }
 627    }
 628
 629    fn open_entry(&self, entry: &GitListEntry, cx: &mut ViewContext<Self>) {
 630        let Some((worktree_id, path)) = maybe!({
 631            let git_state = self.git_state(cx)?;
 632            let (id, repo, _) = git_state.read(cx).active_repository.as_ref()?;
 633            let path = repo.work_directory.unrelativize(&entry.repo_path)?;
 634            Some((*id, path))
 635        }) else {
 636            return;
 637        };
 638        let path = (worktree_id, path).into();
 639        let path_exists = self.project.update(cx, |project, cx| {
 640            project.entry_for_path(&path, cx).is_some()
 641        });
 642        if !path_exists {
 643            return;
 644        }
 645        cx.emit(Event::OpenedEntry { path });
 646    }
 647
 648    fn stage_all(&mut self, _: &git::StageAll, cx: &mut ViewContext<Self>) {
 649        let to_stage = self
 650            .visible_entries
 651            .iter_mut()
 652            .filter_map(|entry| {
 653                let is_unstaged = !entry.is_staged.unwrap_or(false);
 654                entry.is_staged = Some(true);
 655                is_unstaged.then(|| entry.repo_path.clone())
 656            })
 657            .collect();
 658        self.all_staged = Some(true);
 659        let Some(git_state) = self.git_state(cx).cloned() else {
 660            return;
 661        };
 662        git_state.update(cx, |git_state, _| git_state.stage_entries(to_stage));
 663    }
 664
 665    fn unstage_all(&mut self, _: &git::UnstageAll, cx: &mut ViewContext<Self>) {
 666        // This should only be called when all entries are staged.
 667        for entry in &mut self.visible_entries {
 668            entry.is_staged = Some(false);
 669        }
 670        self.all_staged = Some(false);
 671        let Some(git_state) = self.git_state(cx).cloned() else {
 672            return;
 673        };
 674        git_state.update(cx, |git_state, _| git_state.unstage_all());
 675    }
 676
 677    fn discard_all(&mut self, _: &git::RevertAll, _cx: &mut ViewContext<Self>) {
 678        // TODO: Implement discard all
 679        println!("Discard all triggered");
 680    }
 681
 682    fn clear_message(&mut self, cx: &mut ViewContext<Self>) {
 683        let Some(git_state) = self.git_state(cx).cloned() else {
 684            return;
 685        };
 686        git_state.update(cx, |git_state, _| {
 687            git_state.clear_commit_message();
 688        });
 689        self.commit_editor
 690            .update(cx, |editor, cx| editor.set_text("", cx));
 691    }
 692
 693    /// Commit all staged changes
 694    fn commit_changes(&mut self, _: &git::CommitChanges, cx: &mut ViewContext<Self>) {
 695        self.clear_message(cx);
 696
 697        // TODO: Implement commit all staged
 698        println!("Commit staged changes triggered");
 699    }
 700
 701    /// Commit all changes, regardless of whether they are staged or not
 702    fn commit_all_changes(&mut self, _: &git::CommitAllChanges, cx: &mut ViewContext<Self>) {
 703        self.clear_message(cx);
 704
 705        // TODO: Implement commit all changes
 706        println!("Commit all changes triggered");
 707    }
 708
 709    fn no_entries(&self) -> bool {
 710        self.visible_entries.is_empty()
 711    }
 712
 713    fn entry_count(&self) -> usize {
 714        self.visible_entries.len()
 715    }
 716
 717    fn for_each_visible_entry(
 718        &self,
 719        range: Range<usize>,
 720        cx: &mut ViewContext<Self>,
 721        mut callback: impl FnMut(usize, GitListEntry, &mut ViewContext<Self>),
 722    ) {
 723        let visible_entries = &self.visible_entries;
 724
 725        for (ix, entry) in visible_entries
 726            .iter()
 727            .enumerate()
 728            .skip(range.start)
 729            .take(range.end - range.start)
 730        {
 731            let status = entry.status;
 732            let filename = entry
 733                .repo_path
 734                .file_name()
 735                .map(|name| name.to_string_lossy().into_owned())
 736                .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
 737
 738            let details = GitListEntry {
 739                repo_path: entry.repo_path.clone(),
 740                status,
 741                depth: 0,
 742                display_name: filename,
 743                is_staged: entry.is_staged,
 744            };
 745
 746            callback(ix, details, cx);
 747        }
 748    }
 749
 750    fn schedule_update(&mut self) {
 751        self.rebuild_requested.store(true, Ordering::Relaxed);
 752    }
 753
 754    #[track_caller]
 755    fn update_visible_entries(&mut self, cx: &mut ViewContext<Self>) {
 756        self.visible_entries.clear();
 757
 758        let Some((_, repo, _)) = self.active_repository(cx) else {
 759            // Just clear entries if no repository is active.
 760            cx.notify();
 761            return;
 762        };
 763
 764        // First pass - collect all paths
 765        let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path));
 766
 767        // Second pass - create entries with proper depth calculation
 768        let mut all_staged = None;
 769        for (ix, entry) in repo.status().enumerate() {
 770            let (depth, difference) =
 771                Self::calculate_depth_and_difference(&entry.repo_path, &path_set);
 772            let is_staged = entry.status.is_staged();
 773            all_staged = if ix == 0 {
 774                is_staged
 775            } else {
 776                match (all_staged, is_staged) {
 777                    (None, _) | (_, None) => None,
 778                    (Some(a), Some(b)) => (a == b).then_some(a),
 779                }
 780            };
 781
 782            let display_name = if difference > 1 {
 783                // Show partial path for deeply nested files
 784                entry
 785                    .repo_path
 786                    .as_ref()
 787                    .iter()
 788                    .skip(entry.repo_path.components().count() - difference)
 789                    .collect::<PathBuf>()
 790                    .to_string_lossy()
 791                    .into_owned()
 792            } else {
 793                // Just show filename
 794                entry
 795                    .repo_path
 796                    .file_name()
 797                    .map(|name| name.to_string_lossy().into_owned())
 798                    .unwrap_or_default()
 799            };
 800
 801            let entry = GitListEntry {
 802                depth,
 803                display_name,
 804                repo_path: entry.repo_path,
 805                status: entry.status,
 806                is_staged,
 807            };
 808
 809            self.visible_entries.push(entry);
 810        }
 811        self.all_staged = all_staged;
 812
 813        // Sort entries by path to maintain consistent order
 814        self.visible_entries
 815            .sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
 816
 817        self.select_first_entry_if_none(cx);
 818
 819        cx.notify();
 820    }
 821
 822    fn on_buffer_event(
 823        &mut self,
 824        _buffer: Model<Buffer>,
 825        event: &language::BufferEvent,
 826        cx: &mut ViewContext<Self>,
 827    ) {
 828        if let language::BufferEvent::Reparsed | language::BufferEvent::Edited = event {
 829            let commit_message = self.commit_editor.update(cx, |editor, cx| editor.text(cx));
 830
 831            let Some(git_state) = self.git_state(cx).cloned() else {
 832                return;
 833            };
 834            git_state.update(cx, |git_state, _| {
 835                git_state.commit_message = Some(commit_message.into())
 836            });
 837
 838            cx.notify();
 839        }
 840    }
 841}
 842
 843// GitPanel –– Render
 844impl GitPanel {
 845    pub fn panel_button(
 846        &self,
 847        id: impl Into<SharedString>,
 848        label: impl Into<SharedString>,
 849    ) -> Button {
 850        let id = id.into().clone();
 851        let label = label.into().clone();
 852
 853        Button::new(id, label)
 854            .label_size(LabelSize::Small)
 855            .layer(ElevationIndex::ElevatedSurface)
 856            .size(ButtonSize::Compact)
 857            .style(ButtonStyle::Filled)
 858    }
 859
 860    pub fn render_divider(&self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
 861        h_flex()
 862            .items_center()
 863            .h(px(8.))
 864            .child(Divider::horizontal_dashed().color(DividerColor::Border))
 865    }
 866
 867    pub fn render_panel_header(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
 868        let focus_handle = self.focus_handle(cx).clone();
 869
 870        let changes_string = match self.entry_count() {
 871            0 => "No changes".to_string(),
 872            1 => "1 change".to_string(),
 873            n => format!("{} changes", n),
 874        };
 875
 876        // for our use case treat None as false
 877        let all_staged = self.all_staged.unwrap_or(false);
 878
 879        h_flex()
 880            .h(px(32.))
 881            .items_center()
 882            .px_2()
 883            .bg(ElevationIndex::Surface.bg(cx))
 884            .child(
 885                h_flex()
 886                    .gap_2()
 887                    .child(
 888                        Checkbox::new(
 889                            "all-changes",
 890                            if self.no_entries() {
 891                                ToggleState::Selected
 892                            } else {
 893                                self.all_staged
 894                                    .map_or(ToggleState::Indeterminate, ToggleState::from)
 895                            },
 896                        )
 897                        .fill()
 898                        .elevation(ElevationIndex::Surface)
 899                        .tooltip(move |cx| {
 900                            if all_staged {
 901                                Tooltip::text("Unstage all changes", cx)
 902                            } else {
 903                                Tooltip::text("Stage all changes", cx)
 904                            }
 905                        })
 906                        .on_click(cx.listener(move |git_panel, _, cx| match all_staged {
 907                            true => git_panel.unstage_all(&UnstageAll, cx),
 908                            false => git_panel.stage_all(&StageAll, cx),
 909                        })),
 910                    )
 911                    .child(div().text_buffer(cx).text_ui_sm(cx).child(changes_string)),
 912            )
 913            .child(div().flex_grow())
 914            .child(
 915                h_flex()
 916                    .gap_2()
 917                    // TODO: Re-add once revert all is added
 918                    // .child(
 919                    //     IconButton::new("discard-changes", IconName::Undo)
 920                    //         .tooltip({
 921                    //             let focus_handle = focus_handle.clone();
 922                    //             move |cx| {
 923                    //                 Tooltip::for_action_in(
 924                    //                     "Discard all changes",
 925                    //                     &RevertAll,
 926                    //                     &focus_handle,
 927                    //                     cx,
 928                    //                 )
 929                    //             }
 930                    //         })
 931                    //         .icon_size(IconSize::Small)
 932                    //         .disabled(true),
 933                    // )
 934                    .child(if self.all_staged.unwrap_or(false) {
 935                        self.panel_button("unstage-all", "Unstage All")
 936                            .tooltip({
 937                                let focus_handle = focus_handle.clone();
 938                                move |cx| {
 939                                    Tooltip::for_action_in(
 940                                        "Unstage all changes",
 941                                        &UnstageAll,
 942                                        &focus_handle,
 943                                        cx,
 944                                    )
 945                                }
 946                            })
 947                            .key_binding(ui::KeyBinding::for_action_in(
 948                                &UnstageAll,
 949                                &focus_handle,
 950                                cx,
 951                            ))
 952                            .on_click(
 953                                cx.listener(move |this, _, cx| this.unstage_all(&UnstageAll, cx)),
 954                            )
 955                    } else {
 956                        self.panel_button("stage-all", "Stage All")
 957                            .tooltip({
 958                                let focus_handle = focus_handle.clone();
 959                                move |cx| {
 960                                    Tooltip::for_action_in(
 961                                        "Stage all changes",
 962                                        &StageAll,
 963                                        &focus_handle,
 964                                        cx,
 965                                    )
 966                                }
 967                            })
 968                            .key_binding(ui::KeyBinding::for_action_in(
 969                                &StageAll,
 970                                &focus_handle,
 971                                cx,
 972                            ))
 973                            .on_click(cx.listener(move |this, _, cx| this.stage_all(&StageAll, cx)))
 974                    }),
 975            )
 976    }
 977
 978    pub fn render_commit_editor(&self, cx: &ViewContext<Self>) -> impl IntoElement {
 979        let editor = self.commit_editor.clone();
 980        let editor_focus_handle = editor.read(cx).focus_handle(cx).clone();
 981
 982        let focus_handle_1 = self.focus_handle(cx).clone();
 983        let focus_handle_2 = self.focus_handle(cx).clone();
 984
 985        let commit_staged_button = self
 986            .panel_button("commit-staged-changes", "Commit")
 987            .tooltip(move |cx| {
 988                let focus_handle = focus_handle_1.clone();
 989                Tooltip::for_action_in(
 990                    "Commit all staged changes",
 991                    &CommitChanges,
 992                    &focus_handle,
 993                    cx,
 994                )
 995            })
 996            .on_click(
 997                cx.listener(|this, _: &ClickEvent, cx| this.commit_changes(&CommitChanges, cx)),
 998            );
 999
1000        let commit_all_button = self
1001            .panel_button("commit-all-changes", "Commit All")
1002            .tooltip(move |cx| {
1003                let focus_handle = focus_handle_2.clone();
1004                Tooltip::for_action_in(
1005                    "Commit all changes, including unstaged changes",
1006                    &CommitAllChanges,
1007                    &focus_handle,
1008                    cx,
1009                )
1010            })
1011            .on_click(cx.listener(|this, _: &ClickEvent, cx| {
1012                this.commit_all_changes(&CommitAllChanges, cx)
1013            }));
1014
1015        div().w_full().h(px(140.)).px_2().pt_1().pb_2().child(
1016            v_flex()
1017                .id("commit-editor-container")
1018                .relative()
1019                .h_full()
1020                .py_2p5()
1021                .px_3()
1022                .bg(cx.theme().colors().editor_background)
1023                .on_click(cx.listener(move |_, _: &ClickEvent, cx| cx.focus(&editor_focus_handle)))
1024                .child(self.commit_editor.clone())
1025                .child(
1026                    h_flex()
1027                        .absolute()
1028                        .bottom_2p5()
1029                        .right_3()
1030                        .child(div().gap_1().flex_grow())
1031                        .child(if self.current_modifiers.alt {
1032                            commit_all_button
1033                        } else {
1034                            commit_staged_button
1035                        }),
1036                ),
1037        )
1038    }
1039
1040    fn render_empty_state(&self, cx: &ViewContext<Self>) -> impl IntoElement {
1041        h_flex()
1042            .h_full()
1043            .flex_1()
1044            .justify_center()
1045            .items_center()
1046            .child(
1047                v_flex()
1048                    .gap_3()
1049                    .child("No changes to commit")
1050                    .text_ui_sm(cx)
1051                    .mx_auto()
1052                    .text_color(Color::Placeholder.color(cx)),
1053            )
1054    }
1055
1056    fn render_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
1057        let scroll_bar_style = self.show_scrollbar(cx);
1058        let show_container = matches!(scroll_bar_style, ShowScrollbar::Always);
1059
1060        if !self.should_show_scrollbar(cx)
1061            || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
1062        {
1063            return None;
1064        }
1065
1066        Some(
1067            div()
1068                .id("git-panel-vertical-scroll")
1069                .occlude()
1070                .flex_none()
1071                .h_full()
1072                .cursor_default()
1073                .when(show_container, |this| this.pl_1().px_1p5())
1074                .when(!show_container, |this| {
1075                    this.absolute().right_1().top_1().bottom_1().w(px(12.))
1076                })
1077                .on_mouse_move(cx.listener(|_, _, cx| {
1078                    cx.notify();
1079                    cx.stop_propagation()
1080                }))
1081                .on_hover(|_, cx| {
1082                    cx.stop_propagation();
1083                })
1084                .on_any_mouse_down(|_, cx| {
1085                    cx.stop_propagation();
1086                })
1087                .on_mouse_up(
1088                    MouseButton::Left,
1089                    cx.listener(|this, _, cx| {
1090                        if !this.scrollbar_state.is_dragging()
1091                            && !this.focus_handle.contains_focused(cx)
1092                        {
1093                            this.hide_scrollbar(cx);
1094                            cx.notify();
1095                        }
1096
1097                        cx.stop_propagation();
1098                    }),
1099                )
1100                .on_scroll_wheel(cx.listener(|_, _, cx| {
1101                    cx.notify();
1102                }))
1103                .children(Scrollbar::vertical(
1104                    // percentage as f32..end_offset as f32,
1105                    self.scrollbar_state.clone(),
1106                )),
1107        )
1108    }
1109
1110    fn render_entries(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1111        let entry_count = self.entry_count();
1112        h_flex()
1113            .size_full()
1114            .overflow_hidden()
1115            .child(
1116                uniform_list(cx.view().clone(), "entries", entry_count, {
1117                    move |git_panel, range, cx| {
1118                        let mut items = Vec::with_capacity(range.end - range.start);
1119                        git_panel.for_each_visible_entry(range, cx, |ix, details, cx| {
1120                            items.push(git_panel.render_entry(ix, details, cx));
1121                        });
1122                        items
1123                    }
1124                })
1125                .size_full()
1126                .with_sizing_behavior(ListSizingBehavior::Infer)
1127                .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
1128                // .with_width_from_item(self.max_width_item_index)
1129                .track_scroll(self.scroll_handle.clone()),
1130            )
1131            .children(self.render_scrollbar(cx))
1132    }
1133
1134    fn render_entry(
1135        &self,
1136        ix: usize,
1137        entry_details: GitListEntry,
1138        cx: &ViewContext<Self>,
1139    ) -> impl IntoElement {
1140        let repo_path = entry_details.repo_path.clone();
1141        let selected = self.selected_entry == Some(ix);
1142        let status_style = GitPanelSettings::get_global(cx).status_style;
1143        let status = entry_details.status;
1144
1145        let mut label_color = cx.theme().colors().text;
1146        if status_style == StatusStyle::LabelColor {
1147            label_color = if status.is_conflicted() {
1148                cx.theme().status().conflict
1149            } else if status.is_modified() {
1150                cx.theme().status().modified
1151            } else if status.is_deleted() {
1152                cx.theme().colors().text_disabled
1153            } else {
1154                cx.theme().status().created
1155            }
1156        }
1157
1158        let path_color = status
1159            .is_deleted()
1160            .then_some(cx.theme().colors().text_disabled)
1161            .unwrap_or(cx.theme().colors().text_muted);
1162
1163        let entry_id = ElementId::Name(format!("entry_{}", entry_details.display_name).into());
1164        let checkbox_id =
1165            ElementId::Name(format!("checkbox_{}", entry_details.display_name).into());
1166        let is_tree_view = false;
1167        let handle = cx.view().downgrade();
1168
1169        let end_slot = h_flex()
1170            .invisible()
1171            .when(selected, |this| this.visible())
1172            .when(!selected, |this| {
1173                this.group_hover("git-panel-entry", |this| this.visible())
1174            })
1175            .gap_1()
1176            .items_center()
1177            .child(
1178                IconButton::new("more", IconName::EllipsisVertical)
1179                    .icon_color(Color::Placeholder)
1180                    .icon_size(IconSize::Small),
1181            );
1182
1183        let mut entry = h_flex()
1184            .id(entry_id)
1185            .group("git-panel-entry")
1186            .h(px(28.))
1187            .w_full()
1188            .pr(px(4.))
1189            .items_center()
1190            .gap_2()
1191            .font_buffer(cx)
1192            .text_ui_sm(cx)
1193            .when(!selected, |this| {
1194                this.hover(|this| this.bg(cx.theme().colors().ghost_element_hover))
1195            });
1196
1197        if is_tree_view {
1198            entry = entry.pl(px(8. + 12. * entry_details.depth as f32))
1199        } else {
1200            entry = entry.pl(px(8.))
1201        }
1202
1203        if selected {
1204            entry = entry.bg(cx.theme().status().info_background);
1205        }
1206
1207        entry = entry
1208            .child(
1209                Checkbox::new(
1210                    checkbox_id,
1211                    entry_details
1212                        .is_staged
1213                        .map_or(ToggleState::Indeterminate, ToggleState::from),
1214                )
1215                .fill()
1216                .elevation(ElevationIndex::Surface)
1217                .on_click({
1218                    let handle = handle.clone();
1219                    let repo_path = repo_path.clone();
1220                    move |toggle, cx| {
1221                        let Some(this) = handle.upgrade() else {
1222                            return;
1223                        };
1224                        this.update(cx, |this, cx| {
1225                            this.visible_entries[ix].is_staged = match *toggle {
1226                                ToggleState::Selected => Some(true),
1227                                ToggleState::Unselected => Some(false),
1228                                ToggleState::Indeterminate => None,
1229                            };
1230                            let repo_path = repo_path.clone();
1231                            let Some(git_state) = this.git_state(cx).cloned() else {
1232                                return;
1233                            };
1234                            git_state.update(cx, |git_state, _| match toggle {
1235                                ToggleState::Selected | ToggleState::Indeterminate => {
1236                                    git_state.stage_entry(repo_path);
1237                                }
1238                                ToggleState::Unselected => git_state.unstage_entry(repo_path),
1239                            })
1240                        });
1241                    }
1242                }),
1243            )
1244            .when(status_style == StatusStyle::Icon, |this| {
1245                this.child(git_status_icon(status))
1246            })
1247            .child(
1248                h_flex()
1249                    .text_color(label_color)
1250                    .when(status.is_deleted(), |this| this.line_through())
1251                    .when_some(repo_path.parent(), |this, parent| {
1252                        let parent_str = parent.to_string_lossy();
1253                        if !parent_str.is_empty() {
1254                            this.child(
1255                                div()
1256                                    .text_color(path_color)
1257                                    .child(format!("{}/", parent_str)),
1258                            )
1259                        } else {
1260                            this
1261                        }
1262                    })
1263                    .child(div().child(entry_details.display_name.clone())),
1264            )
1265            .child(div().flex_1())
1266            .child(end_slot)
1267            .on_click(move |_, cx| {
1268                // TODO: add `select_entry` method then do after that
1269                cx.dispatch_action(Box::new(OpenSelected));
1270
1271                handle
1272                    .update(cx, |git_panel, _| {
1273                        git_panel.selected_entry = Some(ix);
1274                    })
1275                    .ok();
1276            });
1277
1278        entry
1279    }
1280}
1281
1282impl Render for GitPanel {
1283    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1284        let project = self.project.read(cx);
1285
1286        v_flex()
1287            .id("git_panel")
1288            .key_context(self.dispatch_context(cx))
1289            .track_focus(&self.focus_handle)
1290            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
1291            .when(!project.is_read_only(cx), |this| {
1292                this.on_action(cx.listener(|this, &ToggleStaged, cx| {
1293                    this.toggle_staged_for_selected(&ToggleStaged, cx)
1294                }))
1295                .on_action(cx.listener(|this, &StageAll, cx| this.stage_all(&StageAll, cx)))
1296                .on_action(cx.listener(|this, &UnstageAll, cx| this.unstage_all(&UnstageAll, cx)))
1297                .on_action(cx.listener(|this, &RevertAll, cx| this.discard_all(&RevertAll, cx)))
1298                .on_action(
1299                    cx.listener(|this, &CommitChanges, cx| this.commit_changes(&CommitChanges, cx)),
1300                )
1301                .on_action(cx.listener(|this, &CommitAllChanges, cx| {
1302                    this.commit_all_changes(&CommitAllChanges, cx)
1303                }))
1304            })
1305            .when(self.is_focused(cx), |this| {
1306                this.on_action(cx.listener(Self::select_first))
1307                    .on_action(cx.listener(Self::select_next))
1308                    .on_action(cx.listener(Self::select_prev))
1309                    .on_action(cx.listener(Self::select_last))
1310                    .on_action(cx.listener(Self::close_panel))
1311            })
1312            .on_action(cx.listener(Self::open_selected))
1313            .on_action(cx.listener(Self::focus_changes_list))
1314            .on_action(cx.listener(Self::focus_editor))
1315            .on_action(cx.listener(Self::toggle_staged_for_selected))
1316            // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx)))
1317            .on_hover(cx.listener(|this, hovered, cx| {
1318                if *hovered {
1319                    this.show_scrollbar = true;
1320                    this.hide_scrollbar_task.take();
1321                    cx.notify();
1322                } else if !this.focus_handle.contains_focused(cx) {
1323                    this.hide_scrollbar(cx);
1324                }
1325            }))
1326            .size_full()
1327            .overflow_hidden()
1328            .font_buffer(cx)
1329            .py_1()
1330            .bg(ElevationIndex::Surface.bg(cx))
1331            .child(self.render_panel_header(cx))
1332            .child(self.render_divider(cx))
1333            .child(if !self.no_entries() {
1334                self.render_entries(cx).into_any_element()
1335            } else {
1336                self.render_empty_state(cx).into_any_element()
1337            })
1338            .child(self.render_divider(cx))
1339            .child(self.render_commit_editor(cx))
1340    }
1341}
1342
1343impl FocusableView for GitPanel {
1344    fn focus_handle(&self, _: &AppContext) -> gpui::FocusHandle {
1345        self.focus_handle.clone()
1346    }
1347}
1348
1349impl EventEmitter<Event> for GitPanel {}
1350
1351impl EventEmitter<PanelEvent> for GitPanel {}
1352
1353impl Panel for GitPanel {
1354    fn persistent_name() -> &'static str {
1355        "GitPanel"
1356    }
1357
1358    fn position(&self, cx: &WindowContext) -> DockPosition {
1359        GitPanelSettings::get_global(cx).dock
1360    }
1361
1362    fn position_is_valid(&self, position: DockPosition) -> bool {
1363        matches!(position, DockPosition::Left | DockPosition::Right)
1364    }
1365
1366    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1367        settings::update_settings_file::<GitPanelSettings>(
1368            self.fs.clone(),
1369            cx,
1370            move |settings, _| settings.dock = Some(position),
1371        );
1372    }
1373
1374    fn size(&self, cx: &WindowContext) -> Pixels {
1375        self.width
1376            .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
1377    }
1378
1379    fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
1380        self.width = size;
1381        self.serialize(cx);
1382        cx.notify();
1383    }
1384
1385    fn icon(&self, cx: &WindowContext) -> Option<ui::IconName> {
1386        Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
1387    }
1388
1389    fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
1390        Some("Git Panel")
1391    }
1392
1393    fn toggle_action(&self) -> Box<dyn Action> {
1394        Box::new(ToggleFocus)
1395    }
1396
1397    fn activation_priority(&self) -> u32 {
1398        2
1399    }
1400}