git_panel.rs

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