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