git_panel.rs

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