From 829ef681ba6b8de022c34a30c035cf5578af3674 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 02:36:16 -0400 Subject: [PATCH] =?UTF-8?q?Git=20=C2=B7=20ThreeWayMerge=20draws=20rows=20i?= =?UTF-8?q?n=20view=20with=20its=20words=20in=20i18n=20and=20lines=20read?= =?UTF-8?q?=20out;=20diff3=20base=20sections=20parse;=20branch=20and=20tag?= =?UTF-8?q?=20rows=20pick=20from=20the=20keyboard;=20shorter=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/gallery/pages/git/diffs.rs | 2 +- src/git/merge.rs | 323 +++++++++++++++------------- src/git/merging.rs | 41 +++- src/git/picking.rs | 51 ++++- src/git/refs.rs | 59 ++++- src/git/tags.rs | 40 +++- src/i18n/builtin.rs | 22 ++ 7 files changed, 358 insertions(+), 180 deletions(-) diff --git a/examples/gallery/pages/git/diffs.rs b/examples/gallery/pages/git/diffs.rs index b9c8708..893b028 100644 --- a/examples/gallery/pages/git/diffs.rs +++ b/examples/gallery/pages/git/diffs.rs @@ -129,7 +129,7 @@ pub fn merges(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { cx, ) .child( - div().w(px(840.)).child( + div().w(px(840.)).h(px(320.)).child( ThreeWayMerge::new("git-merge", ["main", "base", "feature/lift"], Rc::new(regions(BASE, OURS, THEIRS))) .takes(now_takes) .on_take(move |conflict, side, _, cx| { diff --git a/src/git/merge.rs b/src/git/merge.rs index 453d957..d37cac4 100644 --- a/src/git/merge.rs +++ b/src/git/merge.rs @@ -1,50 +1,30 @@ use std::rc::Rc; use gpui::{ - AnyElement, App, Div, ElementId, FontWeight, Hsla, IntoElement, ParentElement, RenderOnce, - SharedString, Styled, Window, div, prelude::*, relative, + AnyElement, App, Div, ElementId, FontWeight, Hsla, InteractiveElement, IntoElement, + ParentElement, RenderOnce, Role, SharedString, StatefulInteractiveElement, Styled, Window, div, + prelude::*, uniform_list, }; use similar::DiffableStr; -use super::merging::{Region, RegionKind, Take, conflicts, resolve, result}; +use super::merging::{Region, RegionKind, Take, conflicts, resolve}; use crate::{ buttons::{Button, ButtonVariant}, + i18n, primitives::{Icon, IconName}, theme::{ActiveTheme, ControlSize, IconSize, Palette, Radius, TextSize}, - typography::{LEADING, format}, }; type OnTake = Rc; type OnText = Rc; -/// A column of code lines, padded to `rows` so neighbors line up. -fn column(lines: &[String], rows: usize, wash: Option) -> Div { - div() - .flex_1() - .min_w_0() - .flex() - .flex_col() - .px_2() - .when_some(wash, |column, wash| column.bg(wash)) - .children((0..rows).map(|ix| { - let line = lines - .get(ix) - .map_or("", |line| line.trim_end_matches(['\r', '\n'])); - div() - .whitespace_nowrap() - .overflow_hidden() - .line_height(relative(LEADING)) - .child(line.to_string()) - })) -} - /// The three buttons that settle a conflict. fn takes( id: &ElementId, conflict: usize, on_take: &Option, - words: [&'static str; 3], + words: [SharedString; 3], ) -> Div { let [ours, theirs, both] = words; div().flex().gap_1().children( @@ -65,7 +45,7 @@ fn takes( ) } -/// Three versions side by side, ours, the base and theirs, region by region: clean changes merged and marked, each conflict held with a choice of side; the result below. +/// Ours, base and theirs side by side, rows in view. #[derive(IntoElement)] pub struct ThreeWayMerge { id: ElementId, @@ -113,156 +93,184 @@ fn washes(kind: RegionKind, colors: &Palette) -> [Option; 3] { } } +/// A row as the merge draws it. +#[derive(Clone)] +enum Line { + /// A conflict's place, before its lines. + Conflict(usize), + /// A line of each side, empty past its end. + Code(RegionKind, [Option; 3]), +} + +fn rows(regions: &[Region]) -> Vec { + let mut rows = Vec::new(); + let mut conflict = 0; + for region in regions { + if region.kind == RegionKind::Conflict { + rows.push(Line::Conflict(conflict)); + conflict += 1; + } + let count = region + .ours + .len() + .max(region.base.len()) + .max(region.theirs.len()); + let line = |side: &[String], ix: usize| { + side.get(ix) + .map(|line| line.trim_end_matches(['\r', '\n']).to_string()) + }; + rows.extend((0..count).map(|ix| { + Line::Code( + region.kind, + [ + line(®ion.ours, ix), + line(®ion.base, ix), + line(®ion.theirs, ix), + ], + ) + })); + } + rows +} + impl RenderOnce for ThreeWayMerge { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let colors = theme.colors.clone(); - let (merged, open) = result(&self.regions, &self.takes); - let total = self - .regions - .iter() - .filter(|region| region.kind == RegionKind::Conflict) - .count(); - let mut conflict = 0; - let blocks: Vec = self - .regions - .iter() - .map(|region| { - let rows = region - .ours - .len() - .max(region.base.len()) - .max(region.theirs.len()); - let [ours, base, theirs] = washes(region.kind, &colors); - let block = div().flex().flex_col().child( - div() + let height = theme.control_height(ControlSize::Sm); + let rows = Rc::new(rows(&self.regions)); + let Self { + id, + labels, + takes: taken, + on_take, + .. + } = self; + let header = div() + .flex() + .gap_px() + .bg(colors.hover) + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg_muted) + .children( + labels + .iter() + .map(|label| div().flex_1().min_w_0().px_2().py_1().child(label.clone())), + ); + let list = uniform_list((id.clone(), "rows"), rows.len(), move |range, _, cx| { + let colors = cx.theme().colors.clone(); + range + .map(|ix| { + let row = div() + .id((id.clone(), format!("row-{ix}"))) + .w_full() + .h(height) .flex() - .gap_px() - .child(column(®ion.ours, rows, ours)) - .child(column(®ion.base, rows, base)) - .child(column(®ion.theirs, rows, theirs)), - ); - if region.kind != RegionKind::Conflict { - return block.into_any_element(); - } - let at = conflict; - conflict += 1; - let settled = self.takes.get(at).copied().flatten(); - block - .child( - div() - .flex() - .items_center() - .gap_2() - .px_2() - .py_1() - .border_b_1() - .border_color(colors.border) - .font_family(theme.font_family.clone()) - .child( - Icon::new(IconName::TriangleAlert) - .size(IconSize::Sm) - .color(colors.warning), - ) - .child(div().flex_1().text_color(colors.fg_muted).child( - match settled { - Some(take) => { - format!("Conflict {} took {}", at + 1, word(take)) - } - None => format!("Conflict {}", at + 1), - }, - )) - .child(takes( - &self.id, - at, - &self.on_take, - ["Take ours", "Take theirs", "Take both"], - )), - ) - .into_any_element() - }) - .collect(); - let header = |label: &SharedString| { - div() - .flex_1() - .px_2() - .py_1() - .font_weight(FontWeight::MEDIUM) - .text_color(colors.fg_muted) - .child(label.clone()) - }; + .items_center(); + match &rows[ix] { + Line::Conflict(at) => { + let said = match taken.get(*at).copied().flatten() { + Some(take) => i18n::text( + cx, + "git.merge.took", + &[ + ("n", &(at + 1).to_string()), + ("side", &i18n::text(cx, word(take), &[])), + ], + ), + None => i18n::text( + cx, + "git.merge.conflict", + &[("n", &(at + 1).to_string())], + ), + }; + let words = [ + "git.merge.take_ours", + "git.merge.take_theirs", + "git.merge.take_both", + ] + .map(|key| i18n::text(cx, key, &[])); + row.gap_2() + .px_2() + .role(Role::Heading) + .aria_label(said.clone()) + .border_b_1() + .border_color(colors.border) + .font_family(cx.theme().font_family.clone()) + .child( + Icon::new(IconName::TriangleAlert) + .size(IconSize::Sm) + .color(colors.warning), + ) + .child(div().flex_1().text_color(colors.fg_muted).child(said)) + .child(takes(&id, *at, &on_take, words)) + .into_any_element() + } + Line::Code(kind, sides) => { + let washes = washes(*kind, &colors); + // Each side named by its label, for assistive tech. + let said: Vec = labels + .iter() + .zip(sides) + .map(|(label, line)| { + format!("{label}: {}", line.as_deref().unwrap_or("")) + }) + .collect(); + row.gap_px() + .role(Role::Label) + .aria_label(SharedString::from(said.join(" · "))) + .children(sides.iter().zip(washes).map(|(line, wash)| { + div() + .flex_1() + .min_w_0() + .h_full() + .px_2() + .flex() + .items_center() + .whitespace_nowrap() + .overflow_hidden() + .when_some(wash, |side, wash| side.bg(wash)) + .children(line.clone()) + })) + .into_any_element() + } + } + }) + .collect() + }) + .flex_1(); div() + .size_full() .flex() .flex_col() - .gap_3() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(colors.border) + .overflow_hidden() .text_size(theme.text_size(TextSize::Sm)) + .child(header) .child( div() + .flex_1() + .min_h_0() .flex() .flex_col() - .rounded(theme.radius(Radius::Md)) - .border_1() - .border_color(colors.border) - .overflow_hidden() - .child( - div() - .flex() - .gap_px() - .bg(colors.hover) - .children(self.labels.iter().map(header)), - ) - .child( - div() - .flex() - .flex_col() - .font_family(theme.mono_family.clone()) - .text_size(theme.text_size(TextSize::Xs)) - .children(blocks), - ), - ) - .child(div().text_color(colors.fg_muted).child(if open == 0 { - format!( - "Merged: {}, all settled.", - format::plural(total as u64, "conflict", "conflicts") - ) - } else { - format!( - "{} of {} still open.", - open, - format::plural(total as u64, "conflict", "conflicts") - ) - })) - .child( - div() - .p_2() - .rounded(theme.radius(Radius::Md)) - .bg(colors.sunken) .font_family(theme.mono_family.clone()) .text_size(theme.text_size(TextSize::Xs)) - .text_color(colors.fg) - .children(merged.tokenize_lines().into_iter().map(|line| { - let line = line.trim_end_matches(['\r', '\n']); - let marker = ["<<<<<<<", "=======", ">>>>>>>"] - .iter() - .any(|mark| line.starts_with(mark)); - div() - .whitespace_nowrap() - .when(marker, |row| row.text_color(colors.warning)) - .child(line.to_string()) - })), + .child(list), ) } } fn word(take: Take) -> &'static str { match take { - Take::Ours => "ours", - Take::Theirs => "theirs", - Take::Both => "both", + Take::Ours => "git.merge.ours", + Take::Theirs => "git.merge.theirs", + Take::Both => "git.merge.both", } } -/// A file with conflict markers, each conflict shown as the current and the incoming change, with actions to accept either or both. +/// Marked conflicts, each accepting current, incoming, or both. #[derive(IntoElement)] pub struct ConflictResolver { id: ElementId, @@ -279,7 +287,7 @@ impl ConflictResolver { } } - /// Called with the whole text once a conflict is settled. + /// Gets the whole text once a conflict settles. pub fn on_resolve(mut self, handler: impl Fn(String, &mut Window, &mut App) + 'static) -> Self { self.on_resolve = Some(Rc::new(handler)); self @@ -311,7 +319,12 @@ impl RenderOnce for ConflictResolver { &self.id, at, &on_take, - ["Accept current", "Accept incoming", "Accept both"], + [ + "git.merge.accept_current", + "git.merge.accept_incoming", + "git.merge.accept_both", + ] + .map(|key| i18n::text(cx, key, &[])), ) .py_0p5() .font_family(theme.font_family.clone()) diff --git a/src/git/merging.rs b/src/git/merging.rs index 580473d..65d956f 100644 --- a/src/git/merging.rs +++ b/src/git/merging.rs @@ -28,7 +28,7 @@ pub struct Region { pub theirs: Vec, } -/// A region's lines as similar split them, each with its ending. +/// A region's lines as split, each with its ending. fn lines<'a>( range: std::ops::Range, line: impl Fn(usize) -> Option<&'a str>, @@ -42,7 +42,7 @@ fn lines<'a>( .collect() } -/// The line ending the merged texts use: the first one found, or a newline. +/// The texts' line ending: the first found, else newline. fn ending(regions: &[Region]) -> &'static str { let line = regions .iter() @@ -55,7 +55,7 @@ fn ending(regions: &[Region]) -> &'static str { } } -/// Ends the last line when it has no ending, so what follows starts a line. +/// Ends an unended last line so what follows starts fresh. fn break_line(out: &mut String, ending: &str) { if !out.is_empty() && !out.as_str().ends_with_newline() { out.push_str(ending); @@ -84,7 +84,7 @@ pub fn regions(base: &str, ours: &str, theirs: &str) -> Vec { .collect() } -/// The merged text with each conflict as taken, and how many are still open; an open one keeps its markers. +/// The merged text as taken, and how many stay open. pub fn result(regions: &[Region], takes: &[Option]) -> (String, usize) { let ending = ending(regions); let mut out = String::new(); @@ -126,21 +126,25 @@ pub fn result(regions: &[Region], takes: &[Option]) -> (String, usize) { (out, open) } -/// A conflict in marked text: the lines of its markers, from zero. +/// A marked conflict: its marker lines, from zero. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Conflict { pub start: usize, + /// A diff3 conflict's `|||||||` line, before its base. + pub base: Option, pub middle: usize, pub end: usize, } -/// The conflicts marked in `text`: `<<<<<<<`, `=======`, `>>>>>>>`, each at a line's start. +/// The conflicts marked in `text`, markers at line starts. pub fn conflicts(text: &str) -> Vec { let mut found = Vec::new(); - let (mut start, mut middle) = (None, None); + let (mut start, mut base, mut middle) = (None, None, None); for (ix, line) in text.tokenize_lines().into_iter().enumerate() { if line.starts_with("<<<<<<<") { - (start, middle) = (Some(ix), None); + (start, base, middle) = (Some(ix), None, None); + } else if line.starts_with("|||||||") && start.is_some() && middle.is_none() { + base = Some(ix); } else if line.starts_with("=======") && start.is_some() { middle = Some(ix); } else if line.starts_with(">>>>>>>") @@ -148,6 +152,7 @@ pub fn conflicts(text: &str) -> Vec { { found.push(Conflict { start, + base: base.take(), middle, end: ix, }); @@ -156,14 +161,15 @@ pub fn conflicts(text: &str) -> Vec { found } -/// `text` with conflict `ix` settled as taken, every other byte kept; none when `text` has no conflict `ix`. +/// `text` with conflict `ix` settled; none if absent. pub fn resolve(text: &str, ix: usize, take: Take) -> Option { let Some(conflict) = conflicts(text).get(ix).copied() else { log::error!("no conflict {ix} to resolve in this text"); return None; }; let lines = text.tokenize_lines(); - let ours = lines[conflict.start + 1..conflict.middle].concat(); + // A diff3 conflict's base section is neither side. + let ours = lines[conflict.start + 1..conflict.base.unwrap_or(conflict.middle)].concat(); let theirs = lines[conflict.middle + 1..conflict.end].concat(); let kept = match take { Take::Ours => ours, @@ -185,6 +191,20 @@ pub fn resolve(text: &str, ix: usize, take: Take) -> Option { mod tests { use super::*; + #[test] + fn a_diff3_conflict_leaves_its_base_out() { + let text = "a\n<<<<<<< HEAD\nours\n||||||| base\nold\n=======\ntheirs\n>>>>>>> side\nz\n"; + assert_eq!(conflicts(text)[0].base, Some(3)); + assert_eq!( + resolve(text, 0, Take::Ours).as_deref(), + Some("a\nours\nz\n") + ); + assert_eq!( + resolve(text, 0, Take::Both).as_deref(), + Some("a\nours\ntheirs\nz\n") + ); + } + const BASE: &str = "fn lift() {\n 0.5\n}\nfn keep() {}\n"; const OURS: &str = "fn lift() {\n 0.6\n}\nfn keep() {}\n"; const THEIRS: &str = "fn lift() {\n 0.75\n}\nfn keep() { log() }\n"; @@ -244,6 +264,7 @@ mod tests { conflicts(marked), [Conflict { start: 1, + base: None, middle: 3, end: 5 }] diff --git a/src/git/picking.rs b/src/git/picking.rs index 6a9db46..c89b754 100644 --- a/src/git/picking.rs +++ b/src/git/picking.rs @@ -7,7 +7,9 @@ use gpui::{ TestAppContext, VisualTestContext, Window, div, px, }; -use super::{Blame, BlameView, Commit, CommitList, FileHistory}; +use super::{ + Blame, BlameView, Branch, BranchList, Commit, CommitList, FileHistory, GitTag, TagList, +}; use crate::{forms, primitives::FocusNext, theme::Theme}; fn commit(n: usize) -> Commit { @@ -52,6 +54,34 @@ impl Render for Lists { ) .on_pick(move |id, _, _| pick.borrow_mut().picked.push(id.to_string())), ), + "refs" => { + let branch = |name: &str, current| Branch { + name: name.to_string().into(), + remote: false, + current, + ahead: 0, + behind: 0, + subject: "s".into(), + when: "today".into(), + }; + let tag = GitTag { + name: "v1".into(), + message: None, + commit: "a1".into(), + when: "today".into(), + }; + let tagged = self.heard.clone(); + frame + .child( + BranchList::new("branches", [branch("main", true), branch("side", false)]) + .on_pick(move |name, _, _| { + pick.borrow_mut().picked.push(name.to_string()) + }), + ) + .child(TagList::new("tags", [tag]).on_pick(move |name, _, _| { + tagged.borrow_mut().picked.push(name.to_string()) + })) + } _ => { let blame = |commit: &str| Blame { commit: commit.into(), @@ -158,3 +188,22 @@ fn a_blame_run_picks_its_commit_from_the_keyboard(cx: &mut TestAppContext) { "each run's head, no stop between" ); } + +#[gpui::test] +fn branches_and_tags_pick_from_the_keyboard(cx: &mut TestAppContext) { + let (heard, cx) = shown(cx, 0, "refs"); + press_first(cx); + // Step through the stops until the tag is picked. + for _ in 0..8 { + if heard.borrow().picked.iter().any(|name| name == "v1") { + break; + } + press_first(cx); + } + let picked = heard.borrow().picked.clone(); + assert_eq!(picked.first().map(String::as_str), Some("main")); + assert!( + picked.contains(&"side".to_string()) && picked.contains(&"v1".to_string()), + "{picked:?}" + ); +} diff --git a/src/git/refs.rs b/src/git/refs.rs index 62e1efa..f56787c 100644 --- a/src/git/refs.rs +++ b/src/git/refs.rs @@ -2,19 +2,19 @@ use std::rc::Rc; use gpui::{ AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, - RenderOnce, SharedString, Styled, Window, div, prelude::*, + RenderOnce, Role, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, }; use crate::{ buttons::{ButtonVariant, IconButton}, i18n, navigation::{Group, Palette, Row, fuzzy, marked, query_field}, - primitives::{Icon, IconName}, + primitives::{FocusRing, Icon, IconName}, theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, typography::{Ellipsis, tabular}, }; -/// A branch: its name, remote or local, whether it is checked out, how far it is from its upstream, and its last commit. +/// A branch, its upstream distance, and its last commit. #[derive(Clone, Debug, PartialEq)] pub struct Branch { pub name: SharedString, @@ -36,13 +36,13 @@ fn distance(branch: &Branch) -> Option { (!parts.is_empty()).then(|| parts.join(" ")) } -/// The value a picked row carries: a branch's name, or a new one's after this mark. +/// Marks a picked row's value as a new branch's name. const CREATE: &str = "\u{0}create:"; -type OnName = Rc; +pub(super) type OnName = Rc; pub(super) type Run = Rc; -/// Branches as a palette, local and remote apart, the current one checked; a query no branch is named becomes a new branch. +/// Branches as a palette; an unknown query makes one. #[derive(IntoElement)] pub struct BranchSelector { id: ElementId, @@ -197,7 +197,14 @@ impl RenderOnce for BranchSelector { } } -/// A row's name, detail and trailing words, the actions shown on hover. +/// A row's name, detail, trailing words and hover actions. +/// A pickable row: its spoken name, whether picked, the pick. +pub(super) struct Pick { + pub spoken: SharedString, + pub selected: bool, + pub run: Run, +} + pub(super) fn listed( id: ElementId, icon: IconName, @@ -205,6 +212,7 @@ pub(super) fn listed( detail: SharedString, trailing: Vec, actions: Vec, + pick: Option, cx: &App, ) -> AnyElement { let theme = cx.theme(); @@ -213,6 +221,17 @@ pub(super) fn listed( div() .id(id) .group(group.clone()) + .when_some(pick, |row, pick| { + let run = pick.run; + row.role(Role::ListItem) + .aria_label(pick.spoken) + .aria_selected(pick.selected) + .tab_index(0) + .focus_ring(cx) + .cursor_pointer() + .when(pick.selected, |row| row.bg(colors.selection)) + .on_click(move |_, window, cx| run(window, cx)) + }) .flex() .items_center() .gap_2() @@ -260,13 +279,15 @@ pub(super) fn action( .into_any_element() } -/// Branches with how far each is from its upstream and its last commit; switch to one or delete it. +/// Branches with upstream distance and last commit. #[derive(IntoElement)] pub struct BranchList { id: ElementId, branches: Vec, on_switch: Option, on_delete: Option, + selected: Option, + on_pick: Option, } impl BranchList { @@ -276,9 +297,26 @@ impl BranchList { branches: branches.into_iter().collect(), on_switch: None, on_delete: None, + selected: None, + on_pick: None, } } + /// The branch shown as picked. + pub fn selected(mut self, name: impl Into) -> Self { + self.selected = Some(name.into()); + self + } + + /// A press on a row picks its branch. + pub fn on_pick( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_pick = Some(Rc::new(handler)); + self + } + pub fn on_switch( mut self, handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, @@ -352,6 +390,11 @@ impl RenderOnce for BranchList { branch.subject.clone(), trailing, actions, + bind(&self.on_pick).map(|run| Pick { + spoken: branch.name.clone(), + selected: self.selected.as_ref() == Some(&branch.name), + run, + }), cx, ) }) diff --git a/src/git/tags.rs b/src/git/tags.rs index 73504d3..f65597f 100644 --- a/src/git/tags.rs +++ b/src/git/tags.rs @@ -5,7 +5,7 @@ use gpui::{ Window, div, }; -use super::refs::{Run, action, listed}; +use super::refs::{OnName, Pick, Run, action, listed}; use crate::{ primitives::IconName, theme::{ActiveTheme, TextSize}, @@ -13,7 +13,7 @@ use crate::{ type OnStash = Rc; -/// A tag: its name, its message when annotated, the commit it names, and when. +/// A tag, its message if annotated, its commit, when. #[derive(Clone, Debug, PartialEq)] pub struct GitTag { pub name: SharedString, @@ -22,11 +22,13 @@ pub struct GitTag { pub when: SharedString, } -/// Tags newest first, each with its message and the commit it names. +/// Tags newest first, with message and commit. #[derive(IntoElement)] pub struct TagList { id: ElementId, tags: Vec, + selected: Option, + on_pick: Option, } impl TagList { @@ -34,8 +36,25 @@ impl TagList { Self { id: id.into(), tags: tags.into_iter().collect(), + selected: None, + on_pick: None, } } + + /// The tag shown as picked. + pub fn selected(mut self, name: impl Into) -> Self { + self.selected = Some(name.into()); + self + } + + /// A press on a row picks its tag. + pub fn on_pick( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_pick = Some(Rc::new(handler)); + self + } } impl RenderOnce for TagList { @@ -57,6 +76,16 @@ impl RenderOnce for TagList { tag.message.clone().unwrap_or_default(), vec![short.into(), tag.when.clone()], Vec::new(), + self.on_pick.clone().map(|pick| { + let name = tag.name.clone(); + Pick { + spoken: tag.name.clone(), + selected: self.selected.as_ref() == Some(&tag.name), + run: Rc::new(move |window: &mut Window, cx: &mut App| { + pick(&name, window, cx) + }), + } + }), cx, ) }) @@ -69,7 +98,7 @@ impl RenderOnce for TagList { } } -/// A stash: its message, the branch it came from, and when. +/// A stash: its message, branch, and when. #[derive(Clone, Debug, PartialEq)] pub struct Stash { pub message: SharedString, @@ -85,7 +114,7 @@ pub enum StashAction { Drop, } -/// Stashes newest first, as `stash@{n}`: apply one, pop it, or drop it. +/// Stashes newest first: apply, pop or drop. #[derive(IntoElement)] pub struct StashList { id: ElementId, @@ -148,6 +177,7 @@ impl RenderOnce for StashList { act("pop", IconName::ArrowUp, "Pop", StashAction::Pop), act("drop", IconName::Trash2, "Drop", StashAction::Drop), ], + None, cx, ) }) diff --git a/src/i18n/builtin.rs b/src/i18n/builtin.rs index 61d88c7..cc61ae4 100644 --- a/src/i18n/builtin.rs +++ b/src/i18n/builtin.rs @@ -85,6 +85,17 @@ const EN: &[(&str, &str)] = &[ ("git.commit.commit_to", "Commit to {branch}"), ("git.commit.amend_to", "Amend {branch}"), ("git.commit.amend", "Amend last commit"), + ("git.merge.conflict", "Conflict {n}"), + ("git.merge.took", "Conflict {n} took {side}"), + ("git.merge.ours", "ours"), + ("git.merge.theirs", "theirs"), + ("git.merge.both", "both"), + ("git.merge.take_ours", "Take ours"), + ("git.merge.take_theirs", "Take theirs"), + ("git.merge.take_both", "Take both"), + ("git.merge.accept_current", "Accept current"), + ("git.merge.accept_incoming", "Accept incoming"), + ("git.merge.accept_both", "Accept both"), ]; const ZH_CN: &[(&str, &str)] = &[ @@ -168,6 +179,17 @@ const ZH_CN: &[(&str, &str)] = &[ ("git.commit.commit_to", "提交到 {branch}"), ("git.commit.amend_to", "修改 {branch} 的最近提交"), ("git.commit.amend", "修改最近一次提交"), + ("git.merge.conflict", "冲突 {n}"), + ("git.merge.took", "冲突 {n} 取了{side}"), + ("git.merge.ours", "当前侧"), + ("git.merge.theirs", "另一侧"), + ("git.merge.both", "两侧"), + ("git.merge.take_ours", "取当前侧"), + ("git.merge.take_theirs", "取另一侧"), + ("git.merge.take_both", "取两侧"), + ("git.merge.accept_current", "接受当前"), + ("git.merge.accept_incoming", "接受传入"), + ("git.merge.accept_both", "接受两者"), ]; /// Ely's message for `key`; English if unshipped, fails if unknown.