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