Git · ChangesList: conflicted, untracked and ignored files apart, each with its own actions; titles and tooltips read i18n (English and zh-CN built in)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-06 22:48:10 -04:00
1 parent 295731d336
commit 490b0220f6
3 files changed
+166 -44

No files matched your search

+112 -44
View File
@@ -9,6 +9,7 @@ use super::badges::GitStatusBadge;
use crate::{ use crate::{
buttons::{Button, ButtonVariant, IconButton}, buttons::{Button, ButtonVariant, IconButton},
forms::{Checkbox, Input, Submit, TextInput}, forms::{Checkbox, Input, Submit, TextInput},
i18n,
lists::GitStatus, lists::GitStatus,
primitives::IconName, primitives::IconName,
theme::{ActiveTheme, ControlSize, Radius, TextSize}, theme::{ActiveTheme, ControlSize, Radius, TextSize},
@@ -36,12 +37,37 @@ pub enum ChangeAction {
type OnAction = Rc<dyn Fn(&SharedString, ChangeAction, &mut Window, &mut App)>; type OnAction = Rc<dyn Fn(&SharedString, 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)>;
/// Files changed in the work tree, the staged apart: each with its status, its folder and what it changed; open, discard, stage or unstage it, or all at once. /// Which part of the work tree a file is listed in.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Section {
Conflicted,
Staged,
Unstaged,
Untracked,
Ignored,
}
impl Section {
fn key(self) -> &'static str {
match self {
Self::Conflicted => "git.changes.conflicted",
Self::Staged => "git.changes.staged",
Self::Unstaged => "git.changes.unstaged",
Self::Untracked => "git.changes.untracked",
Self::Ignored => "git.changes.ignored",
}
}
}
/// Files changed in the work tree, kept apart as conflicted, staged, changed, untracked and ignored: each with its status, its folder and what it changed; open, discard, stage or unstage it, or all at once.
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct ChangesList { pub struct ChangesList {
id: ElementId, id: ElementId,
conflicted: Vec<Changed>,
staged: Vec<Changed>, staged: Vec<Changed>,
unstaged: Vec<Changed>, unstaged: Vec<Changed>,
untracked: Vec<Changed>,
ignored: Vec<Changed>,
on_action: Option<OnAction>, on_action: Option<OnAction>,
on_all: Option<OnAll>, on_all: Option<OnAll>,
} }
@@ -54,13 +80,34 @@ impl ChangesList {
) -> Self { ) -> Self {
Self { Self {
id: id.into(), id: id.into(),
conflicted: Vec::new(),
staged: staged.into_iter().collect(), staged: staged.into_iter().collect(),
unstaged: unstaged.into_iter().collect(), unstaged: unstaged.into_iter().collect(),
untracked: Vec::new(),
ignored: Vec::new(),
on_action: None, on_action: None,
on_all: None, on_all: None,
} }
} }
/// Files both sides changed; listed first, opened only.
pub fn conflicted(mut self, files: impl IntoIterator<Item = Changed>) -> Self {
self.conflicted = files.into_iter().collect();
self
}
/// Files Git does not track yet; staged or discarded one by one.
pub fn untracked(mut self, files: impl IntoIterator<Item = Changed>) -> Self {
self.untracked = files.into_iter().collect();
self
}
/// Files Git ignores; listed last, opened only.
pub fn ignored(mut self, files: impl IntoIterator<Item = Changed>) -> Self {
self.ignored = files.into_iter().collect();
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, ChangeAction, &mut Window, &mut App) + 'static,
@@ -77,18 +124,19 @@ impl ChangesList {
} }
impl ChangesList { impl ChangesList {
fn row(&self, file: &Changed, staged: bool, cx: &App) -> AnyElement { fn row(&self, file: &Changed, section: Section, 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('/') {
Some((folder, name)) => (folder.to_string(), name.to_string()), Some((folder, name)) => (folder.to_string(), name.to_string()),
None => (String::new(), file.path.to_string()), None => (String::new(), file.path.to_string()),
}; };
let group = SharedString::from(format!("change-{staged}-{}", file.path)); let group = SharedString::from(format!("change-{section:?}-{}", file.path));
let action = |key: &str, icon, words: &'static str, act: ChangeAction| { let action = |key: &str, icon, words: &str, act: ChangeAction| {
let words = i18n::text(cx, words, &[]);
let (on_action, path) = (self.on_action.clone(), file.path.clone()); let (on_action, path) = (self.on_action.clone(), file.path.clone());
IconButton::new( IconButton::new(
(self.id.clone(), format!("{key}-{staged}-{}", file.path)), (self.id.clone(), format!("{key}-{section:?}-{}", file.path)),
icon, icon,
) )
.variant(ButtonVariant::Ghost) .variant(ButtonVariant::Ghost)
@@ -103,7 +151,7 @@ impl ChangesList {
}; };
let (open, path) = (self.on_action.clone(), file.path.clone()); let (open, path) = (self.on_action.clone(), file.path.clone());
div() div()
.id((self.id.clone(), format!("row-{staged}-{}", file.path))) .id((self.id.clone(), format!("row-{section:?}-{}", file.path)))
.group(group.clone()) .group(group.clone())
.flex() .flex()
.items_center() .items_center()
@@ -117,7 +165,7 @@ impl ChangesList {
row.on_click(move |_, window, cx| open(&path, ChangeAction::Open, window, cx)) row.on_click(move |_, window, cx| open(&path, ChangeAction::Open, window, cx))
}) })
.child(GitStatusBadge::new( .child(GitStatusBadge::new(
(self.id.clone(), format!("status-{staged}-{}", file.path)), (self.id.clone(), format!("status-{section:?}-{}", file.path)),
file.status, file.status,
)) ))
.child( .child(
@@ -152,22 +200,39 @@ impl ChangesList {
.child(action( .child(action(
"open", "open",
IconName::FileText, IconName::FileText,
"Open file", "git.changes.open",
ChangeAction::Open, ChangeAction::Open,
)) ))
.when(!staged, |actions| { .when(
matches!(section, Section::Unstaged | Section::Untracked),
|actions| {
actions.child(action(
"discard",
IconName::RotateCcw,
"git.changes.discard",
ChangeAction::Discard,
))
},
)
.when(section == Section::Staged, |actions| {
actions.child(action( actions.child(action(
"discard", "unstage",
IconName::RotateCcw, IconName::Minus,
"Discard changes", "git.changes.unstage",
ChangeAction::Discard, ChangeAction::Unstage,
)) ))
}) })
.child(if staged { .when(
action("unstage", IconName::Minus, "Unstage", ChangeAction::Unstage) matches!(section, Section::Unstaged | Section::Untracked),
} else { |actions| {
action("stage", IconName::Plus, "Stage", ChangeAction::Stage) actions.child(action(
}), "stage",
IconName::Plus,
"git.changes.stage",
ChangeAction::Stage,
))
},
),
) )
.child( .child(
tabular(div().flex_none().text_color(colors.fg_subtle)) tabular(div().flex_none().text_color(colors.fg_subtle))
@@ -176,24 +241,28 @@ impl ChangesList {
.into_any_element() .into_any_element()
} }
fn section( fn section(&self, section: Section, files: &[Changed], cx: &App) -> Option<AnyElement> {
&self,
title: &'static str,
files: &[Changed],
staged: bool,
cx: &App,
) -> Option<AnyElement> {
if files.is_empty() { if files.is_empty() {
return None; return None;
} }
let theme = cx.theme(); let theme = cx.theme();
let colors = theme.colors.clone(); let colors = theme.colors.clone();
let on_all = self.on_all.clone(); // Only staged and changed files move all at once.
let (icon, words) = if staged { let all = match section {
(IconName::Minus, "Unstage all") Section::Staged => Some((IconName::Minus, "git.changes.unstage_all", false)),
} else { Section::Unstaged => Some((IconName::Plus, "git.changes.stage_all", true)),
(IconName::Plus, "Stage all") _ => None,
}; };
let on_all = self.on_all.clone();
let button = all.map(|(icon, key, stage)| {
IconButton::new((self.id.clone(), format!("all-{section:?}")), icon)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(i18n::text(cx, key, &[]))
.when_some(on_all, |button, on_all| {
button.on_click(move |_, window, cx| on_all(stage, window, cx))
})
});
Some( Some(
div() div()
.flex() .flex()
@@ -207,20 +276,16 @@ impl ChangesList {
.py_1() .py_1()
.text_size(theme.text_size(TextSize::Xs)) .text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted) .text_color(colors.fg_muted)
.child(div().flex_1().font_weight(FontWeight::MEDIUM).child(title))
.child(tabular(div()).child(files.len().to_string()))
.child( .child(
IconButton::new((self.id.clone(), format!("all-{staged}")), icon) div()
.variant(ButtonVariant::Ghost) .flex_1()
.size(ControlSize::Sm) .font_weight(FontWeight::MEDIUM)
.tooltip(words) .child(i18n::text(cx, section.key(), &[])),
.when_some(on_all, |button, on_all| { )
button .child(tabular(div()).child(files.len().to_string()))
.on_click(move |_, window, cx| on_all(!staged, window, cx)) .children(button),
}),
),
) )
.children(files.iter().map(|file| self.row(file, staged, cx))) .children(files.iter().map(|file| self.row(file, section, cx)))
.into_any_element(), .into_any_element(),
) )
} }
@@ -230,8 +295,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("Staged changes", &self.staged, true, cx), self.section(Section::Conflicted, &self.conflicted, cx),
self.section("Changes", &self.unstaged, false, 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),
]; ];
div() div()
.flex() .flex()
+32
View File
@@ -75,3 +75,35 @@ fn a_blame_that_lags_its_code_draws_every_line(cx: &mut TestAppContext) {
let (_, cx) = cx.add_window_view(|_, _| Behind); let (_, cx) = cx.add_window_view(|_, _| Behind);
cx.run_until_parked(); cx.run_until_parked();
} }
/// Every section of a changes list, each with one file.
struct Sorted;
impl Render for Sorted {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
use crate::lists::GitStatus;
let file = |path: &str, status| super::Changed {
path: path.to_string().into(),
status,
added: 1,
removed: 0,
};
div().w(px(420.0)).child(
super::ChangesList::new(
"changes",
[file("a.rs", GitStatus::Modified)],
[file("b.rs", GitStatus::Modified)],
)
.conflicted([file("c.rs", GitStatus::Conflicted)])
.untracked([file("d.rs", GitStatus::Untracked)])
.ignored([file("target/", GitStatus::Untracked)]),
)
}
}
#[gpui::test]
fn every_section_draws_with_its_own_words(cx: &mut TestAppContext) {
cx.update(Theme::init);
let (_, cx) = cx.add_window_view(|_, _| Sorted);
settle(cx);
}
+22
View File
@@ -67,6 +67,17 @@ const EN: &[(&str, &str)] = &[
("pagination.jump", "Go to"), ("pagination.jump", "Go to"),
("signature.here", "Sign here"), ("signature.here", "Sign here"),
("signature.clear", "Clear"), ("signature.clear", "Clear"),
("git.changes.conflicted", "Conflicts"),
("git.changes.staged", "Staged changes"),
("git.changes.unstaged", "Changes"),
("git.changes.untracked", "Untracked"),
("git.changes.ignored", "Ignored"),
("git.changes.open", "Open file"),
("git.changes.discard", "Discard changes"),
("git.changes.stage", "Stage"),
("git.changes.unstage", "Unstage"),
("git.changes.stage_all", "Stage all"),
("git.changes.unstage_all", "Unstage all"),
]; ];
const ZH_CN: &[(&str, &str)] = &[ const ZH_CN: &[(&str, &str)] = &[
@@ -132,6 +143,17 @@ const ZH_CN: &[(&str, &str)] = &[
("pagination.jump", "跳至"), ("pagination.jump", "跳至"),
("signature.here", "在此签名"), ("signature.here", "在此签名"),
("signature.clear", "清除"), ("signature.clear", "清除"),
("git.changes.conflicted", "冲突"),
("git.changes.staged", "已暂存"),
("git.changes.unstaged", "改动"),
("git.changes.untracked", "未跟踪"),
("git.changes.ignored", "已忽略"),
("git.changes.open", "打开文件"),
("git.changes.discard", "丢弃改动"),
("git.changes.stage", "暂存"),
("git.changes.unstage", "取消暂存"),
("git.changes.stage_all", "全部暂存"),
("git.changes.unstage_all", "全部取消暂存"),
]; ];
/// Ely's own message for `key` in `locale`, English for a locale Ely does not ship; fails on an unknown key. /// Ely's own message for `key` in `locale`, English for a locale Ely does not ship; fails on an unknown key.