git_panel.rs

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