//! 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 { ely_platform::git::command(git, dir, args).output() } fn status(file: &Path) -> io::Result { 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) { 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) -> Option { 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)); } }