Git · ChangesList: each press names its section, rows are stops and one shows as current

This commit is contained in:
ZacharyZhang-NY committed 2026-10-07 00:18:25 -04:00
1 parent 9eab2019a8
commit edd2c125e5
4 files changed
+109 -22

No files matched your search

+1 -1
View File
@@ -85,7 +85,7 @@ pub fn changes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
.child( .child(
div().w(px(420.)).child( div().w(px(420.)).child(
ChangesList::new("git-changes", now_staged, now_unstaged) 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 { let (from, to) = match action {
ChangeAction::Stage => (&act_unstaged, &act_staged), ChangeAction::Stage => (&act_unstaged, &act_staged),
ChangeAction::Unstage => (&act_staged, &act_unstaged), ChangeAction::Unstage => (&act_staged, &act_unstaged),
+40 -20
View File
@@ -2,7 +2,7 @@ use std::rc::Rc;
use gpui::{ use gpui::{
AnyElement, App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, ParentElement, 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; use super::badges::GitStatusBadge;
@@ -11,7 +11,7 @@ use crate::{
forms::{Checkbox, Input, Submit, TextInput}, forms::{Checkbox, Input, Submit, TextInput},
i18n, i18n,
lists::GitStatus, lists::GitStatus,
primitives::IconName, primitives::{FocusRing, IconName},
theme::{ActiveTheme, ControlSize, Radius, TextSize}, theme::{ActiveTheme, ControlSize, Radius, TextSize},
typography::{Ellipsis, tabular}, typography::{Ellipsis, tabular},
}; };
@@ -34,12 +34,12 @@ pub enum ChangeAction {
Open, Open,
} }
type OnAction = Rc<dyn Fn(&SharedString, ChangeAction, &mut Window, &mut App)>; type OnAction = Rc<dyn Fn(&SharedString, ChangeSection, ChangeAction, &mut Window, &mut App)>;
type OnAll = Rc<dyn Fn(bool, &mut Window, &mut App)>; type OnAll = Rc<dyn Fn(bool, &mut Window, &mut App)>;
/// Which part of the work tree a file is listed in. /// Which part of the work tree a file is listed in.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Section { pub enum ChangeSection {
Conflicted, Conflicted,
Staged, Staged,
Unstaged, Unstaged,
@@ -47,7 +47,7 @@ enum Section {
Ignored, Ignored,
} }
impl Section { impl ChangeSection {
fn key(self) -> &'static str { fn key(self) -> &'static str {
match self { match self {
Self::Conflicted => "git.changes.conflicted", Self::Conflicted => "git.changes.conflicted",
@@ -70,6 +70,7 @@ pub struct ChangesList {
ignored: Vec<Changed>, ignored: Vec<Changed>,
on_action: Option<OnAction>, on_action: Option<OnAction>,
on_all: Option<OnAll>, on_all: Option<OnAll>,
selected: Option<(ChangeSection, SharedString)>,
} }
impl ChangesList { impl ChangesList {
@@ -87,6 +88,7 @@ impl ChangesList {
ignored: Vec::new(), ignored: Vec::new(),
on_action: None, on_action: None,
on_all: None, on_all: None,
selected: None,
} }
} }
@@ -108,9 +110,15 @@ impl ChangesList {
self self
} }
/// The file shown as current, in its section.
pub fn selected(mut self, section: ChangeSection, path: impl Into<SharedString>) -> Self {
self.selected = Some((section, path.into()));
self
}
pub fn on_action( pub fn on_action(
mut self, 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 {
self.on_action = Some(Rc::new(handler)); self.on_action = Some(Rc::new(handler));
self self
@@ -124,7 +132,7 @@ impl ChangesList {
} }
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 theme = cx.theme();
let colors = theme.colors.clone(); let colors = theme.colors.clone();
let (folder, name) = match file.path.rsplit_once('/') { let (folder, name) = match file.path.rsplit_once('/') {
@@ -145,14 +153,24 @@ impl ChangesList {
.when_some(on_action, |button, on_action| { .when_some(on_action, |button, on_action| {
button.on_click(move |_, window, cx| { button.on_click(move |_, window, cx| {
cx.stop_propagation(); 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 (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() div()
.id((self.id.clone(), format!("row-{section:?}-{}", file.path))) .id((self.id.clone(), format!("row-{section:?}-{}", file.path)))
.group(group.clone()) .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() .flex()
.items_center() .items_center()
.gap_2() .gap_2()
@@ -162,7 +180,9 @@ impl ChangesList {
.cursor_pointer() .cursor_pointer()
.hover(|row| row.bg(colors.hover)) .hover(|row| row.bg(colors.hover))
.when_some(open, |row, open| { .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( .child(GitStatusBadge::new(
(self.id.clone(), format!("status-{section:?}-{}", file.path)), (self.id.clone(), format!("status-{section:?}-{}", file.path)),
@@ -204,7 +224,7 @@ impl ChangesList {
ChangeAction::Open, ChangeAction::Open,
)) ))
.when( .when(
matches!(section, Section::Unstaged | Section::Untracked), matches!(section, ChangeSection::Unstaged | ChangeSection::Untracked),
|actions| { |actions| {
actions.child(action( actions.child(action(
"discard", "discard",
@@ -214,7 +234,7 @@ impl ChangesList {
)) ))
}, },
) )
.when(section == Section::Staged, |actions| { .when(section == ChangeSection::Staged, |actions| {
actions.child(action( actions.child(action(
"unstage", "unstage",
IconName::Minus, IconName::Minus,
@@ -223,7 +243,7 @@ impl ChangesList {
)) ))
}) })
.when( .when(
matches!(section, Section::Unstaged | Section::Untracked), matches!(section, ChangeSection::Unstaged | ChangeSection::Untracked),
|actions| { |actions| {
actions.child(action( actions.child(action(
"stage", "stage",
@@ -241,7 +261,7 @@ impl ChangesList {
.into_any_element() .into_any_element()
} }
fn section(&self, section: Section, files: &[Changed], cx: &App) -> Option<AnyElement> { fn section(&self, section: ChangeSection, files: &[Changed], cx: &App) -> Option<AnyElement> {
if files.is_empty() { if files.is_empty() {
return None; return None;
} }
@@ -249,8 +269,8 @@ impl ChangesList {
let colors = theme.colors.clone(); let colors = theme.colors.clone();
// Only staged and changed files move all at once. // Only staged and changed files move all at once.
let all = match section { let all = match section {
Section::Staged => Some((IconName::Minus, "git.changes.unstage_all", false)), ChangeSection::Staged => Some((IconName::Minus, "git.changes.unstage_all", false)),
Section::Unstaged => Some((IconName::Plus, "git.changes.stage_all", true)), ChangeSection::Unstaged => Some((IconName::Plus, "git.changes.stage_all", true)),
_ => None, _ => None,
}; };
let on_all = self.on_all.clone(); 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 { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme(); let theme = cx.theme();
let sections = [ let sections = [
self.section(Section::Conflicted, &self.conflicted, cx), self.section(ChangeSection::Conflicted, &self.conflicted, cx),
self.section(Section::Staged, &self.staged, cx), self.section(ChangeSection::Staged, &self.staged, cx),
self.section(Section::Unstaged, &self.unstaged, cx), self.section(ChangeSection::Unstaged, &self.unstaged, cx),
self.section(Section::Untracked, &self.untracked, cx), self.section(ChangeSection::Untracked, &self.untracked, cx),
self.section(Section::Ignored, &self.ignored, cx), self.section(ChangeSection::Ignored, &self.ignored, cx),
]; ];
div() div()
.flex() .flex()
+1 -1
View File
@@ -16,7 +16,7 @@ mod viewer;
pub use badges::{DiffStat, GitStatusBadge}; pub use badges::{DiffStat, GitStatusBadge};
pub use blame::{Blame, BlameView, GitBlameAnnotation}; 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 commits::{Commit, CommitItem, CommitList};
pub use diff::{DiffLine, LineKind, Stretch, diff, mark_words, pairs, stat}; pub use diff::{DiffLine, LineKind, Stretch, diff, mark_words, pairs, stat};
pub use graph::{GraphRow, Half, Stroke, lanes}; pub use graph::{GraphRow, Half, Stroke, lanes};
+67
View File
@@ -173,3 +173,70 @@ fn changed_lines_and_hunks_answer_presses(cx: &mut TestAppContext) {
"removed, then added" "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<Self>) -> 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:?}"
);
}