Make git panel entries clickable (#22329)

Kirill Bulatov created

Makes a first pass over git panel UI, making it more interactive.


![image](https://github.com/user-attachments/assets/4d43b086-4ef2-4913-9783-2b9467d99c9a)


* every item can be selected, the selection is shown in the panel
* every item can be clicked, which changes the selection and
creates/focuses the editor with a project changes multi buffer
* the editor is scrolled so that the clicked item is in the center
* it's possible to nagivate up and down the panel, selecting
next/previous items in it, triggering the editor scroll

Known issues:

* entries are updated oddly sometimes (should become better after
DiffMap improvements land?)
* only unstaged diffs are shown currently (entry status storage should
help with this)
* no deleted files are displayed (the underlying work is done by others
now)
* added files have no diff hunks shown (DiffMap will have it?)
* performance story has not improved (again, DiffMap and status storage
should help with this)

Release Notes:

- N/A

Change summary

Cargo.lock                     |   4 
crates/editor/src/editor.rs    |   9 
crates/git_ui/Cargo.toml       |   4 
crates/git_ui/src/git_panel.rs | 575 ++++++++++++++++++++++++++++++++---
4 files changed, 534 insertions(+), 58 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5170,8 +5170,12 @@ dependencies = [
  "anyhow",
  "collections",
  "db",
+ "editor",
+ "futures 0.3.31",
  "git",
  "gpui",
+ "language",
+ "menu",
  "project",
  "schemars",
  "serde",

crates/editor/src/editor.rs 🔗

@@ -128,6 +128,7 @@ use multi_buffer::{
     ExpandExcerptDirection, MultiBufferDiffHunk, MultiBufferPoint, MultiBufferRow, ToOffsetUtf16,
 };
 use project::{
+    buffer_store::BufferChangeSet,
     lsp_store::{FormatTarget, FormatTrigger, OpenLspBufferHandle},
     project_settings::{GitGutterSetting, ProjectSettings},
     CodeAction, Completion, CompletionIntent, DocumentHighlight, InlayHint, Location, LocationLink,
@@ -12950,6 +12951,14 @@ impl Editor {
             .and_then(|item| item.to_any().downcast_ref::<T>())
     }
 
+    pub fn add_change_set(
+        &mut self,
+        change_set: Model<BufferChangeSet>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.diff_map.add_change_set(change_set, cx);
+    }
+
     fn character_size(&self, cx: &mut ViewContext<Self>) -> gpui::Point<Pixels> {
         let text_layout_details = self.text_layout_details(cx);
         let style = &text_layout_details.editor_style;

crates/git_ui/Cargo.toml 🔗

@@ -15,7 +15,11 @@ path = "src/git_ui.rs"
 [dependencies]
 anyhow.workspace = true
 db.workspace = true
+editor.workspace = true
+futures.workspace = true
 gpui.workspace = true
+language.workspace = true
+menu.workspace = true
 project.workspace = true
 schemars.workspace = true
 serde.workspace = true

crates/git_ui/src/git_panel.rs 🔗

@@ -1,13 +1,19 @@
-use anyhow::Result;
+use anyhow::{Context as _, Result};
 use collections::HashMap;
 use db::kvp::KEY_VALUE_STORE;
-use git::repository::GitFileStatus;
+use editor::{
+    scroll::{Autoscroll, AutoscrollStrategy},
+    Editor, MultiBuffer, DEFAULT_MULTIBUFFER_CONTEXT,
+};
+use git::{diff::DiffHunk, repository::GitFileStatus};
 use gpui::{
     actions, prelude::*, uniform_list, Action, AppContext, AsyncWindowContext, ClickEvent,
     CursorStyle, EventEmitter, FocusHandle, FocusableView, KeyContext,
     ListHorizontalSizingBehavior, ListSizingBehavior, Model, Modifiers, ModifiersChangedEvent,
-    MouseButton, Stateful, Task, UniformListScrollHandle, View, WeakView,
+    MouseButton, ScrollStrategy, Stateful, Task, UniformListScrollHandle, View, WeakView,
 };
+use language::{Buffer, BufferRow, OffsetRangeExt};
+use menu::{SelectNext, SelectPrev};
 use project::{Entry, EntryKind, Fs, Project, ProjectEntryId, WorktreeId};
 use serde::{Deserialize, Serialize};
 use settings::Settings as _;
@@ -15,17 +21,22 @@ use std::{
     cell::OnceCell,
     collections::HashSet,
     ffi::OsStr,
-    ops::Range,
+    ops::{Deref, Range},
     path::{Path, PathBuf},
+    rc::Rc,
     sync::Arc,
     time::Duration,
+    usize,
 };
 use ui::{
-    prelude::*, Checkbox, Divider, DividerColor, ElevationIndex, Scrollbar, ScrollbarState, Tooltip,
+    prelude::*, Checkbox, Divider, DividerColor, ElevationIndex, ListItem, Scrollbar,
+    ScrollbarState, Tooltip,
 };
 use util::{ResultExt, TryFutureExt};
-use workspace::dock::{DockPosition, Panel, PanelEvent};
-use workspace::Workspace;
+use workspace::{
+    dock::{DockPosition, Panel, PanelEvent},
+    ItemHandle, Workspace,
+};
 
 use crate::{git_status_icon, settings::GitPanelSettings};
 use crate::{CommitAllChanges, CommitStagedChanges, DiscardAll, StageAll, UnstageAll};
@@ -34,6 +45,8 @@ actions!(git_panel, [ToggleFocus]);
 
 const GIT_PANEL_KEY: &str = "GitPanel";
 
+const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
+
 pub fn init(cx: &mut AppContext) {
     cx.observe_new_views(
         |workspace: &mut Workspace, _cx: &mut ViewContext<Workspace>| {
@@ -61,6 +74,8 @@ struct EntryDetails {
     depth: usize,
     is_expanded: bool,
     status: Option<GitFileStatus>,
+    hunks: Rc<OnceCell<Vec<DiffHunk>>>,
+    index: usize,
 }
 
 impl EntryDetails {
@@ -75,7 +90,7 @@ struct SerializedGitPanel {
 }
 
 pub struct GitPanel {
-    _workspace: WeakView<Workspace>,
+    workspace: WeakView<Workspace>,
     current_modifiers: Modifiers,
     focus_handle: FocusHandle,
     fs: Arc<dyn Fs>,
@@ -90,8 +105,43 @@ pub struct GitPanel {
 
     // The entries that are currently shown in the panel, aka
     // not hidden by folding or such
-    visible_entries: Vec<(WorktreeId, Vec<Entry>, OnceCell<HashSet<Arc<Path>>>)>,
+    visible_entries: Vec<WorktreeEntries>,
     width: Option<Pixels>,
+    git_diff_editor: View<Editor>,
+    git_diff_editor_updates: Task<()>,
+    reveal_in_editor: Task<()>,
+}
+
+#[derive(Debug, Clone)]
+struct WorktreeEntries {
+    worktree_id: WorktreeId,
+    visible_entries: Vec<GitPanelEntry>,
+    paths: Rc<OnceCell<HashSet<Arc<Path>>>>,
+}
+
+#[derive(Debug, Clone)]
+struct GitPanelEntry {
+    entry: Entry,
+    hunks: Rc<OnceCell<Vec<DiffHunk>>>,
+}
+
+impl Deref for GitPanelEntry {
+    type Target = Entry;
+
+    fn deref(&self) -> &Self::Target {
+        &self.entry
+    }
+}
+
+impl WorktreeEntries {
+    fn paths(&self) -> &HashSet<Arc<Path>> {
+        self.paths.get_or_init(|| {
+            self.visible_entries
+                .iter()
+                .map(|e| (e.entry.path.clone()))
+                .collect()
+        })
+    }
 }
 
 impl GitPanel {
@@ -118,18 +168,28 @@ impl GitPanel {
                 this.hide_scrollbar(cx);
             })
             .detach();
-            cx.subscribe(&project, |this, _project, event, cx| match event {
+            cx.subscribe(&project, |this, project, event, cx| match event {
                 project::Event::WorktreeRemoved(id) => {
                     this.expanded_dir_ids.remove(id);
-                    this.update_visible_entries(None, cx);
+                    this.update_visible_entries(None, None, cx);
+                    cx.notify();
+                }
+                project::Event::WorktreeOrderChanged => {
+                    this.update_visible_entries(None, None, cx);
                     cx.notify();
                 }
-                project::Event::WorktreeUpdatedEntries(_, _)
-                | project::Event::WorktreeAdded(_)
-                | project::Event::WorktreeOrderChanged => {
-                    this.update_visible_entries(None, cx);
+                project::Event::WorktreeUpdatedEntries(id, _)
+                | project::Event::WorktreeAdded(id)
+                | project::Event::WorktreeUpdatedGitRepositories(id) => {
+                    this.update_visible_entries(Some(*id), None, cx);
                     cx.notify();
                 }
+                project::Event::Closed => {
+                    this.git_diff_editor_updates = Task::ready(());
+                    this.expanded_dir_ids.clear();
+                    this.visible_entries.clear();
+                    this.git_diff_editor = diff_display_editor(project.clone(), cx);
+                }
                 _ => {}
             })
             .detach();
@@ -137,11 +197,10 @@ impl GitPanel {
             let scroll_handle = UniformListScrollHandle::new();
 
             let mut this = Self {
-                _workspace: weak_workspace,
+                workspace: weak_workspace,
                 focus_handle: cx.focus_handle(),
                 fs,
                 pending_serialization: Task::ready(None),
-                project,
                 visible_entries: Vec::new(),
                 current_modifiers: cx.modifiers(),
                 expanded_dir_ids: Default::default(),
@@ -152,8 +211,12 @@ impl GitPanel {
                 selected_item: None,
                 show_scrollbar: !Self::should_autohide_scrollbar(cx),
                 hide_scrollbar_task: None,
+                git_diff_editor: diff_display_editor(project.clone(), cx),
+                git_diff_editor_updates: Task::ready(()),
+                reveal_in_editor: Task::ready(()),
+                project,
             };
-            this.update_visible_entries(None, cx);
+            this.update_visible_entries(None, None, cx);
             this
         });
 
@@ -254,6 +317,82 @@ impl GitPanel {
 
         (depth, difference)
     }
+
+    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
+        let item_count = self
+            .visible_entries
+            .iter()
+            .map(|worktree_entries| worktree_entries.visible_entries.len())
+            .sum::<usize>();
+        if item_count == 0 {
+            return;
+        }
+        let selection = match self.selected_item {
+            Some(i) => {
+                if i < item_count - 1 {
+                    self.selected_item = Some(i + 1);
+                    i + 1
+                } else {
+                    self.selected_item = Some(0);
+                    0
+                }
+            }
+            None => {
+                self.selected_item = Some(0);
+                0
+            }
+        };
+        self.scroll_handle
+            .scroll_to_item(selection, ScrollStrategy::Center);
+
+        let mut hunks = None;
+        self.for_each_visible_entry(selection..selection + 1, cx, |_, entry, _| {
+            hunks = Some(entry.hunks.clone());
+        });
+        if let Some(hunks) = hunks {
+            self.reveal_entry_in_git_editor(hunks, false, Some(UPDATE_DEBOUNCE), cx);
+        }
+
+        cx.notify();
+    }
+
+    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
+        let item_count = self
+            .visible_entries
+            .iter()
+            .map(|worktree_entries| worktree_entries.visible_entries.len())
+            .sum::<usize>();
+        if item_count == 0 {
+            return;
+        }
+        let selection = match self.selected_item {
+            Some(i) => {
+                if i > 0 {
+                    self.selected_item = Some(i - 1);
+                    i - 1
+                } else {
+                    self.selected_item = Some(item_count - 1);
+                    item_count - 1
+                }
+            }
+            None => {
+                self.selected_item = Some(0);
+                0
+            }
+        };
+        self.scroll_handle
+            .scroll_to_item(selection, ScrollStrategy::Center);
+
+        let mut hunks = None;
+        self.for_each_visible_entry(selection..selection + 1, cx, |_, entry, _| {
+            hunks = Some(entry.hunks.clone());
+        });
+        if let Some(hunks) = hunks {
+            self.reveal_entry_in_git_editor(hunks, false, Some(UPDATE_DEBOUNCE), cx);
+        }
+
+        cx.notify();
+    }
 }
 
 impl GitPanel {
@@ -296,8 +435,9 @@ impl GitPanel {
     fn entry_count(&self) -> usize {
         self.visible_entries
             .iter()
-            .map(|(_, entries, _)| {
-                entries
+            .map(|worktree_entries| {
+                worktree_entries
+                    .visible_entries
                     .iter()
                     .filter(|entry| entry.git_status.is_some())
                     .count()
@@ -312,19 +452,23 @@ impl GitPanel {
         mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<Self>),
     ) {
         let mut ix = 0;
-        for (worktree_id, visible_worktree_entries, entries_paths) in &self.visible_entries {
+        for worktree_entries in &self.visible_entries {
             if ix >= range.end {
                 return;
             }
 
-            if ix + visible_worktree_entries.len() <= range.start {
-                ix += visible_worktree_entries.len();
+            if ix + worktree_entries.visible_entries.len() <= range.start {
+                ix += worktree_entries.visible_entries.len();
                 continue;
             }
 
-            let end_ix = range.end.min(ix + visible_worktree_entries.len());
+            let end_ix = range.end.min(ix + worktree_entries.visible_entries.len());
             // let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
-            if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
+            if let Some(worktree) = self
+                .project
+                .read(cx)
+                .worktree_for_id(worktree_entries.worktree_id, cx)
+            {
                 let snapshot = worktree.read(cx).snapshot();
                 let root_name = OsStr::new(snapshot.root_name());
                 let expanded_entry_ids = self
@@ -334,14 +478,14 @@ impl GitPanel {
                     .unwrap_or(&[]);
 
                 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
-                let entries = entries_paths.get_or_init(|| {
-                    visible_worktree_entries
-                        .iter()
-                        .map(|e| (e.path.clone()))
-                        .collect()
-                });
+                let entries = worktree_entries.paths();
 
-                for entry in visible_worktree_entries[entry_range].iter() {
+                let index_start = entry_range.start;
+                for (i, entry) in worktree_entries.visible_entries[entry_range]
+                    .iter()
+                    .enumerate()
+                {
+                    let index = index_start + i;
                     let status = entry.git_status;
                     let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
 
@@ -363,16 +507,16 @@ impl GitPanel {
                             .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
                     };
 
-                    let display_name = entry.path.to_string_lossy().into_owned();
-
                     let details = EntryDetails {
                         filename,
-                        display_name,
+                        display_name: entry.path.to_string_lossy().into_owned(),
                         kind: entry.kind,
                         is_expanded,
                         path: entry.path.clone(),
                         status,
+                        hunks: entry.hunks.clone(),
                         depth,
+                        index,
                     };
                     callback(entry.id, details, cx);
                 }
@@ -382,44 +526,75 @@ impl GitPanel {
     }
 
     // TODO: Update expanded directory state
+    // TODO: Updates happen in the main loop, could be long for large workspaces
     fn update_visible_entries(
         &mut self,
+        for_worktree: Option<WorktreeId>,
         new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
         cx: &mut ViewContext<Self>,
     ) {
         let project = self.project.read(cx);
-        self.visible_entries.clear();
-        for worktree in project.visible_worktrees(cx) {
-            let snapshot = worktree.read(cx).snapshot();
-            let worktree_id = snapshot.id();
-
-            let mut visible_worktree_entries = Vec::new();
-            let mut entry_iter = snapshot.entries(true, 0);
-            while let Some(entry) = entry_iter.entry() {
-                // Only include entries with a git status
-                if entry.git_status.is_some() {
-                    visible_worktree_entries.push(entry.clone());
+        let mut old_entries_removed = false;
+        let mut after_update = Vec::new();
+        self.visible_entries
+            .retain(|worktree_entries| match for_worktree {
+                Some(for_worktree) => {
+                    if worktree_entries.worktree_id == for_worktree {
+                        old_entries_removed = true;
+                        false
+                    } else if old_entries_removed {
+                        after_update.push(worktree_entries.clone());
+                        false
+                    } else {
+                        true
+                    }
                 }
-                entry_iter.advance();
+                None => false,
+            });
+        for worktree in project.visible_worktrees(cx) {
+            let worktree_id = worktree.read(cx).id();
+            if for_worktree.is_some() && for_worktree != Some(worktree_id) {
+                continue;
             }
+            let snapshot = worktree.read(cx).snapshot();
 
+            let mut visible_worktree_entries = snapshot
+                .entries(false, 0)
+                .filter(|entry| !entry.is_external)
+                .filter(|entry| entry.git_status.is_some())
+                .cloned()
+                .collect::<Vec<_>>();
             snapshot.propagate_git_statuses(&mut visible_worktree_entries);
             project::sort_worktree_entries(&mut visible_worktree_entries);
 
             if !visible_worktree_entries.is_empty() {
-                self.visible_entries
-                    .push((worktree_id, visible_worktree_entries, OnceCell::new()));
+                self.visible_entries.push(WorktreeEntries {
+                    worktree_id,
+                    visible_entries: visible_worktree_entries
+                        .into_iter()
+                        .map(|entry| GitPanelEntry {
+                            entry,
+                            hunks: Rc::default(),
+                        })
+                        .collect(),
+                    paths: Rc::default(),
+                });
             }
         }
+        self.visible_entries.extend(after_update);
 
         if let Some((worktree_id, entry_id)) = new_selected_entry {
             self.selected_item = self.visible_entries.iter().enumerate().find_map(
-                |(worktree_index, (id, entries, _))| {
-                    if *id == worktree_id {
-                        entries
+                |(worktree_index, worktree_entries)| {
+                    if worktree_entries.worktree_id == worktree_id {
+                        worktree_entries
+                            .visible_entries
                             .iter()
                             .position(|entry| entry.id == entry_id)
-                            .map(|entry_index| worktree_index * entries.len() + entry_index)
+                            .map(|entry_index| {
+                                worktree_index * worktree_entries.visible_entries.len()
+                                    + entry_index
+                            })
                     } else {
                         None
                     }
@@ -427,6 +602,163 @@ impl GitPanel {
             );
         }
 
+        let project = self.project.clone();
+        self.git_diff_editor_updates = cx.spawn(|git_panel, mut cx| async move {
+            cx.background_executor()
+                .timer(UPDATE_DEBOUNCE)
+                .await;
+            let Some(project_buffers) = git_panel
+                .update(&mut cx, |git_panel, cx| {
+                    futures::future::join_all(git_panel.visible_entries.iter_mut().flat_map(
+                        move |worktree_entries| {
+                            worktree_entries
+                                .visible_entries
+                                .iter()
+                                .filter_map(|entry| {
+                                    let git_status = entry.git_status()?;
+                                    let entry_hunks = entry.hunks.clone();
+                                    let (entry_path, unstaged_changes_task) =
+                                        project.update(cx, |project, cx| {
+                                            let entry_path =
+                                                project.path_for_entry(entry.id, cx)?;
+                                            let open_task =
+                                                project.open_path(entry_path.clone(), cx);
+                                            let unstaged_changes_task =
+                                                cx.spawn(|project, mut cx| async move {
+                                                    let (_, opened_model) = open_task
+                                                        .await
+                                                        .context("opening buffer")?;
+                                                    let buffer = opened_model
+                                                        .downcast::<Buffer>()
+                                                        .map_err(|_| {
+                                                            anyhow::anyhow!(
+                                                                "accessing buffer for entry"
+                                                            )
+                                                        })?;
+                                                    // TODO added files have noop changes and those are not expanded properly in the multi buffer
+                                                    let unstaged_changes = project
+                                                        .update(&mut cx, |project, cx| {
+                                                            project.open_unstaged_changes(
+                                                                buffer.clone(),
+                                                                cx,
+                                                            )
+                                                        })?
+                                                        .await
+                                                        .context("opening unstaged changes")?;
+
+                                                    let hunks = cx.update(|cx| {
+                                                        entry_hunks
+                                                            .get_or_init(|| {
+                                                                match git_status {
+                                                                    GitFileStatus::Added => {
+                                                                        let buffer_snapshot = buffer.read(cx).snapshot();
+                                                                        let entire_buffer_range =
+                                                                            buffer_snapshot.anchor_after(0)
+                                                                                ..buffer_snapshot
+                                                                                    .anchor_before(
+                                                                                        buffer_snapshot.len(),
+                                                                                    );
+                                                                        let entire_buffer_point_range =
+                                                                            entire_buffer_range
+                                                                                .clone()
+                                                                                .to_point(&buffer_snapshot);
+
+                                                                        vec![DiffHunk {
+                                                                            row_range: entire_buffer_point_range
+                                                                                .start
+                                                                                .row
+                                                                                ..entire_buffer_point_range
+                                                                                    .end
+                                                                                    .row,
+                                                                            buffer_range: entire_buffer_range,
+                                                                            diff_base_byte_range: 0..0,
+                                                                        }]
+                                                                    }
+                                                                    GitFileStatus::Modified => {
+                                                                            let buffer_snapshot =
+                                                                                buffer.read(cx).snapshot();
+                                                                            unstaged_changes.read(cx)
+                                                                                .diff_to_buffer
+                                                                                .hunks_in_row_range(
+                                                                                    0..BufferRow::MAX,
+                                                                                    &buffer_snapshot,
+                                                                                )
+                                                                                .collect()
+                                                                    }
+                                                                    // TODO support conflicts display
+                                                                    GitFileStatus::Conflict => Vec::new(),
+                                                                }
+                                                            }).clone()
+                                                    })?;
+
+                                                    anyhow::Ok((buffer, unstaged_changes, hunks))
+                                                });
+                                            Some((entry_path, unstaged_changes_task))
+                                        })?;
+                                    Some((entry_path, unstaged_changes_task))
+                                })
+                                .map(|(entry_path, open_task)| async move {
+                                    (entry_path, open_task.await)
+                                })
+                                .collect::<Vec<_>>()
+                        },
+                    ))
+                })
+                .ok()
+            else {
+                return;
+            };
+
+            let project_buffers = project_buffers.await;
+            if project_buffers.is_empty() {
+                return;
+            }
+            let mut change_sets = Vec::with_capacity(project_buffers.len());
+            if let Some(buffer_update_task) = git_panel
+                .update(&mut cx, |git_panel, cx| {
+                    let editor = git_panel.git_diff_editor.clone();
+                    let multi_buffer = editor.read(cx).buffer().clone();
+                    let mut buffers_with_ranges = Vec::with_capacity(project_buffers.len());
+                    for (buffer_path, open_result) in project_buffers {
+                        if let Some((buffer, unstaged_changes, diff_hunks)) = open_result
+                            .with_context(|| format!("opening buffer {buffer_path:?}"))
+                            .log_err()
+                        {
+                            change_sets.push(unstaged_changes);
+                            buffers_with_ranges.push((
+                                buffer,
+                                diff_hunks
+                                    .into_iter()
+                                    .map(|hunk| hunk.buffer_range)
+                                    .collect(),
+                            ));
+                        }
+                    }
+
+                    multi_buffer.update(cx, |multi_buffer, cx| {
+                        multi_buffer.clear(cx);
+                        multi_buffer.push_multiple_excerpts_with_context_lines(
+                            buffers_with_ranges,
+                            DEFAULT_MULTIBUFFER_CONTEXT,
+                            cx,
+                        )
+                    })
+                })
+                .ok()
+            {
+                buffer_update_task.await;
+                git_panel
+                    .update(&mut cx, |git_panel, cx| {
+                        git_panel.git_diff_editor.update(cx, |editor, cx| {
+                            for change_set in change_sets {
+                                editor.add_change_set(change_set, cx);
+                            }
+                        })
+                    })
+                    .ok();
+            }
+        });
+
         cx.notify();
     }
 }
@@ -629,17 +961,23 @@ impl GitPanel {
         let item_count = self
             .visible_entries
             .iter()
-            .map(|(_, worktree_entries, _)| worktree_entries.len())
+            .map(|worktree_entries| worktree_entries.visible_entries.len())
             .sum();
+        let selected_entry = self.selected_item;
         h_flex()
             .size_full()
             .overflow_hidden()
             .child(
                 uniform_list(cx.view().clone(), "entries", item_count, {
-                    |this, range, cx| {
+                    move |git_panel, range, cx| {
                         let mut items = Vec::with_capacity(range.end - range.start);
-                        this.for_each_visible_entry(range, cx, |id, details, cx| {
-                            items.push(this.render_entry(id, details, cx));
+                        git_panel.for_each_visible_entry(range, cx, |id, details, cx| {
+                            items.push(git_panel.render_entry(
+                                id,
+                                Some(details.index) == selected_entry,
+                                details,
+                                cx,
+                            ));
                         });
                         items
                     }
@@ -656,12 +994,14 @@ impl GitPanel {
     fn render_entry(
         &self,
         id: ProjectEntryId,
+        selected: bool,
         details: EntryDetails,
         cx: &ViewContext<Self>,
     ) -> impl IntoElement {
         let id = id.to_proto() as usize;
         let checkbox_id = ElementId::Name(format!("checkbox_{}", id).into());
         let is_staged = ToggleState::Selected;
+        let handle = cx.view().clone();
 
         h_flex()
             .id(id)
@@ -679,7 +1019,113 @@ impl GitPanel {
             .when_some(details.status, |this, status| {
                 this.child(git_status_icon(status))
             })
-            .child(h_flex().gap_1p5().child(details.display_name.clone()))
+            .child(
+                ListItem::new(("label", id))
+                    .toggle_state(selected)
+                    .child(h_flex().gap_1p5().child(details.display_name.clone()))
+                    .on_click(move |e, cx| {
+                        handle.update(cx, |git_panel, cx| {
+                            git_panel.selected_item = Some(details.index);
+                            let change_focus = e.down.click_count > 1;
+                            git_panel.reveal_entry_in_git_editor(
+                                details.hunks.clone(),
+                                change_focus,
+                                None,
+                                cx,
+                            );
+                        });
+                    }),
+            )
+    }
+
+    fn reveal_entry_in_git_editor(
+        &mut self,
+        hunks: Rc<OnceCell<Vec<DiffHunk>>>,
+        change_focus: bool,
+        debounce: Option<Duration>,
+        cx: &mut ViewContext<'_, Self>,
+    ) {
+        let workspace = self.workspace.clone();
+        let diff_editor = self.git_diff_editor.clone();
+        self.reveal_in_editor = cx.spawn(|_, mut cx| async move {
+            if let Some(debounce) = debounce {
+                cx.background_executor().timer(debounce).await;
+            }
+
+            let Some(editor) = workspace
+                .update(&mut cx, |workspace, cx| {
+                    let git_diff_editor = workspace
+                        .items_of_type::<Editor>(cx)
+                        .find(|editor| &diff_editor == editor);
+                    match git_diff_editor {
+                        Some(existing_editor) => {
+                            workspace.activate_item(&existing_editor, true, change_focus, cx);
+                            existing_editor
+                        }
+                        None => {
+                            workspace.active_pane().update(cx, |pane, cx| {
+                                pane.add_item(
+                                    diff_editor.boxed_clone(),
+                                    true,
+                                    change_focus,
+                                    None,
+                                    cx,
+                                )
+                            });
+                            diff_editor.clone()
+                        }
+                    }
+                })
+                .ok()
+            else {
+                return;
+            };
+
+            if let Some(first_hunk) = hunks.get().and_then(|hunks| hunks.first()) {
+                let hunk_buffer_range = &first_hunk.buffer_range;
+                if let Some(buffer_id) = hunk_buffer_range
+                    .start
+                    .buffer_id
+                    .or_else(|| first_hunk.buffer_range.end.buffer_id)
+                {
+                    editor
+                        .update(&mut cx, |editor, cx| {
+                            let multi_buffer = editor.buffer().read(cx);
+                            let buffer = multi_buffer.buffer(buffer_id)?;
+                            let buffer_snapshot = buffer.read(cx).snapshot();
+                            let (excerpt_id, _) = multi_buffer
+                                .excerpts_for_buffer(&buffer, cx)
+                                .into_iter()
+                                .find(|(_, excerpt)| {
+                                    hunk_buffer_range
+                                        .start
+                                        .cmp(&excerpt.context.start, &buffer_snapshot)
+                                        .is_ge()
+                                        && hunk_buffer_range
+                                            .end
+                                            .cmp(&excerpt.context.end, &buffer_snapshot)
+                                            .is_le()
+                                })?;
+                            let multi_buffer_hunk_start = multi_buffer
+                                .snapshot(cx)
+                                .anchor_in_excerpt(excerpt_id, hunk_buffer_range.start)?;
+                            editor.change_selections(
+                                Some(Autoscroll::Strategy(AutoscrollStrategy::Center)),
+                                cx,
+                                |s| {
+                                    s.select_ranges(Some(
+                                        multi_buffer_hunk_start..multi_buffer_hunk_start,
+                                    ))
+                                },
+                            );
+                            cx.notify();
+                            Some(())
+                        })
+                        .ok()
+                        .flatten();
+                }
+            }
+        });
     }
 }
 
@@ -707,6 +1153,8 @@ impl Render for GitPanel {
                         this.commit_all_changes(&CommitAllChanges, cx)
                     }))
             })
+            .on_action(cx.listener(Self::select_next))
+            .on_action(cx.listener(Self::select_prev))
             .on_hover(cx.listener(|this, hovered, cx| {
                 if *hovered {
                     this.show_scrollbar = true;
@@ -787,3 +1235,14 @@ impl Panel for GitPanel {
         Box::new(ToggleFocus)
     }
 }
+
+fn diff_display_editor(project: Model<Project>, cx: &mut WindowContext) -> View<Editor> {
+    cx.new_view(|cx| {
+        let multi_buffer = cx.new_model(|cx| {
+            MultiBuffer::new(project.read(cx).capability()).with_title("Project diff".to_string())
+        });
+        let mut editor = Editor::for_multibuffer(multi_buffer, Some(project), true, cx);
+        editor.set_expand_all_diff_hunks();
+        editor
+    })
+}