From edd2c125e56851b255190f0b2f36160164cb2273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 7 Oct 2026 00:18:25 -0400 Subject: [PATCH] =?UTF-8?q?Git=20=C2=B7=20ChangesList:=20each=20press=20na?= =?UTF-8?q?mes=20its=20section,=20rows=20are=20stops=20and=20one=20shows?= =?UTF-8?q?=20as=20current?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/gallery/pages/git/work.rs | 2 +- src/git/changes.rs | 60 +++++++++++++++++--------- src/git/mod.rs | 2 +- src/git/tests.rs | 67 ++++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 22 deletions(-) diff --git a/examples/gallery/pages/git/work.rs b/examples/gallery/pages/git/work.rs index 13a5acc..db97ae1 100644 --- a/examples/gallery/pages/git/work.rs +++ b/examples/gallery/pages/git/work.rs @@ -85,7 +85,7 @@ pub fn changes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { .child( div().w(px(420.)).child( ChangesList::new("git-changes", now_staged, now_unstaged) - .on_action(move |path, action, _, cx| { + .on_action(move |path, _, action, _, cx| { let (from, to) = match action { ChangeAction::Stage => (&act_unstaged, &act_staged), ChangeAction::Unstage => (&act_staged, &act_unstaged), diff --git a/src/git/changes.rs b/src/git/changes.rs index f277ce7..633919d 100644 --- a/src/git/changes.rs +++ b/src/git/changes.rs @@ -2,7 +2,7 @@ use std::rc::Rc; use gpui::{ AnyElement, App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, ParentElement, - RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, + RenderOnce, Role, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, }; use super::badges::GitStatusBadge; @@ -11,7 +11,7 @@ use crate::{ forms::{Checkbox, Input, Submit, TextInput}, i18n, lists::GitStatus, - primitives::IconName, + primitives::{FocusRing, IconName}, theme::{ActiveTheme, ControlSize, Radius, TextSize}, typography::{Ellipsis, tabular}, }; @@ -34,12 +34,12 @@ pub enum ChangeAction { Open, } -type OnAction = Rc; +type OnAction = Rc; type OnAll = Rc; /// Which part of the work tree a file is listed in. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Section { +pub enum ChangeSection { Conflicted, Staged, Unstaged, @@ -47,7 +47,7 @@ enum Section { Ignored, } -impl Section { +impl ChangeSection { fn key(self) -> &'static str { match self { Self::Conflicted => "git.changes.conflicted", @@ -70,6 +70,7 @@ pub struct ChangesList { ignored: Vec, on_action: Option, on_all: Option, + selected: Option<(ChangeSection, SharedString)>, } impl ChangesList { @@ -87,6 +88,7 @@ impl ChangesList { ignored: Vec::new(), on_action: None, on_all: None, + selected: None, } } @@ -108,9 +110,15 @@ impl ChangesList { self } + /// The file shown as current, in its section. + pub fn selected(mut self, section: ChangeSection, path: impl Into) -> Self { + self.selected = Some((section, path.into())); + self + } + pub fn on_action( mut self, - handler: impl Fn(&SharedString, ChangeAction, &mut Window, &mut App) + 'static, + handler: impl Fn(&SharedString, ChangeSection, ChangeAction, &mut Window, &mut App) + 'static, ) -> Self { self.on_action = Some(Rc::new(handler)); self @@ -124,7 +132,7 @@ impl ChangesList { } impl ChangesList { - fn row(&self, file: &Changed, section: Section, cx: &App) -> AnyElement { + fn row(&self, file: &Changed, section: ChangeSection, cx: &App) -> AnyElement { let theme = cx.theme(); let colors = theme.colors.clone(); let (folder, name) = match file.path.rsplit_once('/') { @@ -145,14 +153,24 @@ impl ChangesList { .when_some(on_action, |button, on_action| { button.on_click(move |_, window, cx| { cx.stop_propagation(); - on_action(&path, act, window, cx) + on_action(&path, section, act, window, cx) }) }) }; let (open, path) = (self.on_action.clone(), file.path.clone()); + let picked = self + .selected + .as_ref() + .is_some_and(|(at, chosen)| *at == section && *chosen == file.path); div() .id((self.id.clone(), format!("row-{section:?}-{}", file.path))) .group(group.clone()) + .role(Role::ListItem) + .aria_label(file.path.clone()) + .aria_selected(picked) + .tab_index(0) + .focus_ring(cx) + .when(picked, |row| row.bg(colors.selection)) .flex() .items_center() .gap_2() @@ -162,7 +180,9 @@ impl ChangesList { .cursor_pointer() .hover(|row| row.bg(colors.hover)) .when_some(open, |row, open| { - row.on_click(move |_, window, cx| open(&path, ChangeAction::Open, window, cx)) + row.on_click(move |_, window, cx| { + open(&path, section, ChangeAction::Open, window, cx) + }) }) .child(GitStatusBadge::new( (self.id.clone(), format!("status-{section:?}-{}", file.path)), @@ -204,7 +224,7 @@ impl ChangesList { ChangeAction::Open, )) .when( - matches!(section, Section::Unstaged | Section::Untracked), + matches!(section, ChangeSection::Unstaged | ChangeSection::Untracked), |actions| { actions.child(action( "discard", @@ -214,7 +234,7 @@ impl ChangesList { )) }, ) - .when(section == Section::Staged, |actions| { + .when(section == ChangeSection::Staged, |actions| { actions.child(action( "unstage", IconName::Minus, @@ -223,7 +243,7 @@ impl ChangesList { )) }) .when( - matches!(section, Section::Unstaged | Section::Untracked), + matches!(section, ChangeSection::Unstaged | ChangeSection::Untracked), |actions| { actions.child(action( "stage", @@ -241,7 +261,7 @@ impl ChangesList { .into_any_element() } - fn section(&self, section: Section, files: &[Changed], cx: &App) -> Option { + fn section(&self, section: ChangeSection, files: &[Changed], cx: &App) -> Option { if files.is_empty() { return None; } @@ -249,8 +269,8 @@ impl ChangesList { let colors = theme.colors.clone(); // Only staged and changed files move all at once. let all = match section { - Section::Staged => Some((IconName::Minus, "git.changes.unstage_all", false)), - Section::Unstaged => Some((IconName::Plus, "git.changes.stage_all", true)), + ChangeSection::Staged => Some((IconName::Minus, "git.changes.unstage_all", false)), + ChangeSection::Unstaged => Some((IconName::Plus, "git.changes.stage_all", true)), _ => None, }; let on_all = self.on_all.clone(); @@ -295,11 +315,11 @@ impl RenderOnce for ChangesList { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let sections = [ - self.section(Section::Conflicted, &self.conflicted, cx), - self.section(Section::Staged, &self.staged, cx), - self.section(Section::Unstaged, &self.unstaged, cx), - self.section(Section::Untracked, &self.untracked, cx), - self.section(Section::Ignored, &self.ignored, cx), + self.section(ChangeSection::Conflicted, &self.conflicted, cx), + self.section(ChangeSection::Staged, &self.staged, cx), + self.section(ChangeSection::Unstaged, &self.unstaged, cx), + self.section(ChangeSection::Untracked, &self.untracked, cx), + self.section(ChangeSection::Ignored, &self.ignored, cx), ]; div() .flex() diff --git a/src/git/mod.rs b/src/git/mod.rs index 63f4a5f..ab0b5d3 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -16,7 +16,7 @@ mod viewer; pub use badges::{DiffStat, GitStatusBadge}; pub use blame::{Blame, BlameView, GitBlameAnnotation}; -pub use changes::{ChangeAction, Changed, ChangesList, CommitInput}; +pub use changes::{ChangeAction, ChangeSection, Changed, ChangesList, CommitInput}; pub use commits::{Commit, CommitItem, CommitList}; pub use diff::{DiffLine, LineKind, Stretch, diff, mark_words, pairs, stat}; pub use graph::{GraphRow, Half, Stroke, lanes}; diff --git a/src/git/tests.rs b/src/git/tests.rs index 1213312..59b5e49 100644 --- a/src/git/tests.rs +++ b/src/git/tests.rs @@ -173,3 +173,70 @@ fn changed_lines_and_hunks_answer_presses(cx: &mut TestAppContext) { "removed, then added" ); } + +/// Rows pressed, with their sections. +#[derive(Default)] +struct Rows { + pressed: Vec<(String, super::ChangeSection, super::ChangeAction)>, +} + +impl Render for Rows { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + use crate::lists::GitStatus; + let view = cx.entity(); + let file = |path: &str| super::Changed { + path: path.to_string().into(), + status: GitStatus::Modified, + lines: Some((1, 0)), + }; + div().w(px(420.0)).child( + super::ChangesList::new("rows", [file("a.rs")], [file("a.rs")]) + .selected(super::ChangeSection::Unstaged, "a.rs") + .on_action(move |path, section, action, _, cx| { + view.update(cx, |rows, _| { + rows.pressed.push((path.to_string(), section, action)) + }) + }), + ) + } +} + +#[gpui::test] +fn a_row_says_which_section_it_is_in(cx: &mut TestAppContext) { + use super::{ChangeAction, ChangeSection}; + cx.update(|cx| { + Theme::init(cx); + forms::bind_keys(cx); + cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]); + }); + let (view, cx) = cx.add_window_view(|_, _| Rows::default()); + cx.update(|window, _| window.activate_window()); + settle(cx); + let mut rows = Vec::new(); + // Each row is a stop; its icons stay hidden until hover. + for _ in 0..12 { + cx.update(|window, cx| window.focus_next(cx)); + settle(cx); + cx.simulate_keystrokes("enter"); + settle(cx); + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse("enter").expect("a key"), + }); + settle(cx); + rows = view.read_with(cx, |rows, _| rows.pressed.clone()); + if rows + .iter() + .any(|(_, section, _)| *section == ChangeSection::Unstaged) + { + break; + } + } + assert!( + rows.contains(&("a.rs".into(), ChangeSection::Staged, ChangeAction::Open)), + "{rows:?}" + ); + assert!( + rows.contains(&("a.rs".into(), ChangeSection::Unstaged, ChangeAction::Open)), + "{rows:?}" + ); +}