git_panel.rs

   1use crate::{git_status_icon, settings::GitPanelSettings};
   2use crate::{CommitAllChanges, CommitStagedChanges, DiscardAll, StageAll, UnstageAll};
   3use anyhow::{Context as _, Result};
   4use db::kvp::KEY_VALUE_STORE;
   5use editor::{
   6    scroll::{Autoscroll, AutoscrollStrategy},
   7    Editor, MultiBuffer, DEFAULT_MULTIBUFFER_CONTEXT,
   8};
   9use git::{
  10    diff::DiffHunk,
  11    repository::{GitFileStatus, RepoPath},
  12};
  13use gpui::*;
  14use gpui::{
  15    actions, prelude::*, uniform_list, Action, AppContext, AsyncWindowContext, ClickEvent,
  16    CursorStyle, EventEmitter, FocusHandle, FocusableView, KeyContext,
  17    ListHorizontalSizingBehavior, ListSizingBehavior, Model, Modifiers, ModifiersChangedEvent,
  18    MouseButton, ScrollStrategy, Stateful, Task, UniformListScrollHandle, View, WeakView,
  19};
  20use language::{Buffer, BufferRow, OffsetRangeExt};
  21use menu::{SelectNext, SelectPrev};
  22use project::{EntryKind, Fs, Project, ProjectEntryId, ProjectPath, WorktreeId};
  23use serde::{Deserialize, Serialize};
  24use settings::Settings as _;
  25use std::{
  26    cell::OnceCell,
  27    collections::HashSet,
  28    ffi::OsStr,
  29    ops::{Deref, Range},
  30    path::PathBuf,
  31    rc::Rc,
  32    sync::Arc,
  33    time::Duration,
  34    usize,
  35};
  36use ui::{
  37    prelude::*, Checkbox, Divider, DividerColor, ElevationIndex, ListItem, Scrollbar,
  38    ScrollbarState, Tooltip,
  39};
  40use util::{ResultExt, TryFutureExt};
  41use workspace::{
  42    dock::{DockPosition, Panel, PanelEvent},
  43    ItemHandle, Workspace,
  44};
  45use worktree::StatusEntry;
  46
  47actions!(git_panel, [ToggleFocus]);
  48
  49const GIT_PANEL_KEY: &str = "GitPanel";
  50
  51const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
  52
  53pub fn init(cx: &mut AppContext) {
  54    cx.observe_new_views(
  55        |workspace: &mut Workspace, _cx: &mut ViewContext<Workspace>| {
  56            workspace.register_action(|workspace, _: &ToggleFocus, cx| {
  57                workspace.toggle_panel_focus::<GitPanel>(cx);
  58            });
  59        },
  60    )
  61    .detach();
  62}
  63
  64#[derive(Debug)]
  65pub enum Event {
  66    Focus,
  67}
  68
  69pub struct GitStatusEntry {}
  70
  71#[derive(Debug, PartialEq, Eq, Clone)]
  72struct EntryDetails {
  73    filename: String,
  74    display_name: String,
  75    path: RepoPath,
  76    kind: EntryKind,
  77    depth: usize,
  78    is_expanded: bool,
  79    status: Option<GitFileStatus>,
  80    hunks: Rc<OnceCell<Vec<DiffHunk>>>,
  81    index: usize,
  82}
  83
  84impl EntryDetails {
  85    pub fn is_dir(&self) -> bool {
  86        self.kind.is_dir()
  87    }
  88}
  89
  90#[derive(Serialize, Deserialize)]
  91struct SerializedGitPanel {
  92    width: Option<Pixels>,
  93}
  94
  95pub struct GitPanel {
  96    workspace: WeakView<Workspace>,
  97    current_modifiers: Modifiers,
  98    focus_handle: FocusHandle,
  99    fs: Arc<dyn Fs>,
 100    hide_scrollbar_task: Option<Task<()>>,
 101    pending_serialization: Task<Option<()>>,
 102    project: Model<Project>,
 103    scroll_handle: UniformListScrollHandle,
 104    scrollbar_state: ScrollbarState,
 105    selected_item: Option<usize>,
 106    show_scrollbar: bool,
 107    // TODO Reintroduce expanded directories, once we're deriving directories from paths
 108    // expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
 109
 110    // The entries that are currently shown in the panel, aka
 111    // not hidden by folding or such
 112    visible_entries: Vec<WorktreeEntries>,
 113    width: Option<Pixels>,
 114    git_diff_editor: Option<View<Editor>>,
 115    git_diff_editor_updates: Task<()>,
 116    reveal_in_editor: Task<()>,
 117}
 118
 119#[derive(Debug, Clone)]
 120struct WorktreeEntries {
 121    worktree_id: WorktreeId,
 122    // TODO support multiple repositories per worktree
 123    work_directory: worktree::WorkDirectory,
 124    visible_entries: Vec<GitPanelEntry>,
 125    paths: Rc<OnceCell<HashSet<RepoPath>>>,
 126}
 127
 128#[derive(Debug, Clone)]
 129struct GitPanelEntry {
 130    entry: worktree::StatusEntry,
 131    hunks: Rc<OnceCell<Vec<DiffHunk>>>,
 132}
 133
 134impl Deref for GitPanelEntry {
 135    type Target = worktree::StatusEntry;
 136
 137    fn deref(&self) -> &Self::Target {
 138        &self.entry
 139    }
 140}
 141
 142impl WorktreeEntries {
 143    fn paths(&self) -> &HashSet<RepoPath> {
 144        self.paths.get_or_init(|| {
 145            self.visible_entries
 146                .iter()
 147                .map(|e| (e.entry.repo_path.clone()))
 148                .collect()
 149        })
 150    }
 151}
 152
 153impl GitPanel {
 154    pub fn load(
 155        workspace: WeakView<Workspace>,
 156        cx: AsyncWindowContext,
 157    ) -> Task<Result<View<Self>>> {
 158        cx.spawn(|mut cx| async move { workspace.update(&mut cx, Self::new) })
 159    }
 160
 161    pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
 162        let fs = workspace.app_state().fs.clone();
 163        let weak_workspace = workspace.weak_handle();
 164        let project = workspace.project().clone();
 165
 166        let git_panel = cx.new_view(|cx: &mut ViewContext<Self>| {
 167            let focus_handle = cx.focus_handle();
 168            cx.on_focus(&focus_handle, Self::focus_in).detach();
 169            cx.on_focus_out(&focus_handle, |this, _, cx| {
 170                this.hide_scrollbar(cx);
 171            })
 172            .detach();
 173            cx.subscribe(&project, |this, _, event, cx| match event {
 174                project::Event::GitRepositoryUpdated => {
 175                    this.update_visible_entries(None, None, cx);
 176                }
 177                project::Event::WorktreeRemoved(_id) => {
 178                    // this.expanded_dir_ids.remove(id);
 179                    this.update_visible_entries(None, None, cx);
 180                    cx.notify();
 181                }
 182                project::Event::WorktreeOrderChanged => {
 183                    this.update_visible_entries(None, None, cx);
 184                    cx.notify();
 185                }
 186                project::Event::WorktreeUpdatedEntries(id, _)
 187                | project::Event::WorktreeAdded(id)
 188                | project::Event::WorktreeUpdatedGitRepositories(id) => {
 189                    this.update_visible_entries(Some(*id), None, cx);
 190                    cx.notify();
 191                }
 192                project::Event::Closed => {
 193                    this.git_diff_editor_updates = Task::ready(());
 194                    this.reveal_in_editor = Task::ready(());
 195                    // this.expanded_dir_ids.clear();
 196                    this.visible_entries.clear();
 197                    this.git_diff_editor = None;
 198                }
 199                _ => {}
 200            })
 201            .detach();
 202
 203            let scroll_handle = UniformListScrollHandle::new();
 204
 205            let mut git_panel = Self {
 206                workspace: weak_workspace,
 207                focus_handle: cx.focus_handle(),
 208                fs,
 209                pending_serialization: Task::ready(None),
 210                visible_entries: Vec::new(),
 211                current_modifiers: cx.modifiers(),
 212                // expanded_dir_ids: Default::default(),
 213                width: Some(px(360.)),
 214                scrollbar_state: ScrollbarState::new(scroll_handle.clone()).parent_view(cx.view()),
 215                scroll_handle,
 216                selected_item: None,
 217                show_scrollbar: !Self::should_autohide_scrollbar(cx),
 218                hide_scrollbar_task: None,
 219                git_diff_editor: Some(diff_display_editor(cx)),
 220                git_diff_editor_updates: Task::ready(()),
 221                reveal_in_editor: Task::ready(()),
 222                project,
 223            };
 224            git_panel.update_visible_entries(None, None, cx);
 225            git_panel
 226        });
 227
 228        git_panel
 229    }
 230
 231    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
 232        let width = self.width;
 233        self.pending_serialization = cx.background_executor().spawn(
 234            async move {
 235                KEY_VALUE_STORE
 236                    .write_kvp(
 237                        GIT_PANEL_KEY.into(),
 238                        serde_json::to_string(&SerializedGitPanel { width })?,
 239                    )
 240                    .await?;
 241                anyhow::Ok(())
 242            }
 243            .log_err(),
 244        );
 245    }
 246
 247    fn dispatch_context(&self) -> KeyContext {
 248        let mut dispatch_context = KeyContext::new_with_defaults();
 249        dispatch_context.add("GitPanel");
 250        dispatch_context.add("menu");
 251
 252        dispatch_context
 253    }
 254
 255    fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
 256        if !self.focus_handle.contains_focused(cx) {
 257            cx.emit(Event::Focus);
 258        }
 259    }
 260
 261    fn should_show_scrollbar(_cx: &AppContext) -> bool {
 262        // TODO: plug into settings
 263        true
 264    }
 265
 266    fn should_autohide_scrollbar(_cx: &AppContext) -> bool {
 267        // TODO: plug into settings
 268        true
 269    }
 270
 271    fn hide_scrollbar(&mut self, cx: &mut ViewContext<Self>) {
 272        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
 273        if !Self::should_autohide_scrollbar(cx) {
 274            return;
 275        }
 276        self.hide_scrollbar_task = Some(cx.spawn(|panel, mut cx| async move {
 277            cx.background_executor()
 278                .timer(SCROLLBAR_SHOW_INTERVAL)
 279                .await;
 280            panel
 281                .update(&mut cx, |panel, cx| {
 282                    panel.show_scrollbar = false;
 283                    cx.notify();
 284                })
 285                .log_err();
 286        }))
 287    }
 288
 289    fn handle_modifiers_changed(
 290        &mut self,
 291        event: &ModifiersChangedEvent,
 292        cx: &mut ViewContext<Self>,
 293    ) {
 294        self.current_modifiers = event.modifiers;
 295        cx.notify();
 296    }
 297
 298    fn calculate_depth_and_difference(
 299        entry: &StatusEntry,
 300        visible_worktree_entries: &HashSet<RepoPath>,
 301    ) -> (usize, usize) {
 302        let (depth, difference) = entry
 303            .repo_path
 304            .ancestors()
 305            .skip(1) // Skip the entry itself
 306            .find_map(|ancestor| {
 307                if let Some(parent_entry) = visible_worktree_entries.get(ancestor) {
 308                    let entry_path_components_count = entry.repo_path.components().count();
 309                    let parent_path_components_count = parent_entry.components().count();
 310                    let difference = entry_path_components_count - parent_path_components_count;
 311                    let depth = parent_entry
 312                        .ancestors()
 313                        .skip(1)
 314                        .filter(|ancestor| visible_worktree_entries.contains(*ancestor))
 315                        .count();
 316                    Some((depth + 1, difference))
 317                } else {
 318                    None
 319                }
 320            })
 321            .unwrap_or((0, 0));
 322
 323        (depth, difference)
 324    }
 325
 326    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
 327        let item_count = self
 328            .visible_entries
 329            .iter()
 330            .map(|worktree_entries| worktree_entries.visible_entries.len())
 331            .sum::<usize>();
 332        if item_count == 0 {
 333            return;
 334        }
 335        let selection = match self.selected_item {
 336            Some(i) => {
 337                if i < item_count - 1 {
 338                    self.selected_item = Some(i + 1);
 339                    i + 1
 340                } else {
 341                    self.selected_item = Some(0);
 342                    0
 343                }
 344            }
 345            None => {
 346                self.selected_item = Some(0);
 347                0
 348            }
 349        };
 350        self.scroll_handle
 351            .scroll_to_item(selection, ScrollStrategy::Center);
 352
 353        let mut hunks = None;
 354        self.for_each_visible_entry(selection..selection + 1, cx, |_, entry, _| {
 355            hunks = Some(entry.hunks.clone());
 356        });
 357        if let Some(hunks) = hunks {
 358            self.reveal_entry_in_git_editor(hunks, false, Some(UPDATE_DEBOUNCE), cx);
 359        }
 360
 361        cx.notify();
 362    }
 363
 364    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
 365        let item_count = self
 366            .visible_entries
 367            .iter()
 368            .map(|worktree_entries| worktree_entries.visible_entries.len())
 369            .sum::<usize>();
 370        if item_count == 0 {
 371            return;
 372        }
 373        let selection = match self.selected_item {
 374            Some(i) => {
 375                if i > 0 {
 376                    self.selected_item = Some(i - 1);
 377                    i - 1
 378                } else {
 379                    self.selected_item = Some(item_count - 1);
 380                    item_count - 1
 381                }
 382            }
 383            None => {
 384                self.selected_item = Some(0);
 385                0
 386            }
 387        };
 388        self.scroll_handle
 389            .scroll_to_item(selection, ScrollStrategy::Center);
 390
 391        let mut hunks = None;
 392        self.for_each_visible_entry(selection..selection + 1, cx, |_, entry, _| {
 393            hunks = Some(entry.hunks.clone());
 394        });
 395        if let Some(hunks) = hunks {
 396            self.reveal_entry_in_git_editor(hunks, false, Some(UPDATE_DEBOUNCE), cx);
 397        }
 398
 399        cx.notify();
 400    }
 401}
 402
 403impl GitPanel {
 404    fn stage_all(&mut self, _: &StageAll, _cx: &mut ViewContext<Self>) {
 405        // TODO: Implement stage all
 406        println!("Stage all triggered");
 407    }
 408
 409    fn unstage_all(&mut self, _: &UnstageAll, _cx: &mut ViewContext<Self>) {
 410        // TODO: Implement unstage all
 411        println!("Unstage all triggered");
 412    }
 413
 414    fn discard_all(&mut self, _: &DiscardAll, _cx: &mut ViewContext<Self>) {
 415        // TODO: Implement discard all
 416        println!("Discard all triggered");
 417    }
 418
 419    /// Commit all staged changes
 420    fn commit_staged_changes(&mut self, _: &CommitStagedChanges, _cx: &mut ViewContext<Self>) {
 421        // TODO: Implement commit all staged
 422        println!("Commit staged changes triggered");
 423    }
 424
 425    /// Commit all changes, regardless of whether they are staged or not
 426    fn commit_all_changes(&mut self, _: &CommitAllChanges, _cx: &mut ViewContext<Self>) {
 427        // TODO: Implement commit all changes
 428        println!("Commit all changes triggered");
 429    }
 430
 431    fn all_staged(&self) -> bool {
 432        // TODO: Implement all_staged
 433        true
 434    }
 435
 436    fn no_entries(&self) -> bool {
 437        self.visible_entries.is_empty()
 438    }
 439
 440    fn entry_count(&self) -> usize {
 441        self.visible_entries
 442            .iter()
 443            .map(|worktree_entries| worktree_entries.visible_entries.len())
 444            .sum()
 445    }
 446
 447    fn for_each_visible_entry(
 448        &self,
 449        range: Range<usize>,
 450        cx: &mut ViewContext<Self>,
 451        mut callback: impl FnMut(usize, EntryDetails, &mut ViewContext<Self>),
 452    ) {
 453        let mut ix = 0;
 454        for worktree_entries in &self.visible_entries {
 455            if ix >= range.end {
 456                return;
 457            }
 458
 459            if ix + worktree_entries.visible_entries.len() <= range.start {
 460                ix += worktree_entries.visible_entries.len();
 461                continue;
 462            }
 463
 464            let end_ix = range.end.min(ix + worktree_entries.visible_entries.len());
 465            // let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
 466            if let Some(worktree) = self
 467                .project
 468                .read(cx)
 469                .worktree_for_id(worktree_entries.worktree_id, cx)
 470            {
 471                let snapshot = worktree.read(cx).snapshot();
 472                let root_name = OsStr::new(snapshot.root_name());
 473                // let expanded_entry_ids = self
 474                //     .expanded_dir_ids
 475                //     .get(&snapshot.id())
 476                //     .map(Vec::as_slice)
 477                //     .unwrap_or(&[]);
 478
 479                let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
 480                let entries = worktree_entries.paths();
 481
 482                let index_start = entry_range.start;
 483                for (i, entry) in worktree_entries.visible_entries[entry_range]
 484                    .iter()
 485                    .enumerate()
 486                {
 487                    let index = index_start + i;
 488                    let status = entry.status;
 489                    let is_expanded = true; //expanded_entry_ids.binary_search(&entry.id).is_ok();
 490
 491                    let (depth, difference) = Self::calculate_depth_and_difference(entry, entries);
 492
 493                    let filename = match difference {
 494                        diff if diff > 1 => entry
 495                            .repo_path
 496                            .iter()
 497                            .skip(entry.repo_path.components().count() - diff)
 498                            .collect::<PathBuf>()
 499                            .to_str()
 500                            .unwrap_or_default()
 501                            .to_string(),
 502                        _ => entry
 503                            .repo_path
 504                            .file_name()
 505                            .map(|name| name.to_string_lossy().into_owned())
 506                            .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
 507                    };
 508
 509                    let details = EntryDetails {
 510                        filename,
 511                        display_name: entry.repo_path.to_string_lossy().into_owned(),
 512                        // TODO get it from StatusEntry?
 513                        kind: EntryKind::File,
 514                        is_expanded,
 515                        path: entry.repo_path.clone(),
 516                        status: Some(status),
 517                        hunks: entry.hunks.clone(),
 518                        depth,
 519                        index,
 520                    };
 521                    callback(ix, details, cx);
 522                }
 523            }
 524            ix = end_ix;
 525        }
 526    }
 527
 528    // TODO: Update expanded directory state
 529    // TODO: Updates happen in the main loop, could be long for large workspaces
 530    fn update_visible_entries(
 531        &mut self,
 532        for_worktree: Option<WorktreeId>,
 533        _new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
 534        cx: &mut ViewContext<Self>,
 535    ) {
 536        let project = self.project.read(cx);
 537        let mut old_entries_removed = false;
 538        let mut after_update = Vec::new();
 539        self.visible_entries
 540            .retain(|worktree_entries| match for_worktree {
 541                Some(for_worktree) => {
 542                    if worktree_entries.worktree_id == for_worktree {
 543                        old_entries_removed = true;
 544                        false
 545                    } else if old_entries_removed {
 546                        after_update.push(worktree_entries.clone());
 547                        false
 548                    } else {
 549                        true
 550                    }
 551                }
 552                None => false,
 553            });
 554        for worktree in project.visible_worktrees(cx) {
 555            let snapshot = worktree.read(cx).snapshot();
 556            let worktree_id = snapshot.id();
 557
 558            if for_worktree.is_some() && for_worktree != Some(worktree_id) {
 559                continue;
 560            }
 561
 562            let mut visible_worktree_entries = Vec::new();
 563            // Only use the first repository for now
 564            let repositories = snapshot.repositories().take(1);
 565            let mut work_directory = None;
 566            for repository in repositories {
 567                visible_worktree_entries.extend(repository.status());
 568                work_directory = Some(worktree::WorkDirectory::clone(repository));
 569            }
 570
 571            // TODO use the GitTraversal
 572            // let mut visible_worktree_entries = snapshot
 573            //     .entries(false, 0)
 574            //     .filter(|entry| !entry.is_external)
 575            //     .filter(|entry| entry.git_status.is_some())
 576            //     .cloned()
 577            //     .collect::<Vec<_>>();
 578            // snapshot.propagate_git_statuses(&mut visible_worktree_entries);
 579            // project::sort_worktree_entries(&mut visible_worktree_entries);
 580
 581            if !visible_worktree_entries.is_empty() {
 582                self.visible_entries.push(WorktreeEntries {
 583                    worktree_id,
 584                    work_directory: work_directory.unwrap(),
 585                    visible_entries: visible_worktree_entries
 586                        .into_iter()
 587                        .map(|entry| GitPanelEntry {
 588                            entry,
 589                            hunks: Rc::default(),
 590                        })
 591                        .collect(),
 592                    paths: Rc::default(),
 593                });
 594            }
 595        }
 596        self.visible_entries.extend(after_update);
 597
 598        // TODO re-implement this
 599        // if let Some((worktree_id, entry_id)) = new_selected_entry {
 600        //     self.selected_item = self.visible_entries.iter().enumerate().find_map(
 601        //         |(worktree_index, worktree_entries)| {
 602        //             if worktree_entries.worktree_id == worktree_id {
 603        //                 worktree_entries
 604        //                     .visible_entries
 605        //                     .iter()
 606        //                     .position(|entry| entry.id == entry_id)
 607        //                     .map(|entry_index| {
 608        //                         worktree_index * worktree_entries.visible_entries.len()
 609        //                             + entry_index
 610        //                     })
 611        //             } else {
 612        //                 None
 613        //             }
 614        //         },
 615        //     );
 616        // }
 617
 618        let project = self.project.downgrade();
 619        self.git_diff_editor_updates = cx.spawn(|git_panel, mut cx| async move {
 620            cx.background_executor()
 621                .timer(UPDATE_DEBOUNCE)
 622                .await;
 623            let Some(project_buffers) = git_panel
 624                .update(&mut cx, |git_panel, cx| {
 625                    futures::future::join_all(git_panel.visible_entries.iter_mut().flat_map(
 626                        |worktree_entries| {
 627                            worktree_entries
 628                                .visible_entries
 629                                .iter()
 630                                .filter_map(|entry| {
 631                                    let git_status = entry.status;
 632                                    let entry_hunks = entry.hunks.clone();
 633                                    let (entry_path, unstaged_changes_task) =
 634                                        project.update(cx, |project, cx| {
 635                                            let entry_path = ProjectPath {
 636                                                worktree_id: worktree_entries.worktree_id,
 637                                                path: worktree_entries.work_directory.unrelativize(&entry.repo_path)?,
 638                                            };
 639                                            let open_task =
 640                                                project.open_path(entry_path.clone(), cx);
 641                                            let unstaged_changes_task =
 642                                                cx.spawn(|project, mut cx| async move {
 643                                                    let (_, opened_model) = open_task
 644                                                        .await
 645                                                        .context("opening buffer")?;
 646                                                    let buffer = opened_model
 647                                                        .downcast::<Buffer>()
 648                                                        .map_err(|_| {
 649                                                            anyhow::anyhow!(
 650                                                                "accessing buffer for entry"
 651                                                            )
 652                                                        })?;
 653                                                    // TODO added files have noop changes and those are not expanded properly in the multi buffer
 654                                                    let unstaged_changes = project
 655                                                        .update(&mut cx, |project, cx| {
 656                                                            project.open_unstaged_changes(
 657                                                                buffer.clone(),
 658                                                                cx,
 659                                                            )
 660                                                        })?
 661                                                        .await
 662                                                        .context("opening unstaged changes")?;
 663
 664                                                    let hunks = cx.update(|cx| {
 665                                                        entry_hunks
 666                                                            .get_or_init(|| {
 667                                                                match git_status {
 668                                                                    GitFileStatus::Added => {
 669                                                                        let buffer_snapshot = buffer.read(cx).snapshot();
 670                                                                        let entire_buffer_range =
 671                                                                            buffer_snapshot.anchor_after(0)
 672                                                                                ..buffer_snapshot
 673                                                                                    .anchor_before(
 674                                                                                        buffer_snapshot.len(),
 675                                                                                    );
 676                                                                        let entire_buffer_point_range =
 677                                                                            entire_buffer_range
 678                                                                                .clone()
 679                                                                                .to_point(&buffer_snapshot);
 680
 681                                                                        vec![DiffHunk {
 682                                                                            row_range: entire_buffer_point_range
 683                                                                                .start
 684                                                                                .row
 685                                                                                ..entire_buffer_point_range
 686                                                                                    .end
 687                                                                                    .row,
 688                                                                            buffer_range: entire_buffer_range,
 689                                                                            diff_base_byte_range: 0..0,
 690                                                                        }]
 691                                                                    }
 692                                                                    GitFileStatus::Modified => {
 693                                                                            let buffer_snapshot =
 694                                                                                buffer.read(cx).snapshot();
 695                                                                            unstaged_changes.read(cx)
 696                                                                                .diff_to_buffer
 697                                                                                .hunks_in_row_range(
 698                                                                                    0..BufferRow::MAX,
 699                                                                                    &buffer_snapshot,
 700                                                                                )
 701                                                                                .collect()
 702                                                                    }
 703                                                                    // TODO support these
 704                                                                    GitFileStatus::Conflict | GitFileStatus::Deleted | GitFileStatus::Untracked => Vec::new(),
 705                                                                }
 706                                                            }).clone()
 707                                                    })?;
 708
 709                                                    anyhow::Ok((buffer, unstaged_changes, hunks))
 710                                                });
 711                                            Some((entry_path, unstaged_changes_task))
 712                                        }).ok()??;
 713                                    Some((entry_path, unstaged_changes_task))
 714                                })
 715                                .map(|(entry_path, open_task)| async move {
 716                                    (entry_path, open_task.await)
 717                                })
 718                                .collect::<Vec<_>>()
 719                        },
 720                    ))
 721                })
 722                .ok()
 723            else {
 724                return;
 725            };
 726
 727            let project_buffers = project_buffers.await;
 728            if project_buffers.is_empty() {
 729                return;
 730            }
 731            let mut change_sets = Vec::with_capacity(project_buffers.len());
 732            if let Some(buffer_update_task) = git_panel
 733                .update(&mut cx, |git_panel, cx| {
 734                    let editor = git_panel.git_diff_editor.clone()?;
 735                    let multi_buffer = editor.read(cx).buffer().clone();
 736                    let mut buffers_with_ranges = Vec::with_capacity(project_buffers.len());
 737                    for (buffer_path, open_result) in project_buffers {
 738                        if let Some((buffer, unstaged_changes, diff_hunks)) = open_result
 739                            .with_context(|| format!("opening buffer {buffer_path:?}"))
 740                            .log_err()
 741                        {
 742                            change_sets.push(unstaged_changes);
 743                            buffers_with_ranges.push((
 744                                buffer,
 745                                diff_hunks
 746                                    .into_iter()
 747                                    .map(|hunk| hunk.buffer_range)
 748                                    .collect(),
 749                            ));
 750                        }
 751                    }
 752
 753                    Some(multi_buffer.update(cx, |multi_buffer, cx| {
 754                        multi_buffer.clear(cx);
 755                        multi_buffer.push_multiple_excerpts_with_context_lines(
 756                            buffers_with_ranges,
 757                            DEFAULT_MULTIBUFFER_CONTEXT,
 758                            cx,
 759                        )
 760                    }))
 761                })
 762                .ok().flatten()
 763            {
 764                buffer_update_task.await;
 765                git_panel
 766                    .update(&mut cx, |git_panel, cx| {
 767                        if let Some(diff_editor) = git_panel.git_diff_editor.as_ref() {
 768                            diff_editor.update(cx, |editor, cx| {
 769                                for change_set in change_sets {
 770                                    editor.add_change_set(change_set, cx);
 771                                }
 772                            });
 773                        }
 774                    })
 775                    .ok();
 776            }
 777        });
 778
 779        cx.notify();
 780    }
 781}
 782
 783impl GitPanel {
 784    pub fn panel_button(
 785        &self,
 786        id: impl Into<SharedString>,
 787        label: impl Into<SharedString>,
 788    ) -> Button {
 789        let id = id.into().clone();
 790        let label = label.into().clone();
 791
 792        Button::new(id, label)
 793            .label_size(LabelSize::Small)
 794            .layer(ElevationIndex::ElevatedSurface)
 795            .size(ButtonSize::Compact)
 796            .style(ButtonStyle::Filled)
 797    }
 798
 799    pub fn render_divider(&self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
 800        h_flex()
 801            .items_center()
 802            .h(px(8.))
 803            .child(Divider::horizontal_dashed().color(DividerColor::Border))
 804    }
 805
 806    pub fn render_panel_header(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
 807        let focus_handle = self.focus_handle(cx).clone();
 808
 809        let changes_string = format!("{} changes", self.entry_count());
 810
 811        h_flex()
 812            .h(px(32.))
 813            .items_center()
 814            .px_3()
 815            .bg(ElevationIndex::Surface.bg(cx))
 816            .child(
 817                h_flex()
 818                    .gap_2()
 819                    .child(Checkbox::new("all-changes", true.into()).disabled(true))
 820                    .child(div().text_buffer(cx).text_ui_sm(cx).child(changes_string)),
 821            )
 822            .child(div().flex_grow())
 823            .child(
 824                h_flex()
 825                    .gap_2()
 826                    .child(
 827                        IconButton::new("discard-changes", IconName::Undo)
 828                            .tooltip(move |cx| {
 829                                let focus_handle = focus_handle.clone();
 830
 831                                Tooltip::for_action_in(
 832                                    "Discard all changes",
 833                                    &DiscardAll,
 834                                    &focus_handle,
 835                                    cx,
 836                                )
 837                            })
 838                            .icon_size(IconSize::Small)
 839                            .disabled(true),
 840                    )
 841                    .child(if self.all_staged() {
 842                        self.panel_button("unstage-all", "Unstage All").on_click(
 843                            cx.listener(move |_, _, cx| cx.dispatch_action(Box::new(DiscardAll))),
 844                        )
 845                    } else {
 846                        self.panel_button("stage-all", "Stage All").on_click(
 847                            cx.listener(move |_, _, cx| cx.dispatch_action(Box::new(StageAll))),
 848                        )
 849                    }),
 850            )
 851    }
 852
 853    pub fn render_commit_editor(&self, cx: &ViewContext<Self>) -> impl IntoElement {
 854        let focus_handle_1 = self.focus_handle(cx).clone();
 855        let focus_handle_2 = self.focus_handle(cx).clone();
 856
 857        let commit_staged_button = self
 858            .panel_button("commit-staged-changes", "Commit")
 859            .tooltip(move |cx| {
 860                let focus_handle = focus_handle_1.clone();
 861                Tooltip::for_action_in(
 862                    "Commit all staged changes",
 863                    &CommitStagedChanges,
 864                    &focus_handle,
 865                    cx,
 866                )
 867            })
 868            .on_click(cx.listener(|this, _: &ClickEvent, cx| {
 869                this.commit_staged_changes(&CommitStagedChanges, cx)
 870            }));
 871
 872        let commit_all_button = self
 873            .panel_button("commit-all-changes", "Commit All")
 874            .tooltip(move |cx| {
 875                let focus_handle = focus_handle_2.clone();
 876                Tooltip::for_action_in(
 877                    "Commit all changes, including unstaged changes",
 878                    &CommitAllChanges,
 879                    &focus_handle,
 880                    cx,
 881                )
 882            })
 883            .on_click(cx.listener(|this, _: &ClickEvent, cx| {
 884                this.commit_all_changes(&CommitAllChanges, cx)
 885            }));
 886
 887        div().w_full().h(px(140.)).px_2().pt_1().pb_2().child(
 888            v_flex()
 889                .h_full()
 890                .py_2p5()
 891                .px_3()
 892                .bg(cx.theme().colors().editor_background)
 893                .font_buffer(cx)
 894                .text_ui_sm(cx)
 895                .text_color(cx.theme().colors().text_muted)
 896                .child("Add a message")
 897                .gap_1()
 898                .child(div().flex_grow())
 899                .child(h_flex().child(div().gap_1().flex_grow()).child(
 900                    if self.current_modifiers.alt {
 901                        commit_all_button
 902                    } else {
 903                        commit_staged_button
 904                    },
 905                ))
 906                .cursor(CursorStyle::OperationNotAllowed)
 907                .opacity(0.5),
 908        )
 909    }
 910
 911    fn render_empty_state(&self, cx: &ViewContext<Self>) -> impl IntoElement {
 912        h_flex()
 913            .h_full()
 914            .flex_1()
 915            .justify_center()
 916            .items_center()
 917            .child(
 918                v_flex()
 919                    .gap_3()
 920                    .child("No changes to commit")
 921                    .text_ui_sm(cx)
 922                    .mx_auto()
 923                    .text_color(Color::Placeholder.color(cx)),
 924            )
 925    }
 926
 927    fn render_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
 928        if !Self::should_show_scrollbar(cx)
 929            || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
 930        {
 931            return None;
 932        }
 933        Some(
 934            div()
 935                .occlude()
 936                .id("project-panel-vertical-scroll")
 937                .on_mouse_move(cx.listener(|_, _, cx| {
 938                    cx.notify();
 939                    cx.stop_propagation()
 940                }))
 941                .on_hover(|_, cx| {
 942                    cx.stop_propagation();
 943                })
 944                .on_any_mouse_down(|_, cx| {
 945                    cx.stop_propagation();
 946                })
 947                .on_mouse_up(
 948                    MouseButton::Left,
 949                    cx.listener(|this, _, cx| {
 950                        if !this.scrollbar_state.is_dragging()
 951                            && !this.focus_handle.contains_focused(cx)
 952                        {
 953                            this.hide_scrollbar(cx);
 954                            cx.notify();
 955                        }
 956
 957                        cx.stop_propagation();
 958                    }),
 959                )
 960                .on_scroll_wheel(cx.listener(|_, _, cx| {
 961                    cx.notify();
 962                }))
 963                .h_full()
 964                .absolute()
 965                .right_1()
 966                .top_1()
 967                .bottom_1()
 968                .w(px(12.))
 969                .cursor_default()
 970                .children(Scrollbar::vertical(
 971                    // percentage as f32..end_offset as f32,
 972                    self.scrollbar_state.clone(),
 973                )),
 974        )
 975    }
 976
 977    fn render_entries(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
 978        let item_count = self
 979            .visible_entries
 980            .iter()
 981            .map(|worktree_entries| worktree_entries.visible_entries.len())
 982            .sum();
 983        let selected_entry = self.selected_item;
 984        h_flex()
 985            .size_full()
 986            .overflow_hidden()
 987            .child(
 988                uniform_list(cx.view().clone(), "entries", item_count, {
 989                    move |git_panel, range, cx| {
 990                        let mut items = Vec::with_capacity(range.end - range.start);
 991                        git_panel.for_each_visible_entry(range, cx, |id, details, cx| {
 992                            items.push(git_panel.render_entry(
 993                                id,
 994                                Some(details.index) == selected_entry,
 995                                details,
 996                                cx,
 997                            ));
 998                        });
 999                        items
1000                    }
1001                })
1002                .size_full()
1003                .with_sizing_behavior(ListSizingBehavior::Infer)
1004                .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
1005                // .with_width_from_item(self.max_width_item_index)
1006                .track_scroll(self.scroll_handle.clone()),
1007            )
1008            .children(self.render_scrollbar(cx))
1009    }
1010
1011    fn render_entry(
1012        &self,
1013        ix: usize,
1014        selected: bool,
1015        details: EntryDetails,
1016        cx: &ViewContext<Self>,
1017    ) -> impl IntoElement {
1018        let checkbox_id = ElementId::Name(format!("checkbox_{}", ix).into());
1019        let is_staged = ToggleState::Selected;
1020        let handle = cx.view().downgrade();
1021
1022        h_flex()
1023            .id(("git-panel-entry", ix))
1024            .h(px(28.))
1025            .w_full()
1026            .pl(px(12. + 12. * details.depth as f32))
1027            .pr(px(4.))
1028            .items_center()
1029            .gap_2()
1030            .font_buffer(cx)
1031            .text_ui_sm(cx)
1032            .when(!details.is_dir(), |this| {
1033                this.child(Checkbox::new(checkbox_id, is_staged))
1034            })
1035            .when_some(details.status, |this, status| {
1036                this.child(git_status_icon(status))
1037            })
1038            .child(
1039                ListItem::new(details.path.0.clone())
1040                    .toggle_state(selected)
1041                    .child(h_flex().gap_1p5().child(details.display_name.clone()))
1042                    .on_click(move |e, cx| {
1043                        handle
1044                            .update(cx, |git_panel, cx| {
1045                                git_panel.selected_item = Some(details.index);
1046                                let change_focus = e.down.click_count > 1;
1047                                git_panel.reveal_entry_in_git_editor(
1048                                    details.hunks.clone(),
1049                                    change_focus,
1050                                    None,
1051                                    cx,
1052                                );
1053                            })
1054                            .ok();
1055                    }),
1056            )
1057    }
1058
1059    fn reveal_entry_in_git_editor(
1060        &mut self,
1061        hunks: Rc<OnceCell<Vec<DiffHunk>>>,
1062        change_focus: bool,
1063        debounce: Option<Duration>,
1064        cx: &mut ViewContext<Self>,
1065    ) {
1066        let workspace = self.workspace.clone();
1067        let Some(diff_editor) = self.git_diff_editor.clone() else {
1068            return;
1069        };
1070        self.reveal_in_editor = cx.spawn(|_, mut cx| async move {
1071            if let Some(debounce) = debounce {
1072                cx.background_executor().timer(debounce).await;
1073            }
1074
1075            let Some(editor) = workspace
1076                .update(&mut cx, |workspace, cx| {
1077                    let git_diff_editor = workspace
1078                        .items_of_type::<Editor>(cx)
1079                        .find(|editor| &diff_editor == editor);
1080                    match git_diff_editor {
1081                        Some(existing_editor) => {
1082                            workspace.activate_item(&existing_editor, true, change_focus, cx);
1083                            existing_editor
1084                        }
1085                        None => {
1086                            workspace.active_pane().update(cx, |pane, cx| {
1087                                pane.add_item(
1088                                    diff_editor.boxed_clone(),
1089                                    true,
1090                                    change_focus,
1091                                    None,
1092                                    cx,
1093                                )
1094                            });
1095                            diff_editor.clone()
1096                        }
1097                    }
1098                })
1099                .ok()
1100            else {
1101                return;
1102            };
1103
1104            if let Some(first_hunk) = hunks.get().and_then(|hunks| hunks.first()) {
1105                let hunk_buffer_range = &first_hunk.buffer_range;
1106                if let Some(buffer_id) = hunk_buffer_range
1107                    .start
1108                    .buffer_id
1109                    .or_else(|| first_hunk.buffer_range.end.buffer_id)
1110                {
1111                    editor
1112                        .update(&mut cx, |editor, cx| {
1113                            let multi_buffer = editor.buffer().read(cx);
1114                            let buffer = multi_buffer.buffer(buffer_id)?;
1115                            let buffer_snapshot = buffer.read(cx).snapshot();
1116                            let (excerpt_id, _) = multi_buffer
1117                                .excerpts_for_buffer(&buffer, cx)
1118                                .into_iter()
1119                                .find(|(_, excerpt)| {
1120                                    hunk_buffer_range
1121                                        .start
1122                                        .cmp(&excerpt.context.start, &buffer_snapshot)
1123                                        .is_ge()
1124                                        && hunk_buffer_range
1125                                            .end
1126                                            .cmp(&excerpt.context.end, &buffer_snapshot)
1127                                            .is_le()
1128                                })?;
1129                            let multi_buffer_hunk_start = multi_buffer
1130                                .snapshot(cx)
1131                                .anchor_in_excerpt(excerpt_id, hunk_buffer_range.start)?;
1132                            editor.change_selections(
1133                                Some(Autoscroll::Strategy(AutoscrollStrategy::Center)),
1134                                cx,
1135                                |s| {
1136                                    s.select_ranges(Some(
1137                                        multi_buffer_hunk_start..multi_buffer_hunk_start,
1138                                    ))
1139                                },
1140                            );
1141                            cx.notify();
1142                            Some(())
1143                        })
1144                        .ok()
1145                        .flatten();
1146                }
1147            }
1148        });
1149    }
1150}
1151
1152impl Render for GitPanel {
1153    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1154        let project = self.project.read(cx);
1155
1156        v_flex()
1157            .id("git_panel")
1158            .key_context(self.dispatch_context())
1159            .track_focus(&self.focus_handle)
1160            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
1161            .when(!project.is_read_only(cx), |this| {
1162                this.on_action(cx.listener(|this, &StageAll, cx| this.stage_all(&StageAll, cx)))
1163                    .on_action(
1164                        cx.listener(|this, &UnstageAll, cx| this.unstage_all(&UnstageAll, cx)),
1165                    )
1166                    .on_action(
1167                        cx.listener(|this, &DiscardAll, cx| this.discard_all(&DiscardAll, cx)),
1168                    )
1169                    .on_action(cx.listener(|this, &CommitStagedChanges, cx| {
1170                        this.commit_staged_changes(&CommitStagedChanges, cx)
1171                    }))
1172                    .on_action(cx.listener(|this, &CommitAllChanges, cx| {
1173                        this.commit_all_changes(&CommitAllChanges, cx)
1174                    }))
1175            })
1176            .on_action(cx.listener(Self::select_next))
1177            .on_action(cx.listener(Self::select_prev))
1178            .on_hover(cx.listener(|this, hovered, cx| {
1179                if *hovered {
1180                    this.show_scrollbar = true;
1181                    this.hide_scrollbar_task.take();
1182                    cx.notify();
1183                } else if !this.focus_handle.contains_focused(cx) {
1184                    this.hide_scrollbar(cx);
1185                }
1186            }))
1187            .size_full()
1188            .overflow_hidden()
1189            .font_buffer(cx)
1190            .py_1()
1191            .bg(ElevationIndex::Surface.bg(cx))
1192            .child(self.render_panel_header(cx))
1193            .child(self.render_divider(cx))
1194            .child(if !self.no_entries() {
1195                self.render_entries(cx).into_any_element()
1196            } else {
1197                self.render_empty_state(cx).into_any_element()
1198            })
1199            .child(self.render_divider(cx))
1200            .child(self.render_commit_editor(cx))
1201    }
1202}
1203
1204impl FocusableView for GitPanel {
1205    fn focus_handle(&self, _: &AppContext) -> gpui::FocusHandle {
1206        self.focus_handle.clone()
1207    }
1208}
1209
1210impl EventEmitter<Event> for GitPanel {}
1211
1212impl EventEmitter<PanelEvent> for GitPanel {}
1213
1214impl Panel for GitPanel {
1215    fn persistent_name() -> &'static str {
1216        "GitPanel"
1217    }
1218
1219    fn position(&self, cx: &WindowContext) -> DockPosition {
1220        GitPanelSettings::get_global(cx).dock
1221    }
1222
1223    fn position_is_valid(&self, position: DockPosition) -> bool {
1224        matches!(position, DockPosition::Left | DockPosition::Right)
1225    }
1226
1227    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1228        settings::update_settings_file::<GitPanelSettings>(
1229            self.fs.clone(),
1230            cx,
1231            move |settings, _| settings.dock = Some(position),
1232        );
1233    }
1234
1235    fn size(&self, cx: &WindowContext) -> Pixels {
1236        self.width
1237            .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
1238    }
1239
1240    fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
1241        self.width = size;
1242        self.serialize(cx);
1243        cx.notify();
1244    }
1245
1246    fn icon(&self, cx: &WindowContext) -> Option<ui::IconName> {
1247        Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
1248    }
1249
1250    fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
1251        Some("Git Panel")
1252    }
1253
1254    fn toggle_action(&self) -> Box<dyn Action> {
1255        Box::new(ToggleFocus)
1256    }
1257
1258    fn activation_priority(&self) -> u32 {
1259        2
1260    }
1261}
1262
1263fn diff_display_editor(cx: &mut WindowContext) -> View<Editor> {
1264    cx.new_view(|cx| {
1265        let multi_buffer = cx.new_model(|_| {
1266            MultiBuffer::new(language::Capability::ReadWrite).with_title("Project diff".to_string())
1267        });
1268        let mut editor = Editor::for_multibuffer(multi_buffer, None, true, cx);
1269        editor.set_expand_all_diff_hunks();
1270        editor
1271    })
1272}