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        has_write_access: bool,
 831        cx: &mut Context<Self>,
 832    ) -> impl IntoElement {
 833        let focus_handle = self.focus_handle(cx).clone();
 834        let entry_count = self
 835            .active_repository
 836            .as_ref()
 837            .map_or(0, RepositoryHandle::entry_count);
 838
 839        let changes_string = match entry_count {
 840            0 => "No changes".to_string(),
 841            1 => "1 change".to_string(),
 842            n => format!("{} changes", n),
 843        };
 844
 845        // for our use case treat None as false
 846        let all_staged = self.all_staged.unwrap_or(false);
 847
 848        h_flex()
 849            .h(px(32.))
 850            .items_center()
 851            .px_2()
 852            .bg(ElevationIndex::Surface.bg(cx))
 853            .child(
 854                h_flex()
 855                    .gap_2()
 856                    .child(
 857                        Checkbox::new(
 858                            "all-changes",
 859                            if entry_count == 0 {
 860                                ToggleState::Selected
 861                            } else {
 862                                self.all_staged
 863                                    .map_or(ToggleState::Indeterminate, ToggleState::from)
 864                            },
 865                        )
 866                        .fill()
 867                        .elevation(ElevationIndex::Surface)
 868                        .tooltip(if all_staged {
 869                            Tooltip::text("Unstage all changes")
 870                        } else {
 871                            Tooltip::text("Stage all changes")
 872                        })
 873                        .disabled(!has_write_access || entry_count == 0)
 874                        .on_click(cx.listener(
 875                            move |git_panel, _, window, cx| match all_staged {
 876                                true => git_panel.unstage_all(&UnstageAll, window, cx),
 877                                false => git_panel.stage_all(&StageAll, window, cx),
 878                            },
 879                        )),
 880                    )
 881                    .child(
 882                        div()
 883                            .id("changes-checkbox-label")
 884                            .text_buffer(cx)
 885                            .text_ui_sm(cx)
 886                            .child(changes_string)
 887                            .on_click(cx.listener(
 888                                move |git_panel, _, window, cx| match all_staged {
 889                                    true => git_panel.unstage_all(&UnstageAll, window, cx),
 890                                    false => git_panel.stage_all(&StageAll, window, cx),
 891                                },
 892                            )),
 893                    ),
 894            )
 895            .child(div().flex_grow())
 896            .child(
 897                h_flex()
 898                    .gap_2()
 899                    // TODO: Re-add once revert all is added
 900                    // .child(
 901                    //     IconButton::new("discard-changes", IconName::Undo)
 902                    //         .tooltip({
 903                    //             let focus_handle = focus_handle.clone();
 904                    //             move |cx| {
 905                    //                 Tooltip::for_action_in(
 906                    //                     "Discard all changes",
 907                    //                     &RevertAll,
 908                    //                     &focus_handle,
 909                    //                     cx,
 910                    //                 )
 911                    //             }
 912                    //         })
 913                    //         .icon_size(IconSize::Small)
 914                    //         .disabled(true),
 915                    // )
 916                    .child(if self.all_staged.unwrap_or(false) {
 917                        self.panel_button("unstage-all", "Unstage All")
 918                            .tooltip({
 919                                let focus_handle = focus_handle.clone();
 920                                move |window, cx| {
 921                                    Tooltip::for_action_in(
 922                                        "Unstage all changes",
 923                                        &UnstageAll,
 924                                        &focus_handle,
 925                                        window,
 926                                        cx,
 927                                    )
 928                                }
 929                            })
 930                            .key_binding(ui::KeyBinding::for_action_in(
 931                                &UnstageAll,
 932                                &focus_handle,
 933                                window,
 934                            ))
 935                            .on_click(cx.listener(move |this, _, window, cx| {
 936                                this.unstage_all(&UnstageAll, window, cx)
 937                            }))
 938                    } else {
 939                        self.panel_button("stage-all", "Stage All")
 940                            .tooltip({
 941                                let focus_handle = focus_handle.clone();
 942                                move |window, cx| {
 943                                    Tooltip::for_action_in(
 944                                        "Stage all changes",
 945                                        &StageAll,
 946                                        &focus_handle,
 947                                        window,
 948                                        cx,
 949                                    )
 950                                }
 951                            })
 952                            .key_binding(ui::KeyBinding::for_action_in(
 953                                &StageAll,
 954                                &focus_handle,
 955                                window,
 956                            ))
 957                            .on_click(cx.listener(move |this, _, window, cx| {
 958                                this.stage_all(&StageAll, window, cx)
 959                            }))
 960                    }),
 961            )
 962    }
 963
 964    pub fn render_commit_editor(
 965        &self,
 966        has_write_access: bool,
 967        cx: &Context<Self>,
 968    ) -> impl IntoElement {
 969        let editor = self.commit_editor.clone();
 970        let editor_focus_handle = editor.read(cx).focus_handle(cx).clone();
 971        let (can_commit, can_commit_all) =
 972            self.active_repository
 973                .as_ref()
 974                .map_or((false, false), |active_repository| {
 975                    (
 976                        has_write_access && active_repository.can_commit(false, cx),
 977                        has_write_access && active_repository.can_commit(true, cx),
 978                    )
 979                });
 980
 981        let focus_handle_1 = self.focus_handle(cx).clone();
 982        let focus_handle_2 = self.focus_handle(cx).clone();
 983
 984        let commit_staged_button = self
 985            .panel_button("commit-staged-changes", "Commit")
 986            .tooltip(move |window, cx| {
 987                let focus_handle = focus_handle_1.clone();
 988                Tooltip::for_action_in(
 989                    "Commit all staged changes",
 990                    &CommitChanges,
 991                    &focus_handle,
 992                    window,
 993                    cx,
 994                )
 995            })
 996            .disabled(!can_commit)
 997            .on_click(cx.listener(|this, _: &ClickEvent, window, cx| {
 998                this.commit_changes(&CommitChanges, window, cx)
 999            }));
1000
1001        let commit_all_button = self
1002            .panel_button("commit-all-changes", "Commit All")
1003            .tooltip(move |window, cx| {
1004                let focus_handle = focus_handle_2.clone();
1005                Tooltip::for_action_in(
1006                    "Commit all changes, including unstaged changes",
1007                    &CommitAllChanges,
1008                    &focus_handle,
1009                    window,
1010                    cx,
1011                )
1012            })
1013            .disabled(!can_commit_all)
1014            .on_click(cx.listener(|this, _: &ClickEvent, window, cx| {
1015                this.commit_all_changes(&CommitAllChanges, window, cx)
1016            }));
1017
1018        div().w_full().h(px(140.)).px_2().pt_1().pb_2().child(
1019            v_flex()
1020                .id("commit-editor-container")
1021                .relative()
1022                .h_full()
1023                .py_2p5()
1024                .px_3()
1025                .bg(cx.theme().colors().editor_background)
1026                .on_click(cx.listener(move |_, _: &ClickEvent, window, _cx| {
1027                    window.focus(&editor_focus_handle);
1028                }))
1029                .child(self.commit_editor.clone())
1030                .child(
1031                    h_flex()
1032                        .absolute()
1033                        .bottom_2p5()
1034                        .right_3()
1035                        .child(div().gap_1().flex_grow())
1036                        .child(if self.current_modifiers.alt {
1037                            commit_all_button
1038                        } else {
1039                            commit_staged_button
1040                        }),
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        h_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                // .with_width_from_item(self.max_width_item_index)
1135                .track_scroll(self.scroll_handle.clone()),
1136            )
1137            .children(self.render_scrollbar(cx))
1138    }
1139
1140    fn render_entry(
1141        &self,
1142        ix: usize,
1143        entry_details: GitListEntry,
1144        has_write_access: bool,
1145        cx: &Context<Self>,
1146    ) -> impl IntoElement {
1147        let repo_path = entry_details.repo_path.clone();
1148        let selected = self.selected_entry == Some(ix);
1149        let status_style = GitPanelSettings::get_global(cx).status_style;
1150        let status = entry_details.status;
1151
1152        let mut label_color = cx.theme().colors().text;
1153        if status_style == StatusStyle::LabelColor {
1154            label_color = if status.is_conflicted() {
1155                cx.theme().colors().version_control_conflict
1156            } else if status.is_modified() {
1157                cx.theme().colors().version_control_modified
1158            } else if status.is_deleted() {
1159                // Don't use `version_control_deleted` here or all the
1160                // deleted entries will be likely a red color.
1161                cx.theme().colors().text_disabled
1162            } else {
1163                cx.theme().colors().version_control_added
1164            }
1165        }
1166
1167        let path_color = status
1168            .is_deleted()
1169            .then_some(cx.theme().colors().text_disabled)
1170            .unwrap_or(cx.theme().colors().text_muted);
1171
1172        let entry_id = ElementId::Name(format!("entry_{}", entry_details.display_name).into());
1173        let checkbox_id =
1174            ElementId::Name(format!("checkbox_{}", entry_details.display_name).into());
1175        let is_tree_view = false;
1176        let handle = cx.entity().downgrade();
1177
1178        let end_slot = h_flex()
1179            .invisible()
1180            .when(selected, |this| this.visible())
1181            .when(!selected, |this| {
1182                this.group_hover("git-panel-entry", |this| this.visible())
1183            })
1184            .gap_1()
1185            .items_center()
1186            .child(
1187                IconButton::new("more", IconName::EllipsisVertical)
1188                    .icon_color(Color::Placeholder)
1189                    .icon_size(IconSize::Small),
1190            );
1191
1192        let mut entry = h_flex()
1193            .id(entry_id)
1194            .group("git-panel-entry")
1195            .h(px(28.))
1196            .w_full()
1197            .pr(px(4.))
1198            .items_center()
1199            .gap_2()
1200            .font_buffer(cx)
1201            .text_ui_sm(cx)
1202            .when(!selected, |this| {
1203                this.hover(|this| this.bg(cx.theme().colors().ghost_element_hover))
1204            });
1205
1206        if is_tree_view {
1207            entry = entry.pl(px(8. + 12. * entry_details.depth as f32))
1208        } else {
1209            entry = entry.pl(px(8.))
1210        }
1211
1212        if selected {
1213            entry = entry.bg(cx.theme().status().info_background);
1214        }
1215
1216        entry = entry
1217            .child(
1218                Checkbox::new(
1219                    checkbox_id,
1220                    entry_details
1221                        .is_staged
1222                        .map_or(ToggleState::Indeterminate, ToggleState::from),
1223                )
1224                .disabled(!has_write_access)
1225                .fill()
1226                .elevation(ElevationIndex::Surface)
1227                .on_click({
1228                    let handle = handle.clone();
1229                    let repo_path = repo_path.clone();
1230                    move |toggle, _window, cx| {
1231                        let Some(this) = handle.upgrade() else {
1232                            return;
1233                        };
1234                        this.update(cx, |this, cx| {
1235                            this.visible_entries[ix].is_staged = match *toggle {
1236                                ToggleState::Selected => Some(true),
1237                                ToggleState::Unselected => Some(false),
1238                                ToggleState::Indeterminate => None,
1239                            };
1240                            let repo_path = repo_path.clone();
1241                            let Some(active_repository) = this.active_repository.as_ref() else {
1242                                return;
1243                            };
1244                            let result = match toggle {
1245                                ToggleState::Selected | ToggleState::Indeterminate => {
1246                                    active_repository
1247                                        .stage_entries(vec![repo_path], this.err_sender.clone())
1248                                }
1249                                ToggleState::Unselected => active_repository
1250                                    .unstage_entries(vec![repo_path], this.err_sender.clone()),
1251                            };
1252                            if let Err(e) = result {
1253                                this.show_err_toast("toggle staged error", e, cx);
1254                            }
1255                        });
1256                    }
1257                }),
1258            )
1259            .when(status_style == StatusStyle::Icon, |this| {
1260                this.child(git_status_icon(status, cx))
1261            })
1262            .child(
1263                h_flex()
1264                    .text_color(label_color)
1265                    .when(status.is_deleted(), |this| this.line_through())
1266                    .when_some(repo_path.parent(), |this, parent| {
1267                        let parent_str = parent.to_string_lossy();
1268                        if !parent_str.is_empty() {
1269                            this.child(
1270                                div()
1271                                    .text_color(path_color)
1272                                    .child(format!("{}/", parent_str)),
1273                            )
1274                        } else {
1275                            this
1276                        }
1277                    })
1278                    .child(div().child(entry_details.display_name.clone())),
1279            )
1280            .child(div().flex_1())
1281            .child(end_slot)
1282            .on_click(move |_, window, cx| {
1283                // TODO: add `select_entry` method then do after that
1284                window.dispatch_action(Box::new(OpenSelected), cx);
1285
1286                handle
1287                    .update(cx, |git_panel, _| {
1288                        git_panel.selected_entry = Some(ix);
1289                    })
1290                    .ok();
1291            });
1292
1293        entry
1294    }
1295}
1296
1297impl Render for GitPanel {
1298    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1299        let project = self.project.read(cx);
1300        let has_entries = self
1301            .active_repository
1302            .as_ref()
1303            .map_or(false, |active_repository| {
1304                active_repository.entry_count() > 0
1305            });
1306        let room = self
1307            .workspace
1308            .upgrade()
1309            .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
1310
1311        let has_write_access = room
1312            .as_ref()
1313            .map_or(true, |room| room.read(cx).local_participant().can_write());
1314
1315        let has_co_authors = room.map_or(false, |room| {
1316            has_write_access
1317                && room
1318                    .read(cx)
1319                    .remote_participants()
1320                    .values()
1321                    .any(|remote_participant| remote_participant.can_write())
1322        });
1323
1324        v_flex()
1325            .id("git_panel")
1326            .key_context(self.dispatch_context(window, cx))
1327            .track_focus(&self.focus_handle)
1328            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
1329            .when(!project.is_read_only(cx), |this| {
1330                this.on_action(cx.listener(|this, &ToggleStaged, window, cx| {
1331                    this.toggle_staged_for_selected(&ToggleStaged, window, cx)
1332                }))
1333                .on_action(
1334                    cx.listener(|this, &StageAll, window, cx| {
1335                        this.stage_all(&StageAll, window, cx)
1336                    }),
1337                )
1338                .on_action(cx.listener(|this, &UnstageAll, window, cx| {
1339                    this.unstage_all(&UnstageAll, window, cx)
1340                }))
1341                .on_action(cx.listener(|this, &RevertAll, window, cx| {
1342                    this.discard_all(&RevertAll, window, cx)
1343                }))
1344                .on_action(cx.listener(|this, &CommitChanges, window, cx| {
1345                    this.commit_changes(&CommitChanges, window, cx)
1346                }))
1347                .on_action(cx.listener(|this, &CommitAllChanges, window, cx| {
1348                    this.commit_all_changes(&CommitAllChanges, window, cx)
1349                }))
1350            })
1351            .when(self.is_focused(window, cx), |this| {
1352                this.on_action(cx.listener(Self::select_first))
1353                    .on_action(cx.listener(Self::select_next))
1354                    .on_action(cx.listener(Self::select_prev))
1355                    .on_action(cx.listener(Self::select_last))
1356                    .on_action(cx.listener(Self::close_panel))
1357            })
1358            .on_action(cx.listener(Self::open_selected))
1359            .on_action(cx.listener(Self::focus_changes_list))
1360            .on_action(cx.listener(Self::focus_editor))
1361            .on_action(cx.listener(Self::toggle_staged_for_selected))
1362            .when(has_co_authors, |git_panel| {
1363                git_panel.on_action(cx.listener(Self::fill_co_authors))
1364            })
1365            // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx)))
1366            .on_hover(cx.listener(|this, hovered, window, cx| {
1367                if *hovered {
1368                    this.show_scrollbar = true;
1369                    this.hide_scrollbar_task.take();
1370                    cx.notify();
1371                } else if !this.focus_handle.contains_focused(window, cx) {
1372                    this.hide_scrollbar(window, cx);
1373                }
1374            }))
1375            .size_full()
1376            .overflow_hidden()
1377            .font_buffer(cx)
1378            .py_1()
1379            .bg(ElevationIndex::Surface.bg(cx))
1380            .child(self.render_panel_header(window, has_write_access, cx))
1381            .child(self.render_divider(cx))
1382            .child(if has_entries {
1383                self.render_entries(has_write_access, cx).into_any_element()
1384            } else {
1385                self.render_empty_state(cx).into_any_element()
1386            })
1387            .child(self.render_divider(cx))
1388            .child(self.render_commit_editor(has_write_access, cx))
1389    }
1390}
1391
1392impl Focusable for GitPanel {
1393    fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
1394        self.focus_handle.clone()
1395    }
1396}
1397
1398impl EventEmitter<Event> for GitPanel {}
1399
1400impl EventEmitter<PanelEvent> for GitPanel {}
1401
1402impl Panel for GitPanel {
1403    fn persistent_name() -> &'static str {
1404        "GitPanel"
1405    }
1406
1407    fn position(&self, _: &Window, cx: &App) -> DockPosition {
1408        GitPanelSettings::get_global(cx).dock
1409    }
1410
1411    fn position_is_valid(&self, position: DockPosition) -> bool {
1412        matches!(position, DockPosition::Left | DockPosition::Right)
1413    }
1414
1415    fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
1416        settings::update_settings_file::<GitPanelSettings>(
1417            self.fs.clone(),
1418            cx,
1419            move |settings, _| settings.dock = Some(position),
1420        );
1421    }
1422
1423    fn size(&self, _: &Window, cx: &App) -> Pixels {
1424        self.width
1425            .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
1426    }
1427
1428    fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
1429        self.width = size;
1430        self.serialize(cx);
1431        cx.notify();
1432    }
1433
1434    fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
1435        Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
1436    }
1437
1438    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
1439        Some("Git Panel")
1440    }
1441
1442    fn toggle_action(&self) -> Box<dyn Action> {
1443        Box::new(ToggleFocus)
1444    }
1445
1446    fn activation_priority(&self) -> u32 {
1447        2
1448    }
1449}