git_panel.rs

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