Files
Ely-GPUI-Components/src/git/refs.rs
T

387 lines
12 KiB
Rust

use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, Styled, Window, div, prelude::*,
};
use crate::{
buttons::{ButtonVariant, IconButton},
i18n,
navigation::{Group, Palette, Row, fuzzy, marked, query_field},
primitives::{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.
#[derive(Clone, Debug, PartialEq)]
pub struct Branch {
pub name: SharedString,
pub remote: bool,
pub current: bool,
pub ahead: usize,
pub behind: usize,
pub subject: SharedString,
pub when: SharedString,
}
/// `↑2 ↓1`, or nothing when even.
fn distance(branch: &Branch) -> Option<String> {
let parts: Vec<String> = [(branch.ahead, '↑'), (branch.behind, '↓')]
.iter()
.filter(|(count, _)| *count > 0)
.map(|(count, arrow)| format!("{arrow}{count}"))
.collect();
(!parts.is_empty()).then(|| parts.join(" "))
}
/// The value a picked row carries: a branch's name, or a new one's after this mark.
const CREATE: &str = "\u{0}create:";
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.
#[derive(IntoElement)]
pub struct BranchSelector {
id: ElementId,
branches: Vec<Branch>,
on_pick: Option<OnName>,
on_create: Option<OnName>,
on_close: Run,
}
impl BranchSelector {
pub fn new(
id: impl Into<ElementId>,
branches: impl IntoIterator<Item = Branch>,
on_close: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
branches: branches.into_iter().collect(),
on_pick: None,
on_create: None,
on_close: Rc::new(on_close),
}
}
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_create(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_create = Some(Rc::new(handler));
self
}
}
impl RenderOnce for BranchSelector {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let input = query_field(
&self.id,
i18n::text(cx, "palette.branch.placeholder", &[]),
window,
cx,
);
let query = input.read(cx).text().trim().to_string();
let colors = cx.theme().colors.clone();
let row = |branch: &Branch, cx: &App| -> Option<(i32, Row)> {
let (score, hits) = if query.is_empty() {
(0, Vec::new())
} else {
let fit = fuzzy(&query, &branch.name)?;
(fit.score, fit.hits)
};
Some((
score,
Row {
value: branch.name.clone(),
name: branch.name.clone(),
icon: Some(if branch.current {
IconName::Check
} else {
IconName::GitBranch
}),
label: marked(branch.name.clone(), hits, cx),
detail: Some(
div()
.text_color(colors.fg_subtle)
.child(branch.subject.clone())
.into_any_element(),
),
end: distance(branch).map(|text| {
div()
.text_color(colors.fg_subtle)
.child(text)
.into_any_element()
}),
},
))
};
let group = |title: SharedString, remote: bool, cx: &App| {
let mut rows: Vec<(i32, Row)> = self
.branches
.iter()
.filter(|branch| branch.remote == remote)
.filter_map(|branch| row(branch, cx))
.collect();
if !query.is_empty() {
rows.sort_by(|(a, _), (b, _)| b.cmp(a));
}
Group {
title: Some(title),
rows: rows.into_iter().map(|(_, row)| row).collect(),
}
};
let mut groups = vec![
group(i18n::text(cx, "palette.branch.local", &[]), false, cx),
group(i18n::text(cx, "palette.branch.remote", &[]), true, cx),
];
let named = self
.branches
.iter()
.any(|branch| branch.name.as_ref() == query);
if !query.is_empty() && !named {
let create = i18n::text(cx, "palette.branch.create", &[("query", &query)]);
groups.insert(
0,
Group {
title: None,
rows: vec![Row {
value: format!("{CREATE}{query}").into(),
name: create.clone(),
icon: Some(IconName::Plus),
label: div().child(create).into_any_element(),
detail: None,
end: None,
}],
},
);
}
let (pick, create) = (self.on_pick, self.on_create);
let on_pick: OnName = Rc::new(
move |value: &SharedString, window: &mut Window, cx: &mut App| match value
.strip_prefix(CREATE)
{
Some(name) => {
if let Some(create) = &create {
create(&name.to_string().into(), window, cx)
}
}
None => {
if let Some(pick) = &pick {
pick(value, window, cx)
}
}
},
);
Palette {
id: self.id,
input,
groups,
start: 0,
empty: i18n::text(cx, "palette.branch.empty", &[]),
on_pick: Some(on_pick),
on_close: self.on_close,
}
.overlay(window, cx)
}
}
/// A row's name, detail and trailing words, the actions shown on hover.
pub(super) fn listed(
id: ElementId,
icon: IconName,
name: AnyElement,
detail: SharedString,
trailing: Vec<SharedString>,
actions: Vec<AnyElement>,
cx: &App,
) -> AnyElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let group = SharedString::from(format!("{id:?}"));
div()
.id(id)
.group(group.clone())
.flex()
.items_center()
.gap_2()
.px_2()
.py_1()
.rounded(theme.radius(Radius::Sm))
.hover(|row| row.bg(colors.hover))
.child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted))
.child(div().flex_none().child(name))
.child(
div()
.flex_1()
.min_w_0()
.text_color(colors.fg_subtle)
.child(Ellipsis::new(detail)),
)
.children(
trailing
.into_iter()
.map(|words| tabular(div().flex_none().text_color(colors.fg_subtle)).child(words)),
)
.child(
div()
.flex()
.opacity(0.)
.group_hover(group, |actions| actions.opacity(1.))
.children(actions),
)
.into_any_element()
}
pub(super) fn action(
id: ElementId,
icon: IconName,
words: impl Into<SharedString>,
run: Option<Run>,
) -> AnyElement {
IconButton::new(id, icon)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(words)
.when_some(run, |button, run| {
button.on_click(move |_, window, cx| run(window, cx))
})
.into_any_element()
}
/// Branches with how far each is from its upstream and its last commit; switch to one or delete it.
#[derive(IntoElement)]
pub struct BranchList {
id: ElementId,
branches: Vec<Branch>,
on_switch: Option<OnName>,
on_delete: Option<OnName>,
}
impl BranchList {
pub fn new(id: impl Into<ElementId>, branches: impl IntoIterator<Item = Branch>) -> Self {
Self {
id: id.into(),
branches: branches.into_iter().collect(),
on_switch: None,
on_delete: None,
}
}
pub fn on_switch(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_switch = Some(Rc::new(handler));
self
}
pub fn on_delete(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_delete = Some(Rc::new(handler));
self
}
}
impl RenderOnce for BranchList {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let rows: Vec<AnyElement> = self
.branches
.iter()
.map(|branch| {
let name = div()
.when(branch.current, |name| {
name.font_weight(FontWeight::SEMIBOLD)
})
.text_color(colors.fg)
.child(branch.name.clone())
.into_any_element();
let bind = |handler: &Option<OnName>| {
handler.clone().map(|handler| {
let name = branch.name.clone();
Rc::new(move |window: &mut Window, cx: &mut App| handler(&name, window, cx))
as Run
})
};
let actions = if branch.current {
Vec::new()
} else {
vec![
action(
(self.id.clone(), format!("switch-{}", branch.name)).into(),
IconName::ArrowRight,
i18n::text(cx, "palette.branch.switch", &[]),
bind(&self.on_switch),
),
action(
(self.id.clone(), format!("delete-{}", branch.name)).into(),
IconName::Trash2,
i18n::text(cx, "palette.branch.delete", &[]),
bind(&self.on_delete),
),
]
};
let trailing: Vec<SharedString> = distance(branch)
.into_iter()
.map(SharedString::from)
.chain([branch.when.clone()])
.collect();
listed(
(self.id.clone(), format!("branch-{}", branch.name)).into(),
if branch.current {
IconName::Check
} else {
IconName::GitBranch
},
name,
branch.subject.clone(),
trailing,
actions,
cx,
)
})
.collect();
div()
.flex()
.flex_col()
.text_size(theme.text_size(TextSize::Sm))
.children(rows)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn distance_reads_ahead_then_behind() {
let branch = |ahead, behind| Branch {
name: "main".into(),
remote: false,
current: true,
ahead,
behind,
subject: "".into(),
when: "".into(),
};
assert_eq!(distance(&branch(2, 1)).as_deref(), Some("↑2 ↓1"));
assert_eq!(distance(&branch(0, 3)).as_deref(), Some("↓3"));
assert_eq!(distance(&branch(0, 0)), None);
}
}