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