Git · ChangesList: each press names its section, rows are stops and one shows as current
This commit is contained in:
4 files changed
+109
-22
No files matched your search
@@ -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
@@ -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
@@ -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};
|
||||||
|
|||||||
@@ -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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user