git_panel.rs

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