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.
This commit is contained in:
817 files changed
+143101
No files matched your search
@@ -0,0 +1,189 @@
|
||||
//! 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");
|
||||
let repo = dir.path().join("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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user