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