Files
ElyOffice/apps/ely-mark/src/vcs.rs
T

238 lines
8.8 KiB
Rust

//! Whether Git sees the file changed; ElyTree does the rest.
use std::{
io,
path::Path,
};
use ely_gpui_component::{primitives::Severity, shell::StatusBarItem};
use ely_office::prelude::*;
use ely_platform::AppId;
use ely_ui::i18n::text;
use gpui::Context;
use crate::desk::Desk;
/// What Git says of one file.
#[derive(Clone, Debug, PartialEq)]
pub enum Tracked {
/// Not in a repository: nothing to show.
Outside,
Unchanged,
Changed,
Untracked,
Ignored,
Conflicted,
/// Git could not answer; why.
Unknown(String),
}
/// Git's word on `file`: in a repository, then its status.
pub fn ask(file: &Path) -> Tracked {
match status(file) {
Ok(tracked) => tracked,
Err(error) => Tracked::Unknown(error.to_string()),
}
}
fn run(git: &Path, dir: &Path, args: &[&str]) -> io::Result<std::process::Output> {
ely_platform::git::command(git, dir, args).output()
}
fn status(file: &Path) -> io::Result<Tracked> {
let (git, _) = ely_platform::git::locate()?;
let dir = file
.parent()
.ok_or_else(|| io::Error::other("a file without a folder"))?;
let top = run(&git, dir, &["rev-parse", "--show-toplevel"])?;
let said = String::from_utf8_lossy(&top.stderr);
// Not a repository is an answer; other failures are errors.
if !top.status.success() {
return match said.contains("not a git repository") {
true => Ok(Tracked::Outside),
false => Err(io::Error::other(said.trim().to_owned())),
};
}
let name = file
.file_name()
.ok_or_else(|| io::Error::other("a file without a name"))?;
let name = name.to_string_lossy();
let said = run(
&git,
dir,
&["status", "--porcelain=v2", "-z", "--ignored", "--", &name],
)?;
if !said.status.success() {
return Err(io::Error::other(
String::from_utf8_lossy(&said.stderr).trim().to_owned(),
));
}
Ok(match said.stdout.first() {
None => Tracked::Unchanged,
Some(b'1' | b'2') => Tracked::Changed,
Some(b'u') => Tracked::Conflicted,
Some(b'?') => Tracked::Untracked,
Some(b'!') => Tracked::Ignored,
Some(other) => {
return Err(io::Error::other(format!(
"git status said {:?}",
*other as char
)));
}
})
}
impl Desk {
/// Asks Git about the shown file, off the main thread.
pub fn ask_git(&mut self, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
return;
};
let Some(path) = tab.session.path().map(Path::to_path_buf) else {
self.tracked = None;
return;
};
let id = tab.id;
cx.spawn(async move |desk, cx| {
let asked = path.clone();
let tracked = cx
.background_executor()
.spawn(async move { ask(&asked) })
.await;
desk.update(cx, |desk, cx| {
// Only the shown file's answer counts.
let shown = desk.active_tab().filter(|tab| tab.id == id);
if shown.and_then(|tab| tab.session.path()) == Some(&path) {
log::info!("mark: git says {tracked:?}");
desk.tracked = Some(tracked);
cx.notify();
}
})
.ok();
})
.detach();
}
/// The Git status item; pressing shows the file in ElyTree.
pub fn git_item(&self, cx: &mut Context<Self>) -> Option<StatusBarItem> {
let key = match self.tracked.as_ref()? {
Tracked::Outside => return None,
Tracked::Unchanged => "mark.git.unchanged",
Tracked::Changed => "mark.git.changed",
Tracked::Untracked => "mark.git.untracked",
Tracked::Ignored => "mark.git.ignored",
Tracked::Conflicted => "mark.git.conflicted",
Tracked::Unknown(why) => {
let label = text(cx, "mark.git.unknown", &[]);
return Some(
StatusBarItem::new("mark-git")
.label(label)
.tooltip(why.clone()),
);
}
};
// ElyTree takes the file: its repository and its history (§16).
let file = self.active_tab()?.session.path()?.to_path_buf();
let item = StatusBarItem::new("mark-git")
.label(text(cx, key, &[]))
.tooltip(text(cx, "mark.git.open", &[]));
let this = cx.entity();
Some(item.on_click(move |_, _, cx| {
if !ely_platform::apps::installed(AppId::Tree) {
let said = text(cx, "mark.git.missing", &[]);
this.update(cx, |desk, cx| desk.toast(said, Severity::Warning, cx));
return;
}
let (this, file) = (this.clone(), file.clone());
cx.spawn(async move |cx| {
let opened = cx.background_executor().spawn(async move { ely_platform::apps::open(AppId::Tree, &[file]) }).await;
if let Err(error) = opened {
log::error!("mark: ElyTree did not open: {error}");
cx.update(|cx| {
let said = text(cx, "mark.git.failed", &[("error", &error.to_string())]);
this.update(cx, |desk, cx| desk.toast(said, Severity::Danger, cx));
});
}
})
.detach();
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn git(dir: &Path, args: &[&str]) {
let (git, _) = ely_platform::git::locate().expect("a Git");
let done = std::process::Command::new(git)
.args(args)
.current_dir(dir)
.output();
assert!(done.expect("git runs").status.success(), "git {args:?}");
}
#[test]
fn spaces_stay_and_broken_repositories_say_why() {
let dir = tempfile::tempdir().expect("a temp dir");
// Windows names end in no space.
let repo = dir.path().join(if cfg!(windows) { "re po" } else { "repo " });
std::fs::create_dir(&repo).expect("the folder");
git(&repo, &["init", "-q"]);
std::fs::write(repo.join("t.md"), "a").expect("a file");
assert_eq!(ask(&repo.join("t.md")), Tracked::Untracked);
std::fs::write(repo.join(".git/config"), "[bad\n").expect("a broken config");
assert!(matches!(ask(&repo.join("t.md")), Tracked::Unknown(_)));
let outside = tempfile::tempdir().expect("a temp dir");
std::fs::write(outside.path().join("t.md"), "a").expect("a file");
assert_eq!(ask(&outside.path().join("t.md")), Tracked::Outside);
}
#[test]
fn committed_changed_and_ignored_files_say_so() {
let dir = tempfile::tempdir().expect("a temp dir");
let repo = dir.path();
git(repo, &["init", "-q"]);
std::fs::write(repo.join(".gitignore"), "skip.md
").expect("ignores");
std::fs::write(repo.join("t.md"), "a").expect("a file");
git(repo, &["add", "."]);
git(repo, &["-c", "user.name=T", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", "commit", "-qm", "one"]);
assert_eq!(ask(&repo.join("t.md")), Tracked::Unchanged);
std::fs::write(repo.join("t.md"), "b").expect("an edit");
assert_eq!(ask(&repo.join("t.md")), Tracked::Changed);
std::fs::write(repo.join("skip.md"), "c").expect("a file");
assert_eq!(ask(&repo.join("skip.md")), Tracked::Ignored);
}
#[gpui::test]
fn the_status_item_follows_a_save(cx: &mut gpui::TestAppContext) {
use ely_office::prelude::*;
let (data, dir) = (tempfile::tempdir().expect("data"), tempfile::tempdir().expect("repo"));
let repo = dir.path();
git(repo, &["init", "-q"]);
std::fs::write(repo.join("t.md"), "a
").expect("a file");
git(repo, &["add", "."]);
git(repo, &["-c", "user.name=T", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", "commit", "-qm", "one"]);
let (desk, cx) = crate::desk::tests::start(cx, data.path());
crate::desk::tests::open(&desk, &repo.join("t.md"), cx);
let said = |cx: &mut gpui::VisualTestContext| desk.read_with(cx, |desk, _| desk.tracked.clone());
assert_eq!(said(cx), Some(Tracked::Unchanged));
let (id, view) = desk.read_with(cx, |desk, _| {
let tab = desk.active_tab().expect("a tab");
(tab.id, tab.view.clone())
});
view.update(cx, |view, cx| view.adopt("b
".into(), cx));
cx.executor().advance_clock(std::time::Duration::from_secs(1));
cx.run_until_parked();
desk.update_in(cx, |desk, window, cx| desk.save(id, None, window, cx));
cx.run_until_parked();
assert_eq!(std::fs::read_to_string(repo.join("t.md")).expect("saved"), "b
");
assert_eq!(said(cx), Some(Tracked::Changed));
}
}