ElyTree: no Git version in the status bar; plain real paths on Windows
The component pin moves to bd01470 (long change-list names end before the row's buttons). Real paths come from dunce, so Windows shows C:\... rather than \?\C:\..., and 36 more ElyTree tests pass there. The lock returns to windows-sys 0.61, which the last pin change had resolved down. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d3cfff86db
commit
bfa2f1908c
14 files changed
+450
-471
No files matched your search
+148
-155
@@ -1,155 +1,148 @@
|
||||
//! Git as a program: argument arrays, never a shell (§13.1).
|
||||
|
||||
use std::{
|
||||
io::{self, Write as _},
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Output, Stdio},
|
||||
};
|
||||
|
||||
/// A Git binary and its version.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Git {
|
||||
pub path: PathBuf,
|
||||
pub version: String,
|
||||
/// Ely's own build, not a system Git.
|
||||
pub bundled: bool,
|
||||
}
|
||||
|
||||
impl Git {
|
||||
/// `ELY_GIT` or the setting picks Git; else Ely's own.
|
||||
pub fn find() -> io::Result<Self> {
|
||||
let (path, bundled) = ely_platform::git::locate()?;
|
||||
if !path.is_file() {
|
||||
let hint = if bundled {
|
||||
"run scripts/build-git.sh"
|
||||
} else {
|
||||
"check ELY_GIT or the Git setting"
|
||||
};
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("no Git at {} ({hint})", path.display()),
|
||||
));
|
||||
}
|
||||
let version = match bundled {
|
||||
true => bundled_version(&path)?,
|
||||
false => ely_platform::git::system_version(&path)?,
|
||||
};
|
||||
log::info!(
|
||||
"tree: {version} at {} ({})",
|
||||
path.display(),
|
||||
if bundled { "bundled" } else { "system" }
|
||||
);
|
||||
Ok(Git {
|
||||
path,
|
||||
version,
|
||||
bundled,
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs Git in `dir`; failure carries Git's words.
|
||||
pub fn run(&self, dir: &Path, args: &[&str]) -> io::Result<Vec<u8>> {
|
||||
let output = self.command(dir, args).output()?;
|
||||
checked(args, output)
|
||||
}
|
||||
|
||||
/// Runs Git with `input` on its standard input.
|
||||
pub fn run_input(&self, dir: &Path, args: &[&str], input: &[u8]) -> io::Result<Vec<u8>> {
|
||||
let mut child = self
|
||||
.command(dir, args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| io::Error::other("no stdin"))?;
|
||||
let input = input.to_vec();
|
||||
// Writing apart from reading cannot fill both pipes.
|
||||
let writer = std::thread::spawn(move || stdin.write_all(&input));
|
||||
let output = child.wait_with_output()?;
|
||||
writer
|
||||
.join()
|
||||
.map_err(|_| io::Error::other("the stdin writer panicked"))??;
|
||||
checked(args, output)
|
||||
}
|
||||
|
||||
/// HEAD names a commit; a new repository has none yet.
|
||||
pub fn born(&self, dir: &Path) -> io::Result<bool> {
|
||||
if !self.run_or_one(dir, &["rev-parse", "--verify", "-q", "HEAD"])?.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
// Unborn: HEAD names a branch with no ref at all.
|
||||
let branch = String::from_utf8(self.run(dir, &["symbolic-ref", "-q", "HEAD"])?)
|
||||
.map_err(|_| io::Error::other("HEAD names a ref that is not UTF-8"))?;
|
||||
// Only Git's line end goes; a name keeps every byte.
|
||||
let branch = branch.strip_suffix('\n').unwrap_or(&branch).to_owned();
|
||||
let found = self.command(dir, &["for-each-ref", "--format=%(refname)", &branch]).output()?;
|
||||
let unborn = branch.starts_with("refs/heads/") && found.status.success();
|
||||
if unborn && found.stdout.is_empty() && found.stderr.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut why = format!("HEAD names {branch}: no commit, and no branch yet to begin");
|
||||
let said = String::from_utf8_lossy(&found.stderr);
|
||||
if !said.trim().is_empty() {
|
||||
why = format!("{why}; Git says {}", said.trim());
|
||||
}
|
||||
Err(io::Error::other(why))
|
||||
}
|
||||
|
||||
/// Exit 1 is an answer: files differ, key unset.
|
||||
pub fn run_or_one(&self, dir: &Path, args: &[&str]) -> io::Result<Vec<u8>> {
|
||||
let output = self.command(dir, args).output()?;
|
||||
match output.status.code() {
|
||||
Some(1) => Ok(output.stdout),
|
||||
_ => checked(args, output),
|
||||
}
|
||||
}
|
||||
|
||||
/// `merge-file` exits with its conflict count; past 127 fails.
|
||||
pub fn run_counted(&self, dir: &Path, args: &[&str]) -> io::Result<Vec<u8>> {
|
||||
let output = self.command(dir, args).output()?;
|
||||
match output.status.code() {
|
||||
Some(0..=127) => Ok(output.stdout),
|
||||
_ => checked(args, output),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn command(&self, dir: &Path, args: &[&str]) -> Command {
|
||||
ely_platform::git::command(&self.path, dir, args)
|
||||
}
|
||||
}
|
||||
|
||||
fn checked(args: &[&str], output: Output) -> io::Result<Vec<u8>> {
|
||||
// The verb follows any `-c name=value` pairs.
|
||||
let verb = args
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|arg| !arg.starts_with('-') && !arg.contains('='))
|
||||
.unwrap_or("");
|
||||
match output.status.success() {
|
||||
true => {
|
||||
log::debug!("tree: git {verb} done");
|
||||
Ok(output.stdout)
|
||||
}
|
||||
false => {
|
||||
// Conflicts go to stdout, hooks to stderr.
|
||||
let said: Vec<String> = [&output.stdout, &output.stderr]
|
||||
.into_iter()
|
||||
.map(|bytes| String::from_utf8_lossy(bytes).trim().to_string())
|
||||
.filter(|said| !said.is_empty())
|
||||
.collect();
|
||||
log::warn!("tree: git {verb} failed, {}", output.status);
|
||||
Err(io::Error::other(format!(
|
||||
"git {verb} ({}):\n{}",
|
||||
output.status,
|
||||
said.join("\n")
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ely's own Git, whatever version it was pinned at.
|
||||
fn bundled_version(path: &Path) -> io::Result<String> {
|
||||
ely_platform::git::version_of(path).map(|(version, _)| version)
|
||||
}
|
||||
//! Git as a program: argument arrays, never a shell (§13.1).
|
||||
|
||||
use std::{
|
||||
io::{self, Write as _},
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Output, Stdio},
|
||||
};
|
||||
|
||||
/// A Git binary whose version was checked.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Git {
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
impl Git {
|
||||
/// `ELY_GIT` or the setting picks Git; else Ely's own.
|
||||
pub fn find() -> io::Result<Self> {
|
||||
let (path, bundled) = ely_platform::git::locate()?;
|
||||
if !path.is_file() {
|
||||
let hint = if bundled {
|
||||
"run scripts/build-git.sh"
|
||||
} else {
|
||||
"check ELY_GIT or the Git setting"
|
||||
};
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("no Git at {} ({hint})", path.display()),
|
||||
));
|
||||
}
|
||||
let version = match bundled {
|
||||
true => bundled_version(&path)?,
|
||||
false => ely_platform::git::system_version(&path)?,
|
||||
};
|
||||
log::info!(
|
||||
"tree: {version} at {} ({})",
|
||||
path.display(),
|
||||
if bundled { "bundled" } else { "system" }
|
||||
);
|
||||
Ok(Git { path })
|
||||
}
|
||||
|
||||
/// Runs Git in `dir`; failure carries Git's words.
|
||||
pub fn run(&self, dir: &Path, args: &[&str]) -> io::Result<Vec<u8>> {
|
||||
let output = self.command(dir, args).output()?;
|
||||
checked(args, output)
|
||||
}
|
||||
|
||||
/// Runs Git with `input` on its standard input.
|
||||
pub fn run_input(&self, dir: &Path, args: &[&str], input: &[u8]) -> io::Result<Vec<u8>> {
|
||||
let mut child = self
|
||||
.command(dir, args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| io::Error::other("no stdin"))?;
|
||||
let input = input.to_vec();
|
||||
// Writing apart from reading cannot fill both pipes.
|
||||
let writer = std::thread::spawn(move || stdin.write_all(&input));
|
||||
let output = child.wait_with_output()?;
|
||||
writer
|
||||
.join()
|
||||
.map_err(|_| io::Error::other("the stdin writer panicked"))??;
|
||||
checked(args, output)
|
||||
}
|
||||
|
||||
/// HEAD names a commit; a new repository has none yet.
|
||||
pub fn born(&self, dir: &Path) -> io::Result<bool> {
|
||||
if !self.run_or_one(dir, &["rev-parse", "--verify", "-q", "HEAD"])?.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
// Unborn: HEAD names a branch with no ref at all.
|
||||
let branch = String::from_utf8(self.run(dir, &["symbolic-ref", "-q", "HEAD"])?)
|
||||
.map_err(|_| io::Error::other("HEAD names a ref that is not UTF-8"))?;
|
||||
// Only Git's line end goes; a name keeps every byte.
|
||||
let branch = branch.strip_suffix('\n').unwrap_or(&branch).to_owned();
|
||||
let found = self.command(dir, &["for-each-ref", "--format=%(refname)", &branch]).output()?;
|
||||
let unborn = branch.starts_with("refs/heads/") && found.status.success();
|
||||
if unborn && found.stdout.is_empty() && found.stderr.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut why = format!("HEAD names {branch}: no commit, and no branch yet to begin");
|
||||
let said = String::from_utf8_lossy(&found.stderr);
|
||||
if !said.trim().is_empty() {
|
||||
why = format!("{why}; Git says {}", said.trim());
|
||||
}
|
||||
Err(io::Error::other(why))
|
||||
}
|
||||
|
||||
/// Exit 1 is an answer: files differ, key unset.
|
||||
pub fn run_or_one(&self, dir: &Path, args: &[&str]) -> io::Result<Vec<u8>> {
|
||||
let output = self.command(dir, args).output()?;
|
||||
match output.status.code() {
|
||||
Some(1) => Ok(output.stdout),
|
||||
_ => checked(args, output),
|
||||
}
|
||||
}
|
||||
|
||||
/// `merge-file` exits with its conflict count; past 127 fails.
|
||||
pub fn run_counted(&self, dir: &Path, args: &[&str]) -> io::Result<Vec<u8>> {
|
||||
let output = self.command(dir, args).output()?;
|
||||
match output.status.code() {
|
||||
Some(0..=127) => Ok(output.stdout),
|
||||
_ => checked(args, output),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn command(&self, dir: &Path, args: &[&str]) -> Command {
|
||||
ely_platform::git::command(&self.path, dir, args)
|
||||
}
|
||||
}
|
||||
|
||||
fn checked(args: &[&str], output: Output) -> io::Result<Vec<u8>> {
|
||||
// The verb follows any `-c name=value` pairs.
|
||||
let verb = args
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|arg| !arg.starts_with('-') && !arg.contains('='))
|
||||
.unwrap_or("");
|
||||
match output.status.success() {
|
||||
true => {
|
||||
log::debug!("tree: git {verb} done");
|
||||
Ok(output.stdout)
|
||||
}
|
||||
false => {
|
||||
// Conflicts go to stdout, hooks to stderr.
|
||||
let said: Vec<String> = [&output.stdout, &output.stderr]
|
||||
.into_iter()
|
||||
.map(|bytes| String::from_utf8_lossy(bytes).trim().to_string())
|
||||
.filter(|said| !said.is_empty())
|
||||
.collect();
|
||||
log::warn!("tree: git {verb} failed, {}", output.status);
|
||||
Err(io::Error::other(format!(
|
||||
"git {verb} ({}):\n{}",
|
||||
output.status,
|
||||
said.join("\n")
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ely's own Git, whatever version it was pinned at.
|
||||
fn bundled_version(path: &Path) -> io::Result<String> {
|
||||
ely_platform::git::version_of(path).map(|(version, _)| version)
|
||||
}
|
||||
@@ -36,9 +36,9 @@ pub fn open(git: &Git, path: &Path) -> io::Result<Repo> {
|
||||
let common = ["rev-parse", "--path-format=absolute", "--git-common-dir"];
|
||||
let common_dir = text(git.run(path, &common)?)?;
|
||||
let repo = Repo {
|
||||
root: std::fs::canonicalize(root)?,
|
||||
git_dir: std::fs::canonicalize(git_dir)?,
|
||||
common_dir: std::fs::canonicalize(common_dir)?,
|
||||
root: dunce::canonicalize(root)?,
|
||||
git_dir: dunce::canonicalize(git_dir)?,
|
||||
common_dir: dunce::canonicalize(common_dir)?,
|
||||
};
|
||||
log::info!("tree: opened {}", repo.root.display());
|
||||
Ok(repo)
|
||||
|
||||
@@ -66,7 +66,7 @@ pub fn worktrees(git: &Git, root: &Path) -> io::Result<Vec<Worktree>> {
|
||||
found.extend(now);
|
||||
// Real paths, as the open repository's root is.
|
||||
for tree in &mut found {
|
||||
match std::fs::canonicalize(&tree.path) {
|
||||
match dunce::canonicalize(&tree.path) {
|
||||
Ok(real) => tree.path = real,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error),
|
||||
|
||||
@@ -70,7 +70,7 @@ fn a_repository_opens_from_inside_and_a_bare_one_does_not() {
|
||||
let opened = repo::open(&git(), &root.join("deep")).expect("opens");
|
||||
assert_eq!(
|
||||
opened.root,
|
||||
std::fs::canonicalize(&root).expect("real"),
|
||||
dunce::canonicalize(&root).expect("real"),
|
||||
"the real top"
|
||||
);
|
||||
assert!(opened.git_dir.ends_with(".git"));
|
||||
@@ -324,7 +324,7 @@ fn a_gone_upstream_has_no_counts() {
|
||||
#[test]
|
||||
fn a_linked_file_keeps_its_own_name() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = std::fs::canonicalize(dir.path()).expect("real");
|
||||
let root = dunce::canonicalize(dir.path()).expect("real");
|
||||
std::fs::create_dir(root.join("docs")).expect("a folder");
|
||||
std::fs::write(root.join("real.md"), "a").expect("a file");
|
||||
let outside = tempfile::tempdir().expect("another dir");
|
||||
|
||||
@@ -252,10 +252,10 @@ fn worktrees_list_branches_and_prunable_folders() {
|
||||
.expect("adds");
|
||||
let list = structure::worktrees(&git(), &root).expect("reads");
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].path, std::fs::canonicalize(&root).expect("real"));
|
||||
assert_eq!(list[0].path, dunce::canonicalize(&root).expect("real"));
|
||||
assert_eq!(
|
||||
(list[1].path.clone(), list[1].branch.as_deref()),
|
||||
(std::fs::canonicalize(&other).expect("real"), Some("side"))
|
||||
(dunce::canonicalize(&other).expect("real"), Some("side"))
|
||||
);
|
||||
std::fs::remove_dir_all(&other).expect("removes");
|
||||
let list = structure::worktrees(&git(), &root).expect("reads");
|
||||
@@ -497,5 +497,5 @@ fn a_worktree_registered_through_a_link_lists_its_real_path() {
|
||||
)
|
||||
.expect("writes");
|
||||
let list = structure::worktrees(&git(), &root).expect("reads");
|
||||
assert_eq!(list[1].path, std::fs::canonicalize(&other).expect("real"));
|
||||
assert_eq!(list[1].path, dunce::canonicalize(&other).expect("real"));
|
||||
}
|
||||
@@ -101,7 +101,7 @@ fn one_tab_per_repository_and_reopening_selects_it() {
|
||||
let recent = cx.update(|_, app| tree.read(app).recent.clone());
|
||||
assert_eq!(
|
||||
recent[0],
|
||||
std::fs::canonicalize(&first).expect("real"),
|
||||
dunce::canonicalize(&first).expect("real"),
|
||||
"newest first"
|
||||
);
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.close_active(cx)));
|
||||
|
||||
@@ -156,7 +156,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
|
||||
/// `file` as Git names it; a symlink stays itself.
|
||||
pub(crate) fn inside(root: &Path, file: &Path) -> io::Result<String> {
|
||||
let name = file.file_name().ok_or_else(|| io::Error::other("a path without a name"))?;
|
||||
let dir = std::fs::canonicalize(file.parent().unwrap_or(Path::new(".")))?;
|
||||
let dir = dunce::canonicalize(file.parent().unwrap_or(Path::new(".")))?;
|
||||
let rel = dir.join(name);
|
||||
let rel = rel.strip_prefix(root).map_err(io::Error::other)?;
|
||||
rel.to_str().map(str::to_owned).ok_or_else(|| io::Error::other("a path that is not UTF-8"))
|
||||
|
||||
+268
-283
@@ -1,283 +1,268 @@
|
||||
//! What the window draws.
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
feedback::{EmptyState, ErrorView},
|
||||
lists::{List, ListItem},
|
||||
navigation::{EditorTab, EditorTabs},
|
||||
primitives::IconName,
|
||||
shell::{StatusBar, StatusBarItem},
|
||||
};
|
||||
use ely_platform::AppId;
|
||||
use ely_ui::{Shell, commands};
|
||||
use gpui::{
|
||||
AnyElement, App, Context, InteractiveElement as _, IntoElement, ParentElement as _, Render,
|
||||
SharedString, Styled as _, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
menu,
|
||||
status::{Head, State},
|
||||
tree::{Side, Tree, text},
|
||||
};
|
||||
|
||||
fn name(path: &std::path::Path) -> String {
|
||||
path.file_name().map_or_else(
|
||||
|| path.display().to_string(),
|
||||
|name| name.to_string_lossy().into_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Where HEAD is, in words.
|
||||
pub fn branch(head: &Head, cx: &App) -> SharedString {
|
||||
match (&head.branch, &head.oid) {
|
||||
(Some(branch), Some(_)) => branch.clone().into(),
|
||||
(Some(branch), None) => text(cx, "tree.branch.unborn", &[("branch", branch)]),
|
||||
(None, oid) => {
|
||||
let short = oid.as_deref().map_or("", |oid| &oid[..oid.len().min(7)]);
|
||||
text(cx, "tree.branch.detached", &[("oid", short)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Tree {
|
||||
fn tab_strip(&self, cx: &mut Context<Self>) -> Option<EditorTabs> {
|
||||
if self.repos.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let tabs = self
|
||||
.repos
|
||||
.iter()
|
||||
.map(|opened| EditorTab::new(opened.id.to_string(), name(&opened.repo.root)));
|
||||
let (select, close) = (cx.weak_entity(), cx.weak_entity());
|
||||
let strip = EditorTabs::new("tree-tabs", tabs.collect::<Vec<_>>())
|
||||
.on_select(move |id, _, cx| {
|
||||
let id = id.parse().expect("tab ids are numbers");
|
||||
select
|
||||
.update(cx, |tree, cx| tree.activate(Some(id), cx))
|
||||
.ok();
|
||||
})
|
||||
.on_close(move |id, _, cx| {
|
||||
let id = id.parse().expect("tab ids are numbers");
|
||||
close.update(cx, |tree, cx| tree.close(id, cx)).ok();
|
||||
});
|
||||
Some(match self.active {
|
||||
Some(id) => strip.selected(id.to_string()),
|
||||
None => strip,
|
||||
})
|
||||
}
|
||||
|
||||
fn status_bar(&self, cx: &mut Context<Self>) -> StatusBar {
|
||||
let mut bar = StatusBar::new();
|
||||
if let Some(opened) = self.active() {
|
||||
if let Some(Ok(status)) = &opened.status {
|
||||
let head = &status.head;
|
||||
bar = bar.left(
|
||||
StatusBarItem::new("tree-branch")
|
||||
.icon(IconName::GitBranch)
|
||||
.label(branch(head, cx)),
|
||||
);
|
||||
if let Some(upstream) = &head.upstream {
|
||||
let words = match head.counts {
|
||||
Some((ahead, behind)) => text(
|
||||
cx,
|
||||
"tree.ahead_behind",
|
||||
&[
|
||||
("upstream", upstream),
|
||||
("ahead", &ahead.to_string()),
|
||||
("behind", &behind.to_string()),
|
||||
],
|
||||
),
|
||||
None => text(cx, "tree.upstream_gone", &[("upstream", upstream)]),
|
||||
};
|
||||
bar = bar.left(StatusBarItem::new("tree-upstream").label(words));
|
||||
}
|
||||
}
|
||||
bar = bar.left(
|
||||
StatusBarItem::new("tree-path").label(opened.repo.root.display().to_string()),
|
||||
);
|
||||
}
|
||||
if let Ok(git) = &self.git {
|
||||
let key = if git.bundled {
|
||||
"tree.git.bundled"
|
||||
} else {
|
||||
"tree.git.system"
|
||||
};
|
||||
bar = bar.right(StatusBarItem::new("tree-git").label(text(
|
||||
cx,
|
||||
key,
|
||||
&[
|
||||
("version", &git.version),
|
||||
("path", &git.path.display().to_string()),
|
||||
],
|
||||
)));
|
||||
}
|
||||
bar
|
||||
}
|
||||
|
||||
fn start_page(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let this = cx.weak_entity();
|
||||
let button = |id: &'static str, key: &str, run: fn(&mut Window, &mut App)| {
|
||||
Button::new(id, text(cx, key, &[])).on_click(move |_, window, cx| run(window, cx))
|
||||
};
|
||||
let empty = EmptyState::new(
|
||||
"tree-start",
|
||||
IconName::GitBranch,
|
||||
text(cx, "tree.empty", &[]),
|
||||
)
|
||||
.action(
|
||||
button("tree-open", "tree.open", |window, cx| {
|
||||
window.dispatch_action(Box::new(commands::Open), cx)
|
||||
})
|
||||
.variant(ButtonVariant::Primary),
|
||||
)
|
||||
.action(button("tree-new", "tree.new", |window, cx| {
|
||||
window.dispatch_action(Box::new(commands::New), cx)
|
||||
}))
|
||||
.action(button("tree-clone", "tree.clone", |window, cx| {
|
||||
window.dispatch_action(Box::new(menu::CloneRepository), cx)
|
||||
}));
|
||||
let recent = self.recent.iter().enumerate().map(|(at, path)| {
|
||||
let (this, path) = (this.clone(), path.clone());
|
||||
ListItem::new(("tree-recent", at), name(&path))
|
||||
.description(path.display().to_string())
|
||||
.on_click(move |_, _, cx| {
|
||||
this.update(cx, |tree, cx| tree.open_path(path.clone(), cx))
|
||||
.ok();
|
||||
})
|
||||
.into_any_element()
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.child(empty)
|
||||
.child(div().w_96().child(List::new().children(recent)))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn body(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if let Err(error) = &self.git {
|
||||
return ErrorView::new(
|
||||
"tree-no-git",
|
||||
IconName::TriangleAlert,
|
||||
text(cx, "tree.no_git", &[]),
|
||||
)
|
||||
.detail(error.clone())
|
||||
.into_any_element();
|
||||
}
|
||||
if let Some(cloning) = &self.cloning {
|
||||
let this = cx.weak_entity();
|
||||
let title = text(cx, "tree.cloning", &[("name", &name(&cloning.into))]);
|
||||
let state = EmptyState::new("tree-cloning", IconName::Download, title)
|
||||
.body(cloning.said.clone());
|
||||
if !crate::running::STOPS {
|
||||
return state.into_any_element();
|
||||
}
|
||||
let stop = Button::new("tree-clone-stop", text(cx, "tree.clone.stop", &[]))
|
||||
.disabled(cloning.stopping)
|
||||
.on_click(move |_, _, cx| {
|
||||
this.update(cx, |tree, cx| tree.stop_clone(cx)).ok();
|
||||
});
|
||||
return state.action(stop).into_any_element();
|
||||
}
|
||||
let Some(opened) = self.active() else {
|
||||
return self.start_page(cx);
|
||||
};
|
||||
if let Some(look) = self.look.as_ref().filter(|look| look.repo() == opened.id) {
|
||||
return self.look_pane(look, cx);
|
||||
}
|
||||
match self.side {
|
||||
Side::History => return self.commit_pane(&opened.hist, cx),
|
||||
Side::Branches => return self.refs_pane(cx),
|
||||
Side::Places => return self.places_pane(cx),
|
||||
Side::Changes => {}
|
||||
}
|
||||
if let Some(shown) = self.shown.as_ref().filter(|shown| shown.repo == opened.id) {
|
||||
return self.pane(shown, cx);
|
||||
}
|
||||
match &opened.status {
|
||||
None => div().into_any_element(),
|
||||
Some(Err(error)) => ErrorView::new(
|
||||
"tree-unread",
|
||||
IconName::TriangleAlert,
|
||||
text(cx, "tree.unreadable", &[]),
|
||||
)
|
||||
.detail(error.clone())
|
||||
.into_any_element(),
|
||||
Some(Ok(status)) => {
|
||||
let changed = status
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| entry.state != State::Ignored)
|
||||
.count();
|
||||
let (icon, title) = summary(changed, cx);
|
||||
EmptyState::new(("tree-summary", opened.id), icon, title).into_any_element()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Tree {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let title: SharedString = self.active().map_or_else(
|
||||
|| AppId::Tree.name().into(),
|
||||
|opened| name(&opened.repo.root).into(),
|
||||
);
|
||||
let left = self
|
||||
.active()
|
||||
.filter(|opened| matches!(opened.status, Some(Ok(_))))
|
||||
.map(|_| self.changes.clone().into());
|
||||
let mut shell = Shell::new(title, &self.focus)
|
||||
.left(left)
|
||||
.status(self.status_bar(cx))
|
||||
.children(self.net_alert(cx))
|
||||
.children(self.op_alert(cx))
|
||||
.child(self.body(cx));
|
||||
if let Some(strip) = self.tab_strip(cx) {
|
||||
shell = shell.tabs(strip);
|
||||
}
|
||||
div()
|
||||
.size_full()
|
||||
.key_context(menu::CONTEXT)
|
||||
.track_focus(&self.focus)
|
||||
.on_action(cx.listener(Tree::on_open))
|
||||
.on_action(cx.listener(Tree::on_new))
|
||||
.on_action(cx.listener(Tree::on_clone))
|
||||
.on_action(cx.listener(Tree::on_close))
|
||||
.on_action(cx.listener(|tree, _: &menu::Fetch, _, cx| tree.fetch_all(cx)))
|
||||
.on_action(cx.listener(|tree, _: &menu::Pull, _, cx| tree.pull(cx)))
|
||||
.on_action(cx.listener(|tree, _: &menu::Push, _, cx| tree.push(cx)))
|
||||
.on_action(cx.listener(|tree, _: &menu::ForcePush, _, cx| tree.ask_force(cx)))
|
||||
.on_action(cx.listener(|tree, _: &menu::OpenTerminal, _, cx| tree.open_terminal(cx)))
|
||||
.on_action(cx.listener(|tree, _: &menu::Reveal, _, cx| tree.reveal(cx)))
|
||||
.on_action(cx.listener(|tree, _: &menu::CleanUntracked, _, cx| tree.ask_clean(cx)))
|
||||
.on_action(
|
||||
cx.listener(|tree, _: &menu::ChooseEditor, _, cx| tree.pick_editor(None, cx)),
|
||||
)
|
||||
.child(shell)
|
||||
.children(self.dialog(cx))
|
||||
}
|
||||
}
|
||||
|
||||
/// The work tree's line: clean, or how many files changed.
|
||||
pub(crate) fn summary(changed: usize, cx: &App) -> (IconName, SharedString) {
|
||||
match changed {
|
||||
0 => (IconName::CircleCheck, text(cx, "tree.clean", &[])),
|
||||
n => (IconName::GitBranch, ely_ui::i18n::counted(cx, "tree.changed", n as u64, &[("n", &n.to_string())])),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[gpui::test]
|
||||
fn the_summary_counts_files_in_both_languages(cx: &mut gpui::TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
ely_ui::i18n::install_as("en-US", cx);
|
||||
let said = |n: usize, cx: &gpui::App| super::summary(n, cx).1.to_string();
|
||||
assert_eq!([said(0, cx), said(1, cx), said(2, cx)], ["Nothing to commit", "1 file changed", "2 files changed"]);
|
||||
ely_ui::i18n::install_as("zh-CN", cx);
|
||||
assert_eq!([said(1, cx), said(2, cx)], ["1 个文件有改动", "2 个文件有改动"]);
|
||||
});
|
||||
}
|
||||
}
|
||||
//! What the window draws.
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
feedback::{EmptyState, ErrorView},
|
||||
lists::{List, ListItem},
|
||||
navigation::{EditorTab, EditorTabs},
|
||||
primitives::IconName,
|
||||
shell::{StatusBar, StatusBarItem},
|
||||
};
|
||||
use ely_platform::AppId;
|
||||
use ely_ui::{Shell, commands};
|
||||
use gpui::{
|
||||
AnyElement, App, Context, InteractiveElement as _, IntoElement, ParentElement as _, Render,
|
||||
SharedString, Styled as _, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
menu,
|
||||
status::{Head, State},
|
||||
tree::{Side, Tree, text},
|
||||
};
|
||||
|
||||
fn name(path: &std::path::Path) -> String {
|
||||
path.file_name().map_or_else(
|
||||
|| path.display().to_string(),
|
||||
|name| name.to_string_lossy().into_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Where HEAD is, in words.
|
||||
pub fn branch(head: &Head, cx: &App) -> SharedString {
|
||||
match (&head.branch, &head.oid) {
|
||||
(Some(branch), Some(_)) => branch.clone().into(),
|
||||
(Some(branch), None) => text(cx, "tree.branch.unborn", &[("branch", branch)]),
|
||||
(None, oid) => {
|
||||
let short = oid.as_deref().map_or("", |oid| &oid[..oid.len().min(7)]);
|
||||
text(cx, "tree.branch.detached", &[("oid", short)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Tree {
|
||||
fn tab_strip(&self, cx: &mut Context<Self>) -> Option<EditorTabs> {
|
||||
if self.repos.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let tabs = self
|
||||
.repos
|
||||
.iter()
|
||||
.map(|opened| EditorTab::new(opened.id.to_string(), name(&opened.repo.root)));
|
||||
let (select, close) = (cx.weak_entity(), cx.weak_entity());
|
||||
let strip = EditorTabs::new("tree-tabs", tabs.collect::<Vec<_>>())
|
||||
.on_select(move |id, _, cx| {
|
||||
let id = id.parse().expect("tab ids are numbers");
|
||||
select
|
||||
.update(cx, |tree, cx| tree.activate(Some(id), cx))
|
||||
.ok();
|
||||
})
|
||||
.on_close(move |id, _, cx| {
|
||||
let id = id.parse().expect("tab ids are numbers");
|
||||
close.update(cx, |tree, cx| tree.close(id, cx)).ok();
|
||||
});
|
||||
Some(match self.active {
|
||||
Some(id) => strip.selected(id.to_string()),
|
||||
None => strip,
|
||||
})
|
||||
}
|
||||
|
||||
fn status_bar(&self, cx: &mut Context<Self>) -> StatusBar {
|
||||
let mut bar = StatusBar::new();
|
||||
if let Some(opened) = self.active() {
|
||||
if let Some(Ok(status)) = &opened.status {
|
||||
let head = &status.head;
|
||||
bar = bar.left(
|
||||
StatusBarItem::new("tree-branch")
|
||||
.icon(IconName::GitBranch)
|
||||
.label(branch(head, cx)),
|
||||
);
|
||||
if let Some(upstream) = &head.upstream {
|
||||
let words = match head.counts {
|
||||
Some((ahead, behind)) => text(
|
||||
cx,
|
||||
"tree.ahead_behind",
|
||||
&[
|
||||
("upstream", upstream),
|
||||
("ahead", &ahead.to_string()),
|
||||
("behind", &behind.to_string()),
|
||||
],
|
||||
),
|
||||
None => text(cx, "tree.upstream_gone", &[("upstream", upstream)]),
|
||||
};
|
||||
bar = bar.left(StatusBarItem::new("tree-upstream").label(words));
|
||||
}
|
||||
}
|
||||
bar = bar.left(
|
||||
StatusBarItem::new("tree-path").label(opened.repo.root.display().to_string()),
|
||||
);
|
||||
}
|
||||
bar
|
||||
}
|
||||
|
||||
fn start_page(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let this = cx.weak_entity();
|
||||
let button = |id: &'static str, key: &str, run: fn(&mut Window, &mut App)| {
|
||||
Button::new(id, text(cx, key, &[])).on_click(move |_, window, cx| run(window, cx))
|
||||
};
|
||||
let empty = EmptyState::new(
|
||||
"tree-start",
|
||||
IconName::GitBranch,
|
||||
text(cx, "tree.empty", &[]),
|
||||
)
|
||||
.action(
|
||||
button("tree-open", "tree.open", |window, cx| {
|
||||
window.dispatch_action(Box::new(commands::Open), cx)
|
||||
})
|
||||
.variant(ButtonVariant::Primary),
|
||||
)
|
||||
.action(button("tree-new", "tree.new", |window, cx| {
|
||||
window.dispatch_action(Box::new(commands::New), cx)
|
||||
}))
|
||||
.action(button("tree-clone", "tree.clone", |window, cx| {
|
||||
window.dispatch_action(Box::new(menu::CloneRepository), cx)
|
||||
}));
|
||||
let recent = self.recent.iter().enumerate().map(|(at, path)| {
|
||||
let (this, path) = (this.clone(), path.clone());
|
||||
ListItem::new(("tree-recent", at), name(&path))
|
||||
.description(path.display().to_string())
|
||||
.on_click(move |_, _, cx| {
|
||||
this.update(cx, |tree, cx| tree.open_path(path.clone(), cx))
|
||||
.ok();
|
||||
})
|
||||
.into_any_element()
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.child(empty)
|
||||
.child(div().w_96().child(List::new().children(recent)))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn body(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if let Err(error) = &self.git {
|
||||
return ErrorView::new(
|
||||
"tree-no-git",
|
||||
IconName::TriangleAlert,
|
||||
text(cx, "tree.no_git", &[]),
|
||||
)
|
||||
.detail(error.clone())
|
||||
.into_any_element();
|
||||
}
|
||||
if let Some(cloning) = &self.cloning {
|
||||
let this = cx.weak_entity();
|
||||
let title = text(cx, "tree.cloning", &[("name", &name(&cloning.into))]);
|
||||
let state = EmptyState::new("tree-cloning", IconName::Download, title)
|
||||
.body(cloning.said.clone());
|
||||
if !crate::running::STOPS {
|
||||
return state.into_any_element();
|
||||
}
|
||||
let stop = Button::new("tree-clone-stop", text(cx, "tree.clone.stop", &[]))
|
||||
.disabled(cloning.stopping)
|
||||
.on_click(move |_, _, cx| {
|
||||
this.update(cx, |tree, cx| tree.stop_clone(cx)).ok();
|
||||
});
|
||||
return state.action(stop).into_any_element();
|
||||
}
|
||||
let Some(opened) = self.active() else {
|
||||
return self.start_page(cx);
|
||||
};
|
||||
if let Some(look) = self.look.as_ref().filter(|look| look.repo() == opened.id) {
|
||||
return self.look_pane(look, cx);
|
||||
}
|
||||
match self.side {
|
||||
Side::History => return self.commit_pane(&opened.hist, cx),
|
||||
Side::Branches => return self.refs_pane(cx),
|
||||
Side::Places => return self.places_pane(cx),
|
||||
Side::Changes => {}
|
||||
}
|
||||
if let Some(shown) = self.shown.as_ref().filter(|shown| shown.repo == opened.id) {
|
||||
return self.pane(shown, cx);
|
||||
}
|
||||
match &opened.status {
|
||||
None => div().into_any_element(),
|
||||
Some(Err(error)) => ErrorView::new(
|
||||
"tree-unread",
|
||||
IconName::TriangleAlert,
|
||||
text(cx, "tree.unreadable", &[]),
|
||||
)
|
||||
.detail(error.clone())
|
||||
.into_any_element(),
|
||||
Some(Ok(status)) => {
|
||||
let changed = status
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| entry.state != State::Ignored)
|
||||
.count();
|
||||
let (icon, title) = summary(changed, cx);
|
||||
EmptyState::new(("tree-summary", opened.id), icon, title).into_any_element()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Tree {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let title: SharedString = self.active().map_or_else(
|
||||
|| AppId::Tree.name().into(),
|
||||
|opened| name(&opened.repo.root).into(),
|
||||
);
|
||||
let left = self
|
||||
.active()
|
||||
.filter(|opened| matches!(opened.status, Some(Ok(_))))
|
||||
.map(|_| self.changes.clone().into());
|
||||
let mut shell = Shell::new(title, &self.focus)
|
||||
.left(left)
|
||||
.status(self.status_bar(cx))
|
||||
.children(self.net_alert(cx))
|
||||
.children(self.op_alert(cx))
|
||||
.child(self.body(cx));
|
||||
if let Some(strip) = self.tab_strip(cx) {
|
||||
shell = shell.tabs(strip);
|
||||
}
|
||||
div()
|
||||
.size_full()
|
||||
.key_context(menu::CONTEXT)
|
||||
.track_focus(&self.focus)
|
||||
.on_action(cx.listener(Tree::on_open))
|
||||
.on_action(cx.listener(Tree::on_new))
|
||||
.on_action(cx.listener(Tree::on_clone))
|
||||
.on_action(cx.listener(Tree::on_close))
|
||||
.on_action(cx.listener(|tree, _: &menu::Fetch, _, cx| tree.fetch_all(cx)))
|
||||
.on_action(cx.listener(|tree, _: &menu::Pull, _, cx| tree.pull(cx)))
|
||||
.on_action(cx.listener(|tree, _: &menu::Push, _, cx| tree.push(cx)))
|
||||
.on_action(cx.listener(|tree, _: &menu::ForcePush, _, cx| tree.ask_force(cx)))
|
||||
.on_action(cx.listener(|tree, _: &menu::OpenTerminal, _, cx| tree.open_terminal(cx)))
|
||||
.on_action(cx.listener(|tree, _: &menu::Reveal, _, cx| tree.reveal(cx)))
|
||||
.on_action(cx.listener(|tree, _: &menu::CleanUntracked, _, cx| tree.ask_clean(cx)))
|
||||
.on_action(
|
||||
cx.listener(|tree, _: &menu::ChooseEditor, _, cx| tree.pick_editor(None, cx)),
|
||||
)
|
||||
.child(shell)
|
||||
.children(self.dialog(cx))
|
||||
}
|
||||
}
|
||||
|
||||
/// The work tree's line: clean, or how many files changed.
|
||||
pub(crate) fn summary(changed: usize, cx: &App) -> (IconName, SharedString) {
|
||||
match changed {
|
||||
0 => (IconName::CircleCheck, text(cx, "tree.clean", &[])),
|
||||
n => (IconName::GitBranch, ely_ui::i18n::counted(cx, "tree.changed", n as u64, &[("n", &n.to_string())])),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[gpui::test]
|
||||
fn the_summary_counts_files_in_both_languages(cx: &mut gpui::TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
ely_ui::i18n::install_as("en-US", cx);
|
||||
let said = |n: usize, cx: &gpui::App| super::summary(n, cx).1.to_string();
|
||||
assert_eq!([said(0, cx), said(1, cx), said(2, cx)], ["Nothing to commit", "1 file changed", "2 files changed"]);
|
||||
ely_ui::i18n::install_as("zh-CN", cx);
|
||||
assert_eq!([said(1, cx), said(2, cx)], ["1 个文件有改动", "2 个文件有改动"]);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user