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: 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 mut git_state = GitState::new(cx);
 116        let current_commit_message = git_state.commit_message.clone();
 117
 118        let git_panel = cx.new_view(|cx: &mut ViewContext<Self>| {
 119            let focus_handle = cx.focus_handle();
 120            cx.on_focus(&focus_handle, Self::focus_in).detach();
 121            cx.on_focus_out(&focus_handle, |this, _, cx| {
 122                this.hide_scrollbar(cx);
 123            })
 124            .detach();
 125            cx.subscribe(&project, move |this, project, event, cx| {
 126                use project::Event;
 127
 128                let git_state = &mut this.git_state;
 129                let first_worktree_id = project.read(cx).worktrees(cx).next().map(|worktree| {
 130                    let snapshot = worktree.read(cx).snapshot();
 131                    snapshot.id()
 132                });
 133                let first_repo_in_project = first_repository_in_project(&project, cx);
 134
 135                match event {
 136                    project::Event::WorktreeRemoved(id) => {
 137                        git_state.all_repositories.remove(id);
 138                        let Some((worktree_id, _, _)) = git_state.active_repository.as_ref() else {
 139                            return;
 140                        };
 141                        if worktree_id == id {
 142                            git_state.active_repository = first_repo_in_project;
 143                            this.schedule_update();
 144                        }
 145                    }
 146                    project::Event::WorktreeOrderChanged => {
 147                        // activate the new first worktree if the first was moved
 148                        let Some(first_id) = first_worktree_id else {
 149                            return;
 150                        };
 151                        if !git_state
 152                            .active_repository
 153                            .as_ref()
 154                            .is_some_and(|(id, _, _)| id == &first_id)
 155                        {
 156                            git_state.active_repository = first_repo_in_project;
 157                            this.schedule_update();
 158                        }
 159                    }
 160                    Event::WorktreeAdded(id) => {
 161                        let Some(worktree) = project.read(cx).worktree_for_id(*id, cx) else {
 162                            return;
 163                        };
 164                        let snapshot = worktree.read(cx).snapshot();
 165                        git_state
 166                            .all_repositories
 167                            .insert(*id, snapshot.repositories().clone());
 168                        let Some(first_id) = first_worktree_id else {
 169                            return;
 170                        };
 171                        if !git_state
 172                            .active_repository
 173                            .as_ref()
 174                            .is_some_and(|(id, _, _)| id == &first_id)
 175                        {
 176                            git_state.active_repository = first_repo_in_project;
 177                            this.schedule_update();
 178                        }
 179                    }
 180                    project::Event::WorktreeUpdatedEntries(id, _) => {
 181                        if git_state
 182                            .active_repository
 183                            .as_ref()
 184                            .is_some_and(|(active_id, _, _)| active_id == id)
 185                        {
 186                            git_state.active_repository = first_repo_in_project;
 187                            this.schedule_update();
 188                        }
 189                    }
 190                    project::Event::WorktreeUpdatedGitRepositories(_) => {
 191                        let Some(first) = first_repo_in_project else {
 192                            return;
 193                        };
 194                        git_state.active_repository = Some(first);
 195                        this.schedule_update();
 196                    }
 197                    project::Event::Closed => {
 198                        this.reveal_in_editor = Task::ready(());
 199                        this.visible_entries.clear();
 200                        // TODO cancel/clear task?
 201                    }
 202                    _ => {}
 203                };
 204            })
 205            .detach();
 206
 207            let commit_editor = cx.new_view(|cx| {
 208                let theme = ThemeSettings::get_global(cx);
 209
 210                let mut text_style = cx.text_style();
 211                let refinement = TextStyleRefinement {
 212                    font_family: Some(theme.buffer_font.family.clone()),
 213                    font_features: Some(FontFeatures::disable_ligatures()),
 214                    font_size: Some(px(12.).into()),
 215                    color: Some(cx.theme().colors().editor_foreground),
 216                    background_color: Some(gpui::transparent_black()),
 217                    ..Default::default()
 218                };
 219
 220                text_style.refine(&refinement);
 221
 222                let mut commit_editor = Editor::auto_height(10, cx);
 223                if let Some(message) = current_commit_message {
 224                    commit_editor.set_text(message, cx);
 225                } else {
 226                    commit_editor.set_text("", cx);
 227                }
 228                commit_editor.set_use_autoclose(false);
 229                commit_editor.set_show_gutter(false, cx);
 230                commit_editor.set_show_wrap_guides(false, cx);
 231                commit_editor.set_show_indent_guides(false, cx);
 232                commit_editor.set_text_style_refinement(refinement);
 233                commit_editor.set_placeholder_text("Enter commit message", cx);
 234                commit_editor
 235            });
 236
 237            let buffer = commit_editor
 238                .read(cx)
 239                .buffer()
 240                .read(cx)
 241                .as_singleton()
 242                .expect("commit editor must be singleton");
 243
 244            cx.subscribe(&buffer, Self::on_buffer_event).detach();
 245
 246            let markdown = language_registry.language_for_name("Markdown");
 247            cx.spawn(|_, mut cx| async move {
 248                let markdown = markdown.await.context("failed to load Markdown language")?;
 249                buffer.update(&mut cx, |buffer, cx| {
 250                    buffer.set_language(Some(markdown), cx)
 251                })
 252            })
 253            .detach_and_log_err(cx);
 254
 255            let scroll_handle = UniformListScrollHandle::new();
 256
 257            let mut visible_worktrees = project.read(cx).visible_worktrees(cx);
 258            let first_worktree = visible_worktrees.next();
 259            drop(visible_worktrees);
 260            if let Some(first_worktree) = first_worktree {
 261                let snapshot = first_worktree.read(cx).snapshot();
 262
 263                if let Some((repo, git_repo)) =
 264                    first_worktree_repository(&project, snapshot.id(), cx)
 265                {
 266                    git_state.activate_repository(snapshot.id(), repo, git_repo);
 267                }
 268            };
 269
 270            let rebuild_requested = Arc::new(AtomicBool::new(false));
 271            let flag = rebuild_requested.clone();
 272            let handle = cx.view().downgrade();
 273            cx.spawn(|_, mut cx| async move {
 274                loop {
 275                    cx.background_executor().timer(UPDATE_DEBOUNCE).await;
 276                    if flag.load(Ordering::Relaxed) {
 277                        if let Some(this) = handle.upgrade() {
 278                            this.update(&mut cx, |this, cx| {
 279                                this.update_visible_entries(cx);
 280                            })
 281                            .ok();
 282                        }
 283                        flag.store(false, Ordering::Relaxed);
 284                    }
 285                }
 286            })
 287            .detach();
 288
 289            let mut git_panel = Self {
 290                focus_handle: cx.focus_handle(),
 291                fs,
 292                pending_serialization: Task::ready(None),
 293                visible_entries: Vec::new(),
 294                all_staged: None,
 295                current_modifiers: cx.modifiers(),
 296                width: Some(px(360.)),
 297                scrollbar_state: ScrollbarState::new(scroll_handle.clone()).parent_view(cx.view()),
 298                scroll_handle,
 299                selected_entry: None,
 300                show_scrollbar: false,
 301                hide_scrollbar_task: None,
 302                rebuild_requested,
 303                commit_editor,
 304                git_state,
 305                reveal_in_editor: Task::ready(()),
 306                project,
 307            };
 308            git_panel.schedule_update();
 309            git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx);
 310            git_panel
 311        });
 312
 313        cx.subscribe(
 314            &git_panel,
 315            move |workspace, _, event: &Event, cx| match event.clone() {
 316                Event::OpenedEntry { path } => {
 317                    workspace
 318                        .open_path_preview(path, None, false, false, cx)
 319                        .detach_and_prompt_err("Failed to open file", cx, |e, _| {
 320                            Some(format!("{e}"))
 321                        });
 322                }
 323                Event::Focus => { /* TODO */ }
 324            },
 325        )
 326        .detach();
 327
 328        git_panel
 329    }
 330
 331    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
 332        // TODO: we can store stage status here
 333        let width = self.width;
 334        self.pending_serialization = cx.background_executor().spawn(
 335            async move {
 336                KEY_VALUE_STORE
 337                    .write_kvp(
 338                        GIT_PANEL_KEY.into(),
 339                        serde_json::to_string(&SerializedGitPanel { width })?,
 340                    )
 341                    .await?;
 342                anyhow::Ok(())
 343            }
 344            .log_err(),
 345        );
 346    }
 347
 348    fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
 349        let mut dispatch_context = KeyContext::new_with_defaults();
 350        dispatch_context.add("GitPanel");
 351
 352        if self.is_focused(cx) {
 353            dispatch_context.add("menu");
 354            dispatch_context.add("ChangesList");
 355        }
 356
 357        if self.commit_editor.read(cx).is_focused(cx) {
 358            dispatch_context.add("CommitEditor");
 359        }
 360
 361        dispatch_context
 362    }
 363
 364    fn is_focused(&self, cx: &ViewContext<Self>) -> bool {
 365        cx.focused()
 366            .map_or(false, |focused| self.focus_handle == focused)
 367    }
 368
 369    fn close_panel(&mut self, _: &Close, cx: &mut ViewContext<Self>) {
 370        cx.emit(PanelEvent::Close);
 371    }
 372
 373    fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
 374        if !self.focus_handle.contains_focused(cx) {
 375            cx.emit(Event::Focus);
 376        }
 377    }
 378
 379    fn show_scrollbar(&self, cx: &mut ViewContext<Self>) -> ShowScrollbar {
 380        GitPanelSettings::get_global(cx)
 381            .scrollbar
 382            .show
 383            .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show)
 384    }
 385
 386    fn should_show_scrollbar(&self, cx: &mut ViewContext<Self>) -> bool {
 387        let show = self.show_scrollbar(cx);
 388        match show {
 389            ShowScrollbar::Auto => true,
 390            ShowScrollbar::System => true,
 391            ShowScrollbar::Always => true,
 392            ShowScrollbar::Never => false,
 393        }
 394    }
 395
 396    fn should_autohide_scrollbar(&self, cx: &mut ViewContext<Self>) -> bool {
 397        let show = self.show_scrollbar(cx);
 398        match show {
 399            ShowScrollbar::Auto => true,
 400            ShowScrollbar::System => cx
 401                .try_global::<ScrollbarAutoHide>()
 402                .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
 403            ShowScrollbar::Always => false,
 404            ShowScrollbar::Never => true,
 405        }
 406    }
 407
 408    fn hide_scrollbar(&mut self, cx: &mut ViewContext<Self>) {
 409        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
 410        if !self.should_autohide_scrollbar(cx) {
 411            return;
 412        }
 413        self.hide_scrollbar_task = Some(cx.spawn(|panel, mut cx| async move {
 414            cx.background_executor()
 415                .timer(SCROLLBAR_SHOW_INTERVAL)
 416                .await;
 417            panel
 418                .update(&mut cx, |panel, cx| {
 419                    panel.show_scrollbar = false;
 420                    cx.notify();
 421                })
 422                .log_err();
 423        }))
 424    }
 425
 426    fn handle_modifiers_changed(
 427        &mut self,
 428        event: &ModifiersChangedEvent,
 429        cx: &mut ViewContext<Self>,
 430    ) {
 431        self.current_modifiers = event.modifiers;
 432        cx.notify();
 433    }
 434
 435    fn calculate_depth_and_difference(
 436        repo_path: &RepoPath,
 437        visible_entries: &HashSet<RepoPath>,
 438    ) -> (usize, usize) {
 439        let ancestors = repo_path.ancestors().skip(1);
 440        for ancestor in ancestors {
 441            if let Some(parent_entry) = visible_entries.get(ancestor) {
 442                let entry_component_count = repo_path.components().count();
 443                let parent_component_count = parent_entry.components().count();
 444
 445                let difference = entry_component_count - parent_component_count;
 446
 447                let parent_depth = parent_entry
 448                    .ancestors()
 449                    .skip(1) // Skip the parent itself
 450                    .filter(|ancestor| visible_entries.contains(*ancestor))
 451                    .count();
 452
 453                return (parent_depth + 1, difference);
 454            }
 455        }
 456
 457        (0, 0)
 458    }
 459
 460    fn scroll_to_selected_entry(&mut self, cx: &mut ViewContext<Self>) {
 461        if let Some(selected_entry) = self.selected_entry {
 462            self.scroll_handle
 463                .scroll_to_item(selected_entry, ScrollStrategy::Center);
 464        }
 465
 466        cx.notify();
 467    }
 468
 469    fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
 470        if self.visible_entries.first().is_some() {
 471            self.selected_entry = Some(0);
 472            self.scroll_to_selected_entry(cx);
 473        }
 474    }
 475
 476    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
 477        let item_count = self.visible_entries.len();
 478        if item_count == 0 {
 479            return;
 480        }
 481
 482        if let Some(selected_entry) = self.selected_entry {
 483            let new_selected_entry = if selected_entry > 0 {
 484                selected_entry - 1
 485            } else {
 486                selected_entry
 487            };
 488
 489            self.selected_entry = Some(new_selected_entry);
 490
 491            self.scroll_to_selected_entry(cx);
 492        }
 493
 494        cx.notify();
 495    }
 496
 497    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
 498        let item_count = self.visible_entries.len();
 499        if item_count == 0 {
 500            return;
 501        }
 502
 503        if let Some(selected_entry) = self.selected_entry {
 504            let new_selected_entry = if selected_entry < item_count - 1 {
 505                selected_entry + 1
 506            } else {
 507                selected_entry
 508            };
 509
 510            self.selected_entry = Some(new_selected_entry);
 511
 512            self.scroll_to_selected_entry(cx);
 513        }
 514
 515        cx.notify();
 516    }
 517
 518    fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
 519        if self.visible_entries.last().is_some() {
 520            self.selected_entry = Some(self.visible_entries.len() - 1);
 521            self.scroll_to_selected_entry(cx);
 522        }
 523    }
 524
 525    fn focus_editor(&mut self, _: &FocusEditor, cx: &mut ViewContext<Self>) {
 526        self.commit_editor.update(cx, |editor, cx| {
 527            editor.focus(cx);
 528        });
 529        cx.notify();
 530    }
 531
 532    fn select_first_entry_if_none(&mut self, cx: &mut ViewContext<Self>) {
 533        if !self.no_entries() && self.selected_entry.is_none() {
 534            self.selected_entry = Some(0);
 535            self.scroll_to_selected_entry(cx);
 536            cx.notify();
 537        }
 538    }
 539
 540    fn focus_changes_list(&mut self, _: &FocusChanges, cx: &mut ViewContext<Self>) {
 541        self.select_first_entry_if_none(cx);
 542
 543        cx.focus_self();
 544        cx.notify();
 545    }
 546
 547    fn get_selected_entry(&self) -> Option<&GitListEntry> {
 548        self.selected_entry
 549            .and_then(|i| self.visible_entries.get(i))
 550    }
 551
 552    fn toggle_staged_for_entry(&mut self, entry: &GitListEntry, cx: &mut ViewContext<Self>) {
 553        match entry.status.is_staged() {
 554            Some(true) | None => self.git_state.unstage_entry(entry.repo_path.clone()),
 555            Some(false) => self.git_state.stage_entry(entry.repo_path.clone()),
 556        }
 557        cx.notify();
 558    }
 559
 560    fn toggle_staged_for_selected(&mut self, _: &ToggleStaged, cx: &mut ViewContext<Self>) {
 561        if let Some(selected_entry) = self.get_selected_entry().cloned() {
 562            self.toggle_staged_for_entry(&selected_entry, cx);
 563        }
 564    }
 565
 566    fn open_selected(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
 567        if let Some(entry) = self
 568            .selected_entry
 569            .and_then(|i| self.visible_entries.get(i))
 570        {
 571            self.open_entry(entry, cx);
 572        }
 573    }
 574
 575    fn open_entry(&self, entry: &GitListEntry, cx: &mut ViewContext<Self>) {
 576        let Some((worktree_id, path)) =
 577            self.git_state
 578                .active_repository
 579                .as_ref()
 580                .and_then(|(id, repo, _)| {
 581                    Some((*id, repo.work_directory.unrelativize(&entry.repo_path)?))
 582                })
 583        else {
 584            return;
 585        };
 586        let path = (worktree_id, path).into();
 587        let path_exists = self.project.update(cx, |project, cx| {
 588            project.entry_for_path(&path, cx).is_some()
 589        });
 590        if !path_exists {
 591            return;
 592        }
 593        cx.emit(Event::OpenedEntry { path });
 594    }
 595
 596    fn stage_all(&mut self, _: &StageAll, _cx: &mut ViewContext<Self>) {
 597        let to_stage = self
 598            .visible_entries
 599            .iter_mut()
 600            .filter_map(|entry| {
 601                let is_unstaged = !entry.is_staged.unwrap_or(false);
 602                entry.is_staged = Some(true);
 603                is_unstaged.then(|| entry.repo_path.clone())
 604            })
 605            .collect();
 606        self.all_staged = Some(true);
 607        self.git_state.stage_entries(to_stage);
 608    }
 609
 610    fn unstage_all(&mut self, _: &UnstageAll, _cx: &mut ViewContext<Self>) {
 611        // This should only be called when all entries are staged.
 612        for entry in &mut self.visible_entries {
 613            entry.is_staged = Some(false);
 614        }
 615        self.all_staged = Some(false);
 616        self.git_state.unstage_all();
 617    }
 618
 619    fn discard_all(&mut self, _: &RevertAll, _cx: &mut ViewContext<Self>) {
 620        // TODO: Implement discard all
 621        println!("Discard all triggered");
 622    }
 623
 624    fn clear_message(&mut self, cx: &mut ViewContext<Self>) {
 625        self.git_state.clear_commit_message();
 626        self.commit_editor
 627            .update(cx, |editor, cx| editor.set_text("", cx));
 628    }
 629
 630    /// Commit all staged changes
 631    fn commit_changes(&mut self, _: &CommitChanges, cx: &mut ViewContext<Self>) {
 632        self.clear_message(cx);
 633
 634        // TODO: Implement commit all staged
 635        println!("Commit staged changes triggered");
 636    }
 637
 638    /// Commit all changes, regardless of whether they are staged or not
 639    fn commit_all_changes(&mut self, _: &CommitAllChanges, cx: &mut ViewContext<Self>) {
 640        self.clear_message(cx);
 641
 642        // TODO: Implement commit all changes
 643        println!("Commit all changes triggered");
 644    }
 645
 646    fn no_entries(&self) -> bool {
 647        self.visible_entries.is_empty()
 648    }
 649
 650    fn entry_count(&self) -> usize {
 651        self.visible_entries.len()
 652    }
 653
 654    fn for_each_visible_entry(
 655        &self,
 656        range: Range<usize>,
 657        cx: &mut ViewContext<Self>,
 658        mut callback: impl FnMut(usize, GitListEntry, &mut ViewContext<Self>),
 659    ) {
 660        let visible_entries = &self.visible_entries;
 661
 662        for (ix, entry) in visible_entries
 663            .iter()
 664            .enumerate()
 665            .skip(range.start)
 666            .take(range.end - range.start)
 667        {
 668            let status = entry.status.clone();
 669            let filename = entry
 670                .repo_path
 671                .file_name()
 672                .map(|name| name.to_string_lossy().into_owned())
 673                .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
 674
 675            let details = GitListEntry {
 676                repo_path: entry.repo_path.clone(),
 677                status,
 678                depth: 0,
 679                display_name: filename,
 680                is_staged: entry.is_staged,
 681            };
 682
 683            callback(ix, details, cx);
 684        }
 685    }
 686
 687    fn schedule_update(&mut self) {
 688        self.rebuild_requested.store(true, Ordering::Relaxed);
 689    }
 690
 691    #[track_caller]
 692    fn update_visible_entries(&mut self, cx: &mut ViewContext<Self>) {
 693        self.visible_entries.clear();
 694
 695        let Some((_, repo, _)) = self.git_state.active_repository().as_ref() else {
 696            // Just clear entries if no repository is active.
 697            cx.notify();
 698            return;
 699        };
 700
 701        // First pass - collect all paths
 702        let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path));
 703
 704        // Second pass - create entries with proper depth calculation
 705        let mut all_staged = None;
 706        for (ix, entry) in repo.status().enumerate() {
 707            let (depth, difference) =
 708                Self::calculate_depth_and_difference(&entry.repo_path, &path_set);
 709            let is_staged = entry.status.is_staged();
 710            all_staged = if ix == 0 {
 711                is_staged
 712            } else {
 713                match (all_staged, is_staged) {
 714                    (None, _) | (_, None) => None,
 715                    (Some(a), Some(b)) => (a == b).then_some(a),
 716                }
 717            };
 718
 719            let display_name = if difference > 1 {
 720                // Show partial path for deeply nested files
 721                entry
 722                    .repo_path
 723                    .as_ref()
 724                    .iter()
 725                    .skip(entry.repo_path.components().count() - difference)
 726                    .collect::<PathBuf>()
 727                    .to_string_lossy()
 728                    .into_owned()
 729            } else {
 730                // Just show filename
 731                entry
 732                    .repo_path
 733                    .file_name()
 734                    .map(|name| name.to_string_lossy().into_owned())
 735                    .unwrap_or_default()
 736            };
 737
 738            let entry = GitListEntry {
 739                depth,
 740                display_name,
 741                repo_path: entry.repo_path,
 742                status: entry.status,
 743                is_staged,
 744            };
 745
 746            self.visible_entries.push(entry);
 747        }
 748        self.all_staged = all_staged;
 749
 750        // Sort entries by path to maintain consistent order
 751        self.visible_entries
 752            .sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
 753
 754        self.select_first_entry_if_none(cx);
 755
 756        cx.notify();
 757    }
 758
 759    fn on_buffer_event(
 760        &mut self,
 761        _buffer: Model<Buffer>,
 762        event: &language::BufferEvent,
 763        cx: &mut ViewContext<Self>,
 764    ) {
 765        if let language::BufferEvent::Reparsed | language::BufferEvent::Edited = event {
 766            let commit_message = self.commit_editor.update(cx, |editor, cx| editor.text(cx));
 767
 768            self.git_state.commit_message = Some(commit_message.into());
 769
 770            cx.notify();
 771        }
 772    }
 773}
 774
 775// GitPanel –– Render
 776impl GitPanel {
 777    pub fn panel_button(
 778        &self,
 779        id: impl Into<SharedString>,
 780        label: impl Into<SharedString>,
 781    ) -> Button {
 782        let id = id.into().clone();
 783        let label = label.into().clone();
 784
 785        Button::new(id, label)
 786            .label_size(LabelSize::Small)
 787            .layer(ElevationIndex::ElevatedSurface)
 788            .size(ButtonSize::Compact)
 789            .style(ButtonStyle::Filled)
 790    }
 791
 792    pub fn render_divider(&self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
 793        h_flex()
 794            .items_center()
 795            .h(px(8.))
 796            .child(Divider::horizontal_dashed().color(DividerColor::Border))
 797    }
 798
 799    pub fn render_panel_header(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
 800        let focus_handle = self.focus_handle(cx).clone();
 801
 802        let changes_string = match self.entry_count() {
 803            0 => "No changes".to_string(),
 804            1 => "1 change".to_string(),
 805            n => format!("{} changes", n),
 806        };
 807
 808        // for our use case treat None as false
 809        let all_staged = self.all_staged.unwrap_or(false);
 810
 811        h_flex()
 812            .h(px(32.))
 813            .items_center()
 814            .px_2()
 815            .bg(ElevationIndex::Surface.bg(cx))
 816            .child(
 817                h_flex()
 818                    .gap_2()
 819                    .child(
 820                        Checkbox::new(
 821                            "all-changes",
 822                            if self.no_entries() {
 823                                ToggleState::Selected
 824                            } else {
 825                                self.all_staged
 826                                    .map_or(ToggleState::Indeterminate, ToggleState::from)
 827                            },
 828                        )
 829                        .fill()
 830                        .elevation(ElevationIndex::Surface)
 831                        .tooltip(move |cx| {
 832                            if all_staged {
 833                                Tooltip::text("Unstage all changes", cx)
 834                            } else {
 835                                Tooltip::text("Stage all changes", cx)
 836                            }
 837                        })
 838                        .on_click(cx.listener(move |git_panel, _, cx| match all_staged {
 839                            true => git_panel.unstage_all(&UnstageAll, cx),
 840                            false => git_panel.stage_all(&StageAll, cx),
 841                        })),
 842                    )
 843                    .child(div().text_buffer(cx).text_ui_sm(cx).child(changes_string)),
 844            )
 845            .child(div().flex_grow())
 846            .child(
 847                h_flex()
 848                    .gap_2()
 849                    // TODO: Re-add once revert all is added
 850                    // .child(
 851                    //     IconButton::new("discard-changes", IconName::Undo)
 852                    //         .tooltip({
 853                    //             let focus_handle = focus_handle.clone();
 854                    //             move |cx| {
 855                    //                 Tooltip::for_action_in(
 856                    //                     "Discard all changes",
 857                    //                     &RevertAll,
 858                    //                     &focus_handle,
 859                    //                     cx,
 860                    //                 )
 861                    //             }
 862                    //         })
 863                    //         .icon_size(IconSize::Small)
 864                    //         .disabled(true),
 865                    // )
 866                    .child(if self.all_staged.unwrap_or(false) {
 867                        self.panel_button("unstage-all", "Unstage All")
 868                            .tooltip({
 869                                let focus_handle = focus_handle.clone();
 870                                move |cx| {
 871                                    Tooltip::for_action_in(
 872                                        "Unstage all changes",
 873                                        &UnstageAll,
 874                                        &focus_handle,
 875                                        cx,
 876                                    )
 877                                }
 878                            })
 879                            .key_binding(ui::KeyBinding::for_action_in(
 880                                &UnstageAll,
 881                                &focus_handle,
 882                                cx,
 883                            ))
 884                            .on_click(
 885                                cx.listener(move |this, _, cx| this.unstage_all(&UnstageAll, cx)),
 886                            )
 887                    } else {
 888                        self.panel_button("stage-all", "Stage All")
 889                            .tooltip({
 890                                let focus_handle = focus_handle.clone();
 891                                move |cx| {
 892                                    Tooltip::for_action_in(
 893                                        "Stage all changes",
 894                                        &StageAll,
 895                                        &focus_handle,
 896                                        cx,
 897                                    )
 898                                }
 899                            })
 900                            .key_binding(ui::KeyBinding::for_action_in(
 901                                &StageAll,
 902                                &focus_handle,
 903                                cx,
 904                            ))
 905                            .on_click(cx.listener(move |this, _, cx| this.stage_all(&StageAll, cx)))
 906                    }),
 907            )
 908    }
 909
 910    pub fn render_commit_editor(&self, cx: &ViewContext<Self>) -> impl IntoElement {
 911        let editor = self.commit_editor.clone();
 912        let editor_focus_handle = editor.read(cx).focus_handle(cx).clone();
 913
 914        let focus_handle_1 = self.focus_handle(cx).clone();
 915        let focus_handle_2 = self.focus_handle(cx).clone();
 916
 917        let commit_staged_button = self
 918            .panel_button("commit-staged-changes", "Commit")
 919            .tooltip(move |cx| {
 920                let focus_handle = focus_handle_1.clone();
 921                Tooltip::for_action_in(
 922                    "Commit all staged changes",
 923                    &CommitChanges,
 924                    &focus_handle,
 925                    cx,
 926                )
 927            })
 928            .on_click(
 929                cx.listener(|this, _: &ClickEvent, cx| this.commit_changes(&CommitChanges, cx)),
 930            );
 931
 932        let commit_all_button = self
 933            .panel_button("commit-all-changes", "Commit All")
 934            .tooltip(move |cx| {
 935                let focus_handle = focus_handle_2.clone();
 936                Tooltip::for_action_in(
 937                    "Commit all changes, including unstaged changes",
 938                    &CommitAllChanges,
 939                    &focus_handle,
 940                    cx,
 941                )
 942            })
 943            .on_click(cx.listener(|this, _: &ClickEvent, cx| {
 944                this.commit_all_changes(&CommitAllChanges, cx)
 945            }));
 946
 947        div().w_full().h(px(140.)).px_2().pt_1().pb_2().child(
 948            v_flex()
 949                .id("commit-editor-container")
 950                .relative()
 951                .h_full()
 952                .py_2p5()
 953                .px_3()
 954                .bg(cx.theme().colors().editor_background)
 955                .on_click(cx.listener(move |_, _: &ClickEvent, cx| cx.focus(&editor_focus_handle)))
 956                .child(self.commit_editor.clone())
 957                .child(
 958                    h_flex()
 959                        .absolute()
 960                        .bottom_2p5()
 961                        .right_3()
 962                        .child(div().gap_1().flex_grow())
 963                        .child(if self.current_modifiers.alt {
 964                            commit_all_button
 965                        } else {
 966                            commit_staged_button
 967                        }),
 968                ),
 969        )
 970    }
 971
 972    fn render_empty_state(&self, cx: &ViewContext<Self>) -> impl IntoElement {
 973        h_flex()
 974            .h_full()
 975            .flex_1()
 976            .justify_center()
 977            .items_center()
 978            .child(
 979                v_flex()
 980                    .gap_3()
 981                    .child("No changes to commit")
 982                    .text_ui_sm(cx)
 983                    .mx_auto()
 984                    .text_color(Color::Placeholder.color(cx)),
 985            )
 986    }
 987
 988    fn render_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
 989        let scroll_bar_style = self.show_scrollbar(cx);
 990        let show_container = matches!(scroll_bar_style, ShowScrollbar::Always);
 991
 992        if !self.should_show_scrollbar(cx)
 993            || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
 994        {
 995            return None;
 996        }
 997
 998        Some(
 999            div()
1000                .id("git-panel-vertical-scroll")
1001                .occlude()
1002                .flex_none()
1003                .h_full()
1004                .cursor_default()
1005                .when(show_container, |this| this.pl_1().px_1p5())
1006                .when(!show_container, |this| {
1007                    this.absolute().right_1().top_1().bottom_1().w(px(12.))
1008                })
1009                .on_mouse_move(cx.listener(|_, _, cx| {
1010                    cx.notify();
1011                    cx.stop_propagation()
1012                }))
1013                .on_hover(|_, cx| {
1014                    cx.stop_propagation();
1015                })
1016                .on_any_mouse_down(|_, cx| {
1017                    cx.stop_propagation();
1018                })
1019                .on_mouse_up(
1020                    MouseButton::Left,
1021                    cx.listener(|this, _, cx| {
1022                        if !this.scrollbar_state.is_dragging()
1023                            && !this.focus_handle.contains_focused(cx)
1024                        {
1025                            this.hide_scrollbar(cx);
1026                            cx.notify();
1027                        }
1028
1029                        cx.stop_propagation();
1030                    }),
1031                )
1032                .on_scroll_wheel(cx.listener(|_, _, cx| {
1033                    cx.notify();
1034                }))
1035                .children(Scrollbar::vertical(
1036                    // percentage as f32..end_offset as f32,
1037                    self.scrollbar_state.clone(),
1038                )),
1039        )
1040    }
1041
1042    fn render_entries(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1043        let entry_count = self.entry_count();
1044        h_flex()
1045            .size_full()
1046            .overflow_hidden()
1047            .child(
1048                uniform_list(cx.view().clone(), "entries", entry_count, {
1049                    move |git_panel, range, cx| {
1050                        let mut items = Vec::with_capacity(range.end - range.start);
1051                        git_panel.for_each_visible_entry(range, cx, |ix, details, cx| {
1052                            items.push(git_panel.render_entry(ix, details, cx));
1053                        });
1054                        items
1055                    }
1056                })
1057                .size_full()
1058                .with_sizing_behavior(ListSizingBehavior::Infer)
1059                .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
1060                // .with_width_from_item(self.max_width_item_index)
1061                .track_scroll(self.scroll_handle.clone()),
1062            )
1063            .children(self.render_scrollbar(cx))
1064    }
1065
1066    fn render_entry(
1067        &self,
1068        ix: usize,
1069        entry_details: GitListEntry,
1070        cx: &ViewContext<Self>,
1071    ) -> impl IntoElement {
1072        let repo_path = entry_details.repo_path.clone();
1073        let selected = self.selected_entry == Some(ix);
1074        let status_style = GitPanelSettings::get_global(cx).status_style;
1075        // TODO revisit, maybe use a different status here?
1076        let status = entry_details.status.combined();
1077
1078        let mut label_color = cx.theme().colors().text;
1079        if status_style == StatusStyle::LabelColor {
1080            label_color = match status {
1081                GitFileStatus::Added => cx.theme().status().created,
1082                GitFileStatus::Modified => cx.theme().status().modified,
1083                GitFileStatus::Conflict => cx.theme().status().conflict,
1084                GitFileStatus::Deleted => cx.theme().colors().text_disabled,
1085                // TODO: Should we even have this here?
1086                GitFileStatus::Untracked => cx.theme().colors().text_placeholder,
1087            }
1088        }
1089
1090        let path_color = matches!(status, GitFileStatus::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 == GitFileStatus::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}