git_ui: Make the file history view keyboard navigable (#44328)

feeiyu created

![file_history_view_navigation](https://github.com/user-attachments/assets/1435fdae-806e-48d1-a031-2c0fec28725f)

Release Notes:

- git: Made the file history view keyboard navigable

Change summary

crates/git_ui/src/file_history_view.rs | 117 +++++++++++++++++++++++----
1 file changed, 99 insertions(+), 18 deletions(-)

Detailed changes

crates/git_ui/src/file_history_view.rs 🔗

@@ -4,7 +4,8 @@ use git::repository::{FileHistory, FileHistoryEntry, RepoPath};
 use git::{GitHostingProviderRegistry, GitRemote, parse_git_remote_url};
 use gpui::{
     AnyElement, AnyEntity, App, Asset, Context, Entity, EventEmitter, FocusHandle, Focusable,
-    IntoElement, Render, Task, UniformListScrollHandle, WeakEntity, Window, actions, uniform_list,
+    IntoElement, Render, ScrollStrategy, Task, UniformListScrollHandle, WeakEntity, Window,
+    actions, uniform_list,
 };
 use project::{
     Project, ProjectPath,
@@ -191,6 +192,93 @@ impl FileHistoryView {
         task.detach();
     }
 
+    fn select_next(&mut self, _: &menu::SelectNext, _: &mut Window, cx: &mut Context<Self>) {
+        let entry_count = self.history.entries.len();
+        let ix = match self.selected_entry {
+            _ if entry_count == 0 => None,
+            None => Some(0),
+            Some(ix) => {
+                if ix == entry_count - 1 {
+                    Some(0)
+                } else {
+                    Some(ix + 1)
+                }
+            }
+        };
+        self.select_ix(ix, cx);
+    }
+
+    fn select_previous(
+        &mut self,
+        _: &menu::SelectPrevious,
+        _: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let entry_count = self.history.entries.len();
+        let ix = match self.selected_entry {
+            _ if entry_count == 0 => None,
+            None => Some(entry_count - 1),
+            Some(ix) => {
+                if ix == 0 {
+                    Some(entry_count - 1)
+                } else {
+                    Some(ix - 1)
+                }
+            }
+        };
+        self.select_ix(ix, cx);
+    }
+
+    fn select_first(&mut self, _: &menu::SelectFirst, _: &mut Window, cx: &mut Context<Self>) {
+        let entry_count = self.history.entries.len();
+        let ix = if entry_count != 0 { Some(0) } else { None };
+        self.select_ix(ix, cx);
+    }
+
+    fn select_last(&mut self, _: &menu::SelectLast, _: &mut Window, cx: &mut Context<Self>) {
+        let entry_count = self.history.entries.len();
+        let ix = if entry_count != 0 {
+            Some(entry_count - 1)
+        } else {
+            None
+        };
+        self.select_ix(ix, cx);
+    }
+
+    fn select_ix(&mut self, ix: Option<usize>, cx: &mut Context<Self>) {
+        self.selected_entry = ix;
+        if let Some(ix) = ix {
+            self.scroll_handle.scroll_to_item(ix, ScrollStrategy::Top);
+        }
+        cx.notify();
+    }
+
+    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
+        self.open_commit_view(window, cx);
+    }
+
+    fn open_commit_view(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(entry) = self
+            .selected_entry
+            .and_then(|ix| self.history.entries.get(ix))
+        else {
+            return;
+        };
+
+        if let Some(repo) = self.repository.upgrade() {
+            let sha_str = entry.sha.to_string();
+            CommitView::open(
+                sha_str,
+                repo.downgrade(),
+                self.workspace.clone(),
+                None,
+                Some(self.history.path.clone()),
+                window,
+                cx,
+            );
+        }
+    }
+
     fn render_commit_avatar(
         &self,
         sha: &SharedString,
@@ -245,12 +333,8 @@ impl FileHistoryView {
             time_format::TimestampFormat::Relative,
         );
 
-        let sha = entry.sha.clone();
-        let repo = self.repository.clone();
-        let workspace = self.workspace.clone();
-        let file_path = self.history.path.clone();
-
         ListItem::new(("commit", ix))
+            .toggle_state(Some(ix) == self.selected_entry)
             .child(
                 h_flex()
                     .h_8()
@@ -301,18 +385,7 @@ impl FileHistoryView {
                 this.selected_entry = Some(ix);
                 cx.notify();
 
-                if let Some(repo) = repo.upgrade() {
-                    let sha_str = sha.to_string();
-                    CommitView::open(
-                        sha_str,
-                        repo.downgrade(),
-                        workspace.clone(),
-                        None,
-                        Some(file_path.clone()),
-                        window,
-                        cx,
-                    );
-                }
+                this.open_commit_view(window, cx);
             }))
             .into_any_element()
     }
@@ -380,6 +453,14 @@ impl Render for FileHistoryView {
         let entry_count = self.history.entries.len();
 
         v_flex()
+            .id("file_history_view")
+            .key_context("FileHistoryView")
+            .track_focus(&self.focus_handle)
+            .on_action(cx.listener(Self::select_next))
+            .on_action(cx.listener(Self::select_previous))
+            .on_action(cx.listener(Self::select_first))
+            .on_action(cx.listener(Self::select_last))
+            .on_action(cx.listener(Self::confirm))
             .size_full()
             .bg(cx.theme().colors().editor_background)
             .child(