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