git_panel.rs

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