From 51d2aea02ecfd619c85e74bbc6c84331c903b776 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 01:16:54 -0400 Subject: [PATCH] =?UTF-8?q?Git=20=C2=B7=20CommitList,=20FileHistory,=20Bla?= =?UTF-8?q?meView=20draw=20only=20rows=20in=20view,=20rows=20are=20stops?= =?UTF-8?q?=20with=20list=20semantics;=20CommitList=20loads=20more=20near?= =?UTF-8?q?=20its=20end;=20CommitInput=20words=20read=20i18n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/gallery/pages/git/people.rs | 15 +- examples/gallery/pages/git/work.rs | 25 ++- src/git/blame.rs | 254 ++++++++++++++------------- src/git/changes.rs | 11 +- src/git/commits.rs | 148 +++++++++------- src/git/history.rs | 72 +++++--- src/git/mod.rs | 2 + src/git/picking.rs | 160 +++++++++++++++++ src/i18n/builtin.rs | 6 + 9 files changed, 472 insertions(+), 221 deletions(-) create mode 100644 src/git/picking.rs diff --git a/examples/gallery/pages/git/people.rs b/examples/gallery/pages/git/people.rs index dd33251..08862c9 100644 --- a/examples/gallery/pages/git/people.rs +++ b/examples/gallery/pages/git/people.rs @@ -37,7 +37,14 @@ pub fn blame(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { ]; let history_rows: Vec<_> = history() .into_iter() - .zip([(12, 4), (3, 3), (9, 1), (18, 0), (2, 1), (40, 0)]) + .zip([ + Some((12, 4)), + Some((3, 3)), + Some((9, 1)), + Some((18, 0)), + None, + Some((40, 0)), + ]) .collect(); let (pick, select) = (said.clone(), said.clone()); section( @@ -46,7 +53,7 @@ pub fn blame(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { cx, ) .child( - div().w(px(840.)).child( + div().w(px(840.)).h(px(240.)).child( BlameView::new("git-blame", CODE, [0, 1, 1, 1, 0, 0, 2, 2, 0, 0], blames) .current(2) .on_commit(move |commit, _, cx| set(&pick, Some(format!("Picked {}.", &commit[..7]).into()), cx)), @@ -54,8 +61,8 @@ pub fn blame(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { ) .children(told.map(Caption::new)) .child( - div().w(px(520.)).child( - FileHistory::new("git-file-history", history_rows) + div().w(px(520.)).h(px(360.)).child( + FileHistory::new("git-file-history", std::rc::Rc::new(history_rows)) .selected(history()[1].id.clone()) .on_pick(move |commit, _, cx| set(&select, Some(format!("Opened {}.", &commit[..7]).into()), cx)), ), diff --git a/examples/gallery/pages/git/work.rs b/examples/gallery/pages/git/work.rs index db97ae1..14472f6 100644 --- a/examples/gallery/pages/git/work.rs +++ b/examples/gallery/pages/git/work.rs @@ -3,11 +3,13 @@ use ely_gpui_component::{ forms::TextInput, git::{ Branch, BranchList, BranchSelector, ChangeAction, Changed, ChangesList, Commit, - CommitInput, CommitList, GitTag, Stash, StashAction, StashList, TagList, + CommitInput, CommitList, GitTag, GraphRow, Stash, StashAction, StashList, TagList, lanes, }, lists::GitStatus, typography::Caption, }; +use std::rc::Rc; + use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; use crate::{ @@ -210,6 +212,21 @@ pub(super) fn history() -> Vec { ] } +/// The sample history's lanes. +fn graph() -> Vec { + let commits = history(); + let parents: Vec> = commits + .iter() + .map(|commit| commit.parents.iter().map(|id| id.as_ref()).collect()) + .collect(); + lanes( + commits + .iter() + .zip(&parents) + .map(|(commit, parents)| (commit.id.as_ref(), parents.as_slice())), + ) +} + pub fn commits(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { let picked = keep("git-picked", || history()[0].id.clone(), window, cx); let now = picked.read(cx).clone(); @@ -220,9 +237,9 @@ pub fn commits(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { cx, ) .child( - div().w(px(840.)).child( - CommitList::new("git-graph", history()) - .graph() + div().w(px(840.)).h(px(320.)).child( + CommitList::new("git-graph", Rc::new(history())) + .graph(Rc::new(graph())) .selected(now) .on_pick(move |id, _, cx| set(&pick, id.clone(), cx)), ), diff --git a/src/git/blame.rs b/src/git/blame.rs index 6eb46d3..e9df5ec 100644 --- a/src/git/blame.rs +++ b/src/git/blame.rs @@ -1,14 +1,15 @@ use std::{ops::Range, rc::Rc}; use gpui::{ - AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, - RenderOnce, SharedString, StatefulInteractiveElement, Styled, StyledText, Window, div, - prelude::*, relative, + App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce, Role, + SharedString, StatefulInteractiveElement, Styled, StyledText, Window, div, prelude::*, + relative, uniform_list, }; use crate::{ data_display::Avatar, editor::code_colors, + primitives::FocusRing, theme::{ActiveTheme, AvatarSize, TextSize}, typography::{Ellipsis, LEADING}, }; @@ -130,126 +131,137 @@ impl BlameView { impl RenderOnce for BlameView { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); - let colors = theme.colors.clone(); - let lines: Vec<&str> = self.code.lines().collect(); - let digits = lines.len().to_string().len(); - let blocks: Vec = runs(&self.owners) - .into_iter() - .map(|(owner, span)| { - let blame = owner.map(|owner| self.blames[owner].clone()); - let Some(found) = blame.clone() else { - let bare = div() - .flex_none() - .w_64() - .border_l_2() - .border_color(colors.border); - return (span, bare.into_any_element(), None); - }; - let blame = found; - let fade = 1.0 - blame.age.clamp(0.0, 1.0) * 0.8; - let pick = self.on_commit.clone(); - let commit = blame.commit.clone(); - let gutter = div() - .id((self.id.clone(), format!("run-{}", span.start))) - .flex_none() - .w_64() - .flex() - .items_start() - .gap_2() - .px_2() - .border_l_2() - .border_color(colors.accent.opacity(fade)) - .font_family(theme.font_family.clone()) - .text_size(theme.text_size(TextSize::Xs)) - .when_some(pick, |gutter, pick| { - gutter - .cursor_pointer() - .hover(|gutter| gutter.bg(colors.hover)) - .on_click(move |_, window, cx| pick(&commit, window, cx)) - }) - .child( - Avatar::new( - (self.id.clone(), format!("author-{}", span.start)), - blame.author.clone(), - ) - .size(AvatarSize::Xs), - ) - .child( - div() - .flex_1() - .min_w_0() - .flex() - .flex_col() - .child( - div() - .flex() - .gap_1() - .child( - div() - .font_weight(FontWeight::MEDIUM) - .text_color(colors.fg) - .child(blame.author.clone()), - ) - .child( - div() - .text_color(colors.fg_subtle) - .child(blame.when.clone()), - ), - ) - .child( - div() - .text_color(colors.fg_muted) - .child(Ellipsis::new(blame.subject.clone())), - ), - ); - (span, gutter.into_any_element(), Some(blame)) - }) - .map(|(span, gutter, blame)| { - let code = div() - .flex_1() - .min_w_0() - .overflow_hidden() - .line_height(relative(LEADING)) - .flex() - .flex_col() - .children(span.clone().map(|line| { - let text = lines[line].to_string(); - let styles = code_colors(&text, cx); - div() - .flex() - .gap_3() - .whitespace_nowrap() - .when(self.current == Some(line), |row| row.bg(colors.hover)) - .child( - div() - .flex_none() - .w(theme.text_size(TextSize::Xs) * (digits as f32 * 0.62)) - .text_right() - .text_color(colors.fg_subtle) - .child((line + 1).to_string()), - ) - .child(StyledText::new(text).with_highlights(styles)) - .when_some( - blame.clone().filter(|_| self.current == Some(line)), - |row, blame| row.child(GitBlameAnnotation::new(blame)), - ) - })); - div() - .flex() - .border_t_1() - .border_color(colors.border) - .child(gutter) - .child(code) - .into_any_element() - }) - .collect(); - div() - .flex() - .flex_col() + let frame = div() + .size_full() .font_family(theme.mono_family.clone()) .text_size(theme.text_size(TextSize::Xs)) - .text_color(colors.fg) - .children(blocks) + .text_color(theme.colors.fg); + let Self { + id, + code, + owners, + blames, + current, + on_commit, + } = self; + let lines: Rc> = + Rc::new(code.lines().map(|line| line.to_string().into()).collect()); + let digits = lines.len().to_string().len(); + // Each line's owner, and whether it starts a run. + let heads: Rc, bool)>> = Rc::new( + runs(&owners) + .into_iter() + .flat_map(|(owner, span)| span.clone().map(move |line| (owner, line == span.start))) + .collect(), + ); + let blames = Rc::new(blames); + let list = uniform_list((id.clone(), "lines"), lines.len(), move |range, _, cx| { + let theme = cx.theme(); + let colors = theme.colors.clone(); + range + .map(|line| { + let (owner, head) = heads[line]; + let blame = owner.map(|owner| blames[owner].clone()); + let fade = blame + .as_ref() + .map(|blame| 1.0 - blame.age.clamp(0.0, 1.0) * 0.8); + let gutter = div() + .id((id.clone(), format!("gutter-{line}"))) + .flex_none() + .w_80() + .h_full() + .flex() + .items_center() + .gap_2() + .px_2() + .border_l_2() + .border_color( + fade.map_or(colors.border, |fade| colors.accent.opacity(fade)), + ) + .font_family(theme.font_family.clone()) + .text_size(theme.text_size(TextSize::Xs)); + let gutter = match blame.clone().filter(|_| head) { + Some(blame) => { + let said: SharedString = + format!("{}, {} · {}", blame.author, blame.when, blame.subject) + .into(); + let pick = on_commit.clone(); + let commit = blame.commit.clone(); + gutter + .when_some(pick, |gutter, pick| { + gutter + .role(Role::Button) + .aria_label(said) + .tab_index(0) + .focus_ring(cx) + .cursor_pointer() + .hover(|gutter| gutter.bg(colors.hover)) + .on_click(move |_, window, cx| pick(&commit, window, cx)) + }) + .child( + Avatar::new( + (id.clone(), format!("author-{line}")), + blame.author.clone(), + ) + .size(AvatarSize::Xs), + ) + .child( + div() + .flex_none() + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg) + .child(blame.author.clone()), + ) + .child( + div() + .flex_none() + .text_color(colors.fg_subtle) + .child(blame.when.clone()), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_color(colors.fg_muted) + .child(Ellipsis::new(blame.subject.clone())), + ) + } + None => gutter, + }; + let text = lines[line].clone(); + let styles = code_colors(&text, cx); + div() + .w_full() + .flex() + .items_center() + .gap_3() + .border_t_1() + .border_color(if head && line > 0 { + colors.border + } else { + gpui::transparent_black() + }) + .whitespace_nowrap() + .line_height(relative(LEADING)) + .when(current == Some(line), |row| row.bg(colors.hover)) + .child(gutter) + .child( + div() + .flex_none() + .w(theme.text_size(TextSize::Xs) * (digits as f32 * 0.62)) + .text_right() + .text_color(colors.fg_subtle) + .child((line + 1).to_string()), + ) + .child(StyledText::new(text).with_highlights(styles)) + .when_some(blame.filter(|_| current == Some(line)), |row, blame| { + row.child(GitBlameAnnotation::new(blame)) + }) + }) + .collect() + }) + .size_full(); + frame.child(list) } } diff --git a/src/git/changes.rs b/src/git/changes.rs index 633919d..161c1b7 100644 --- a/src/git/changes.rs +++ b/src/git/changes.rs @@ -398,12 +398,15 @@ impl RenderOnce for CommitInput { let commit = self.on_commit.filter(|_| ready); let submit = commit.clone(); let label = if self.amend { - format!("Amend {}", self.branch) + i18n::text(cx, "git.commit.amend_to", &[("branch", &self.branch)]) } else { - format!("Commit to {}", self.branch) + i18n::text(cx, "git.commit.commit_to", &[("branch", &self.branch)]) }; - let amend = - Checkbox::new((self.id.clone(), "amend"), self.amend).label("Amend last commit"); + let amend = Checkbox::new((self.id.clone(), "amend"), self.amend).label(i18n::text( + cx, + "git.commit.amend", + &[], + )); let amend = match self.on_amend { Some(on_amend) => amend.on_change(move |on, window, cx| on_amend(on, window, cx)), None => amend, diff --git a/src/git/commits.rs b/src/git/commits.rs index 8d0c070..09fccfa 100644 --- a/src/git/commits.rs +++ b/src/git/commits.rs @@ -2,13 +2,14 @@ use std::rc::Rc; use gpui::{ AnyElement, App, Bounds, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement, - PathBuilder, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, - canvas, div, point, prelude::*, size, + PathBuilder, Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement, Styled, + Window, canvas, div, point, prelude::*, size, uniform_list, }; -use super::graph::{GraphRow, Half, lanes}; +use super::graph::{GraphRow, Half}; use crate::{ data_display::{Avatar, Tag, Tone}, + primitives::FocusRing, theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, Radius, TextSize}, typography::Ellipsis, }; @@ -68,6 +69,11 @@ impl RenderOnce for CommitItem { let (pick, picked) = (self.on_pick, commit.id.clone()); div() .id(self.id.clone()) + .role(Role::ListItem) + .aria_label(commit.subject.clone()) + .aria_selected(self.selected) + .tab_index(0) + .focus_ring(cx) .flex_1() .min_w_0() .h(theme.control_height(ControlSize::Lg)) @@ -126,24 +132,28 @@ impl RenderOnce for CommitItem { } } -/// Commits newest first, one to a row; a press picks one. +type OnEnd = Rc; + +/// Commits newest first; draws rows in view, fills its box. #[derive(IntoElement)] pub struct CommitList { id: ElementId, - commits: Vec, + commits: Rc>, + graph: Option>>, selected: Option, on_pick: Option, - graph: bool, + on_end: Option, } impl CommitList { - pub fn new(id: impl Into, commits: impl IntoIterator) -> Self { + pub fn new(id: impl Into, commits: Rc>) -> Self { Self { id: id.into(), - commits: commits.into_iter().collect(), + commits, + graph: None, selected: None, on_pick: None, - graph: false, + on_end: None, } } @@ -160,13 +170,22 @@ impl CommitList { self } - /// Draws the lanes beside the rows, as a commit graph. - pub fn graph(mut self) -> Self { - self.graph = true; + /// Lanes beside the rows, from `lanes`. + pub fn graph(mut self, rows: Rc>) -> Self { + self.graph = Some(rows); + self + } + + /// Called when rows near the end draw. + pub fn on_end(mut self, handler: impl Fn(&mut App) + 'static) -> Self { + self.on_end = Some(Rc::new(handler)); self } } +/// Rows from the end that ask for more. +const NEAR_END: usize = 20; + /// Paints one row's lanes, `span` lanes wide so every row lines up: strokes into and out of its commit, lanes passing it, and the commit's dot. fn lanes_cell( row: GraphRow, @@ -219,56 +238,67 @@ fn lanes_cell( } impl RenderOnce for CommitList { - fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let theme = cx.theme(); - let rem = window.rem_size(); - let height = theme.control_height(ControlSize::Lg).to_pixels(rem); - let lane = theme.icon_size(IconSize::Md).to_pixels(rem); - let stroke = theme.chart().hairline * 1.5; - let palette = theme.colors.chart; - let graph: Vec> = if self.graph { - let parents: Vec> = self - .commits - .iter() - .map(|commit| commit.parents.iter().map(|id| id.as_ref()).collect()) - .collect(); - lanes( - self.commits - .iter() - .zip(&parents) - .map(|(commit, parents)| (commit.id.as_ref(), parents.as_slice())), - ) - .into_iter() - .map(Some) - .collect() - } else { - vec![None; self.commits.len()] - }; - let span = graph + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + if let Some(graph) = &self.graph + && graph.len() != self.commits.len() + { + log::error!( + "commit list: {} graph rows for {} commits; rows past them draw bare", + graph.len(), + self.commits.len() + ); + } + let span = self + .graph .iter() - .flatten() + .flat_map(|rows| rows.iter()) .map(|row| row.width) .max() .unwrap_or(0); - div() - .flex() - .flex_col() - .children(self.commits.into_iter().zip(graph).map(|(commit, lanes)| { - let selected = self.selected.as_ref() == Some(&commit.id); - let item = CommitItem::new((self.id.clone(), commit.id.clone()), commit) - .selected(selected); - let item = match &self.on_pick { - Some(pick) => { - let pick = pick.clone(); - item.on_pick(move |id, window, cx| pick(id, window, cx)) - } - None => item, - }; - div() - .flex() - .items_center() - .children(lanes.map(|row| lanes_cell(row, span, lane, height, stroke, palette))) - .child(item) - })) + let Self { + id, + commits, + graph, + selected, + on_pick, + on_end, + } = self; + let count = commits.len(); + uniform_list((id.clone(), "rows"), count, move |range, window, cx| { + if let Some(on_end) = on_end.clone().filter(|_| range.end + NEAR_END >= count) { + cx.defer(move |cx| on_end(cx)); + } + let theme = cx.theme(); + let rem = window.rem_size(); + let height = theme.control_height(ControlSize::Lg).to_pixels(rem); + let lane = theme.icon_size(IconSize::Md).to_pixels(rem); + let stroke = theme.chart().hairline * 1.5; + let palette = theme.colors.chart; + range + .map(|ix| { + let commit = commits[ix].clone(); + let picked = selected.as_ref() == Some(&commit.id); + let item = + CommitItem::new((id.clone(), commit.id.clone()), commit).selected(picked); + let item = match &on_pick { + Some(pick) => { + let pick = pick.clone(); + item.on_pick(move |id, window, cx| pick(id, window, cx)) + } + None => item, + }; + let lanes = graph.as_ref().and_then(|rows| rows.get(ix)).cloned(); + div() + .w_full() + .flex() + .items_center() + .children( + lanes.map(|row| lanes_cell(row, span, lane, height, stroke, palette)), + ) + .child(item) + }) + .collect() + }) + .size_full() } } diff --git a/src/git/history.rs b/src/git/history.rs index 1b1252e..5ef335f 100644 --- a/src/git/history.rs +++ b/src/git/history.rs @@ -1,36 +1,37 @@ use std::rc::Rc; use gpui::{ - App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString, - StatefulInteractiveElement, Styled, Window, div, prelude::*, + App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, Role, SharedString, + StatefulInteractiveElement, Styled, Window, div, prelude::*, uniform_list, }; use super::{badges::DiffStat, commits::Commit}; use crate::{ + primitives::FocusRing, theme::{ActiveTheme, Radius, TextSize}, typography::Ellipsis, }; type OnPick = Rc; -/// A file's commits newest first on a line through them: subject, author and when, and what each changed; a press picks one. +/// A file's commits newest first; draws rows in view. #[derive(IntoElement)] pub struct FileHistory { id: ElementId, - commits: Vec<(Commit, (usize, usize))>, + commits: Rc)>>, selected: Option, on_pick: Option, } impl FileHistory { - /// Each commit with the lines it added and removed in the file. + /// Each commit with lines added and removed, if counted. pub fn new( id: impl Into, - commits: impl IntoIterator, + commits: Rc)>>, ) -> Self { Self { id: id.into(), - commits: commits.into_iter().collect(), + commits, selected: None, on_pick: None, } @@ -51,30 +52,41 @@ impl FileHistory { } impl RenderOnce for FileHistory { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let theme = cx.theme(); - let colors = theme.colors.clone(); - let last = self.commits.len().saturating_sub(1); - div() - .flex() - .flex_col() - .text_size(theme.text_size(TextSize::Sm)) - .children(self.commits.into_iter().enumerate().map( - |(ix, (commit, (added, removed)))| { - let selected = self.selected.as_ref() == Some(&commit.id); - let (pick, id) = (self.on_pick.clone(), commit.id.clone()); + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let Self { + id, + commits, + selected, + on_pick, + } = self; + let last = commits.len().saturating_sub(1); + uniform_list((id.clone(), "rows"), commits.len(), move |range, _, cx| { + let theme = cx.theme(); + let colors = theme.colors.clone(); + range + .map(|ix| { + let (commit, lines) = &commits[ix]; + let picked = selected.as_ref() == Some(&commit.id); + let (pick, picked_id) = (on_pick.clone(), commit.id.clone()); let short: String = commit.id.chars().take(7).collect(); div() - .id((self.id.clone(), format!("commit-{ix}"))) + .id((id.clone(), format!("commit-{ix}"))) + .w_full() + .role(Role::ListItem) + .aria_label(commit.subject.clone()) + .aria_selected(picked) + .tab_index(0) + .focus_ring(cx) .flex() .gap_3() .px_2() + .text_size(theme.text_size(TextSize::Sm)) .rounded(theme.radius(Radius::Sm)) - .when(selected, |row| row.bg(colors.active)) - .when(!selected, |row| row.hover(|row| row.bg(colors.hover))) + .when(picked, |row| row.bg(colors.active)) + .when(!picked, |row| row.hover(|row| row.bg(colors.hover))) .when_some(pick, |row, pick| { row.cursor_pointer() - .on_click(move |_, window, cx| pick(&id, window, cx)) + .on_click(move |_, window, cx| pick(&picked_id, window, cx)) }) .child( div() @@ -90,7 +102,7 @@ impl RenderOnce for FileHistory { .size_2() .rounded_full() .border_1() - .border_color(if selected { + .border_color(if picked { colors.focus } else { colors.border_strong @@ -130,13 +142,15 @@ impl RenderOnce for FileHistory { ), ), ) - .child( + .children(lines.map(|(added, removed)| { div() .flex_none() .py_1p5() - .child(DiffStat::new(added, removed)), - ) - }, - )) + .child(DiffStat::new(added, removed)) + })) + }) + .collect() + }) + .size_full() } } diff --git a/src/git/mod.rs b/src/git/mod.rs index 511b0ee..1e86e38 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -7,6 +7,8 @@ mod graph; mod history; mod merge; mod merging; +#[cfg(all(test, feature = "test-support"))] +mod picking; mod refs; mod review; mod rows; diff --git a/src/git/picking.rs b/src/git/picking.rs new file mode 100644 index 0000000..6a9db46 --- /dev/null +++ b/src/git/picking.rs @@ -0,0 +1,160 @@ +//! Long lists draw in view, load more, pick by key. + +use std::{cell::RefCell, rc::Rc}; + +use gpui::{ + Context, IntoElement, KeyBinding, KeyUpEvent, Keystroke, ParentElement, Render, Styled, + TestAppContext, VisualTestContext, Window, div, px, +}; + +use super::{Blame, BlameView, Commit, CommitList, FileHistory}; +use crate::{forms, primitives::FocusNext, theme::Theme}; + +fn commit(n: usize) -> Commit { + Commit { + id: format!("{n:040}").into(), + parents: vec![format!("{:040}", n + 1).into()], + subject: format!("change {n}").into(), + author: "Ann".into(), + when: "today".into(), + refs: Vec::new(), + } +} + +/// What a list asked for. +#[derive(Default)] +struct Heard { + picked: Vec, + ends: usize, +} + +struct Lists { + count: usize, + heard: Rc>, + which: &'static str, +} + +impl Render for Lists { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let commits: Vec = (0..self.count).map(commit).collect(); + let (pick, end) = (self.heard.clone(), self.heard.clone()); + let frame = div().w(px(600.0)).h(px(240.0)); + match self.which { + "commits" => frame.child( + CommitList::new("commits", Rc::new(commits)) + .on_pick(move |id, _, _| pick.borrow_mut().picked.push(id.to_string())) + .on_end(move |_| end.borrow_mut().ends += 1), + ), + "history" => frame.child( + FileHistory::new( + "history", + Rc::new(commits.into_iter().map(|c| (c, None)).collect()), + ) + .on_pick(move |id, _, _| pick.borrow_mut().picked.push(id.to_string())), + ), + _ => { + let blame = |commit: &str| Blame { + commit: commit.into(), + author: "Ann".into(), + when: "today".into(), + subject: "first".into(), + age: 0.0, + }; + frame.child( + BlameView::new( + "blame", + "one\ntwo\nthree", + [0, 0, 1], + [blame("a1"), blame("b2")], + ) + .on_commit(move |id, _, _| pick.borrow_mut().picked.push(id.to_string())), + ) + } + } + } +} + +fn shown<'a>( + cx: &'a mut TestAppContext, + count: usize, + which: &'static str, +) -> (Rc>, &'a mut VisualTestContext) { + cx.update(|cx| { + Theme::init(cx); + forms::bind_keys(cx); + cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]); + }); + let heard = Rc::new(RefCell::new(Heard::default())); + let held = heard.clone(); + let (_, cx) = cx.add_window_view(move |_, _| Lists { + count, + heard: held, + which, + }); + cx.update(|window, _| window.activate_window()); + settle(cx); + (heard, cx) +} + +fn settle(cx: &mut VisualTestContext) { + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); +} + +/// Tab to the first stop and press Enter. +fn press_first(cx: &mut VisualTestContext) { + 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); +} + +#[gpui::test] +fn a_long_list_asks_for_more_only_near_its_end(cx: &mut TestAppContext) { + let (heard, _) = shown(cx, 500, "commits"); + assert_eq!(heard.borrow().ends, 0, "500 rows, few in view"); +} + +#[gpui::test] +fn a_short_list_asks_for_more(cx: &mut TestAppContext) { + let (heard, _) = shown(cx, 12, "commits"); + assert!(heard.borrow().ends > 0); +} + +#[gpui::test] +fn a_commit_picks_from_the_keyboard(cx: &mut TestAppContext) { + let (heard, cx) = shown(cx, 500, "commits"); + press_first(cx); + assert_eq!(heard.borrow().picked, [format!("{:040}", 0)]); +} + +#[gpui::test] +fn a_file_history_row_picks_from_the_keyboard(cx: &mut TestAppContext) { + let (heard, cx) = shown(cx, 300, "history"); + press_first(cx); + assert_eq!(heard.borrow().picked, [format!("{:040}", 0)]); +} + +#[gpui::test] +fn a_blame_run_picks_its_commit_from_the_keyboard(cx: &mut TestAppContext) { + let (heard, cx) = shown(cx, 0, "blame"); + press_first(cx); + 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); + assert_eq!( + heard.borrow().picked, + ["a1", "b2"], + "each run's head, no stop between" + ); +} diff --git a/src/i18n/builtin.rs b/src/i18n/builtin.rs index 1a0ddfb..d6d3c7d 100644 --- a/src/i18n/builtin.rs +++ b/src/i18n/builtin.rs @@ -82,6 +82,9 @@ const EN: &[(&str, &str)] = &[ ("git.diff.split", "Split"), ("git.diff.unchanged.one", "{n} unchanged line"), ("git.diff.unchanged.other", "{n} unchanged lines"), + ("git.commit.commit_to", "Commit to {branch}"), + ("git.commit.amend_to", "Amend {branch}"), + ("git.commit.amend", "Amend last commit"), ]; const ZH_CN: &[(&str, &str)] = &[ @@ -162,6 +165,9 @@ const ZH_CN: &[(&str, &str)] = &[ ("git.diff.split", "并排"), ("git.diff.unchanged.one", "{n} 行未改动"), ("git.diff.unchanged.other", "{n} 行未改动"), + ("git.commit.commit_to", "提交到 {branch}"), + ("git.commit.amend_to", "修改 {branch} 的最近提交"), + ("git.commit.amend", "修改最近一次提交"), ]; /// Ely's own message for `key` in `locale`, English for a locale Ely does not ship; fails on an unknown key.