Git · ThreeWayMerge draws rows in view with its words in i18n and lines read out; diff3 base sections parse; branch and tag rows pick from the keyboard; shorter comments
This commit is contained in:
7 files changed
+358
-180
No files matched your search
@@ -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| {
|
||||
|
||||
+168
-155
@@ -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<dyn Fn(usize, Take, &mut Window, &mut App)>;
|
||||
type OnText = Rc<dyn Fn(String, &mut Window, &mut App)>;
|
||||
|
||||
/// A column of code lines, padded to `rows` so neighbors line up.
|
||||
fn column(lines: &[String], rows: usize, wash: Option<Hsla>) -> 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<OnTake>,
|
||||
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<Hsla>; 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<String>; 3]),
|
||||
}
|
||||
|
||||
fn rows(regions: &[Region]) -> Vec<Line> {
|
||||
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<AnyElement> = 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<String> = 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())
|
||||
|
||||
+31
-10
@@ -28,7 +28,7 @@ pub struct Region {
|
||||
pub theirs: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<usize>,
|
||||
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<Region> {
|
||||
.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<Take>]) -> (String, usize) {
|
||||
let ending = ending(regions);
|
||||
let mut out = String::new();
|
||||
@@ -126,21 +126,25 @@ pub fn result(regions: &[Region], takes: &[Option<Take>]) -> (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<usize>,
|
||||
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<Conflict> {
|
||||
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<Conflict> {
|
||||
{
|
||||
found.push(Conflict {
|
||||
start,
|
||||
base: base.take(),
|
||||
middle,
|
||||
end: ix,
|
||||
});
|
||||
@@ -156,14 +161,15 @@ pub fn conflicts(text: &str) -> Vec<Conflict> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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
|
||||
}]
|
||||
|
||||
+50
-1
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
+51
-8
@@ -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<String> {
|
||||
(!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<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||
pub(super) type OnName = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||
pub(super) type Run = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// 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<SharedString>,
|
||||
actions: Vec<AnyElement>,
|
||||
pick: Option<Pick>,
|
||||
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<Branch>,
|
||||
on_switch: Option<OnName>,
|
||||
on_delete: Option<OnName>,
|
||||
selected: Option<SharedString>,
|
||||
on_pick: Option<OnName>,
|
||||
}
|
||||
|
||||
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<SharedString>) -> 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,
|
||||
)
|
||||
})
|
||||
|
||||
+35
-5
@@ -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<dyn Fn(usize, StashAction, &mut Window, &mut App)>;
|
||||
|
||||
/// 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<GitTag>,
|
||||
selected: Option<SharedString>,
|
||||
on_pick: Option<OnName>,
|
||||
}
|
||||
|
||||
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<SharedString>) -> 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,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user