Files
ElyOffice/apps/ely-tree/src/side_places.rs
T
ZacharyZhang-NY b7fca0146d Ely Office: six offline-first desktop apps in Rust and GPUI
ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git),
ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
2026-10-08 21:20:08 -04:00

337 lines
12 KiB
Rust

//! The Repository panel: stashes, remotes, worktrees, more.
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
git::{Stash, StashList},
lists::{List, ListItem},
theme::{ActiveTheme as _, ControlSize},
};
use gpui::{
AnyElement, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _,
Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div,
prelude::FluentBuilder as _,
};
use crate::{
changes::Changes,
hist::when,
places::{Place, Read},
structure::{Checkout, LfsFile},
tree::{Tree, text},
};
type Act = Box<dyn Fn(&mut Tree, &mut Window, &mut Context<Tree>)>;
fn button(tree: &Entity<Tree>, id: &'static str, words: SharedString, act: Act) -> Button {
let this = tree.downgrade();
Button::new(id, words)
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.on_click(move |_, window, cx| {
this.update(cx, |tree, cx| act(tree, window, cx)).ok();
})
}
/// A list's name, with its own buttons.
fn heading(name: SharedString, buttons: Vec<Button>, cx: &App) -> AnyElement {
let colors = cx.theme().colors.clone();
div()
.flex()
.items_center()
.gap_1()
.pt_2()
.child(
div()
.id(SharedString::from(format!("tree-heading-{name}")))
.role(Role::Heading)
.aria_label(name.clone())
.flex_1()
.text_color(colors.fg_muted)
.child(name),
)
.children(buttons)
.into_any_element()
}
/// A list Git could not give: its own words.
fn unread(error: &str, cx: &App) -> AnyElement {
let colors = cx.theme().colors.clone();
div()
.text_color(colors.danger)
.child(error.to_string())
.into_any_element()
}
fn size(bytes: u64) -> String {
match bytes {
0..1024 => format!("{bytes} B"),
1024..1_048_576 => format!("{:.1} KB", bytes as f64 / 1024.0),
_ => format!("{:.1} MB", bytes as f64 / 1_048_576.0),
}
}
fn lfs_row(at: usize, file: &LfsFile, cx: &App) -> AnyElement {
let key = match (file.checked_out, file.downloaded) {
(true, _) => "tree.lfs.here",
(false, true) => "tree.lfs.stored",
(false, false) => "tree.lfs.pointer",
};
ListItem::new(("tree-lfs", at), file.path.clone())
.description(format!("{} · {}", text(cx, key, &[]), size(file.size)))
.into_any_element()
}
pub fn places(tree: &Entity<Tree>, cx: &mut Context<Changes>) -> AnyElement {
let read_tree = tree.read(cx);
let Some(opened) = read_tree.active() else {
return div().into_any_element();
};
let Some(read) = &opened.places.read else {
return div().into_any_element();
};
let picked = read_tree.picked_place.clone();
let label = |key: &str| text(cx, key, &[]);
let pick = |place: Place| {
let this = tree.downgrade();
move |_: &gpui::ClickEvent, _: &mut Window, cx: &mut App| {
this.update(cx, |tree, cx| tree.pick_place(place.clone(), cx))
.ok();
}
};
let net = div().flex().flex_wrap().gap_1().children([
button(
tree,
"tree-fetch",
label("tree.net.fetch"),
Box::new(|t, _, cx| t.fetch_all(cx)),
),
button(
tree,
"tree-pull",
label("tree.net.pull"),
Box::new(|t, _, cx| t.pull(cx)),
),
button(
tree,
"tree-push",
label("tree.net.push"),
Box::new(|t, _, cx| t.push(cx)),
),
button(
tree,
"tree-force",
label("tree.force.open"),
Box::new(|t, _, cx| t.ask_force(cx)),
),
]);
let local = div().flex().flex_wrap().gap_1().children([
button(
tree,
"tree-terminal",
label("tree.tool.terminal"),
Box::new(|t, _, cx| t.open_terminal(cx)),
),
button(
tree,
"tree-reveal",
label("tree.tool.reveal"),
Box::new(|t, _, cx| t.reveal(cx)),
),
button(
tree,
"tree-clean",
label("tree.clean.open"),
Box::new(|t, _, cx| t.ask_clean(cx)),
),
]);
let mut out: Vec<AnyElement> = vec![net.into_any_element(), local.into_any_element()];
out.push(heading(
label("tree.places.stashes"),
vec![button(
tree,
"tree-stash-new",
label("tree.stash.open"),
Box::new(|t, window, cx| t.ask_stash(window, cx)),
)],
cx,
));
out.push(stashes(tree, read, picked.as_ref(), cx));
out.push(heading(
label("tree.places.remotes"),
vec![button(
tree,
"tree-remote-new",
label("tree.remote.open"),
Box::new(|t, window, cx| t.ask_remote(None, window, cx)),
)],
cx,
));
out.push(match &read.remotes {
Err(error) => unread(error, cx),
Ok(remotes) => List::new()
.children(remotes.iter().enumerate().map(|(at, remote)| {
let place = Place::Remote(remote.name.clone());
ListItem::new(("tree-remote", at), remote.name.clone())
.description(remote.urls.join(", "))
.selected(picked.as_ref() == Some(&place))
.on_click(pick(place))
.into_any_element()
}))
.into_any_element(),
});
let prunable =
matches!(&read.worktrees, Ok(trees) if trees.iter().any(|t| t.prunable.is_some()));
let mut tree_buttons = vec![button(
tree,
"tree-worktree-new",
label("tree.worktree.open"),
Box::new(|t, window, cx| t.add_worktree(window, cx)),
)];
if prunable {
tree_buttons.push(button(
tree,
"tree-worktree-prune",
label("tree.worktree.prune"),
Box::new(|t, _, cx| t.prune_worktrees(cx)),
));
}
out.push(heading(label("tree.places.worktrees"), tree_buttons, cx));
out.push(match &read.worktrees {
Err(error) => unread(error, cx),
Ok(trees) => List::new()
.children(trees.iter().enumerate().map(|(at, worktree)| {
let place = Place::Worktree(worktree.path.clone());
let about = match (&worktree.branch, &worktree.head, &worktree.prunable) {
(_, _, Some(_)) => label("tree.worktree.prunable"),
(Some(branch), _, _) => branch.clone().into(),
(None, Some(head), _) => text(
cx,
"tree.branch.detached",
&[("oid", &head[..head.len().min(7)])],
),
(None, None, _) => label("tree.worktree.bare"),
};
ListItem::new(
("tree-worktree", at),
worktree.path.file_name().map_or_else(
|| worktree.path.display().to_string(),
|name| name.to_string_lossy().into_owned(),
),
)
.description(about)
.selected(picked.as_ref() == Some(&place))
.on_click(pick(place))
.into_any_element()
}))
.into_any_element(),
});
match &read.submodules {
Ok(subs) if subs.is_empty() => {}
Err(error) => {
out.push(heading(label("tree.places.submodules"), Vec::new(), cx));
out.push(unread(error, cx));
}
Ok(subs) => {
out.push(heading(label("tree.places.submodules"), Vec::new(), cx));
out.push(
List::new()
.children(subs.iter().enumerate().map(|(at, sub)| {
let place = Place::Submodule(sub.path.clone());
let key = match (&sub.checkout, sub.conflicted) {
(_, true) => "tree.submodule.conflicted",
(Checkout::Missing, _) => "tree.submodule.missing",
(Checkout::At(head), _) if *head == sub.recorded => {
"tree.submodule.matches"
}
(Checkout::At(_), _) => "tree.submodule.differs",
(Checkout::Unread(_), _) => "tree.submodule.unread",
};
ListItem::new(("tree-submodule", at), sub.path.clone())
.description(label(key))
.selected(picked.as_ref() == Some(&place))
.on_click(pick(place))
.into_any_element()
}))
.into_any_element(),
);
}
}
match &read.lfs {
Ok(files) if files.is_empty() => {}
Err(error) => {
out.push(heading(label("tree.places.lfs"), Vec::new(), cx));
out.push(unread(error, cx));
}
Ok(files) => {
out.push(heading(label("tree.places.lfs"), Vec::new(), cx));
out.push(
List::new()
.children(
files
.iter()
.enumerate()
.map(|(at, file)| lfs_row(at, file, cx)),
)
.into_any_element(),
);
}
}
out.push(heading(label("tree.places.reflog"), Vec::new(), cx));
out.push(match &read.reflog {
Err(error) => unread(error, cx),
Ok(moves) => List::new()
.children(moves.iter().enumerate().map(|(at, moved)| {
let chosen = matches!(&picked, Some(Place::Reflog(was)) if was.same(moved));
ListItem::new(("tree-reflog", at), moved.said.clone())
.description(format!("{} · {}", moved.name, when(moved.at)))
.selected(chosen)
.on_click(pick(Place::Reflog(moved.clone())))
.into_any_element()
}))
.into_any_element(),
});
div()
.id("tree-places-scroll")
.size_full()
.overflow_y_scroll()
.p_2()
.flex()
.flex_col()
.gap_1()
.children(out)
.into_any_element()
}
fn stashes(tree: &Entity<Tree>, read: &Read, picked: Option<&Place>, cx: &App) -> AnyElement {
let list = match &read.stashes {
Err(error) => return unread(error, cx),
Ok(list) => list,
};
let rows = list.iter().map(|stash| Stash {
message: stash.message.clone().into(),
branch: stash.branch.clone().into(),
when: when(stash.at),
});
let oids: Vec<String> = list.iter().map(|stash| stash.oid.clone()).collect();
let (pick, act) = (tree.downgrade(), tree.downgrade());
StashList::new("tree-stashes", rows)
.when_some(
picked.and_then(|place| match place {
Place::Stash(oid) => oids.iter().position(|known| known == oid),
_ => None,
}),
|list, at| list.selected(at),
)
.on_pick(move |at, _, cx| {
if let Some(oid) = oids.get(at).cloned() {
pick.update(cx, |tree, cx| tree.pick_place(Place::Stash(oid), cx))
.ok();
}
})
.on_action(move |at, action, _, cx| {
act.update(cx, |tree, cx| tree.stash_act(at, action, cx))
.ok();
})
.into_any_element()
}