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,490 @@
|
||||
//! The window: one tab per repository (TREE-01).
|
||||
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use ely_gpui_component::{
|
||||
forms::{InputEvent, TextInput},
|
||||
primitives::Severity,
|
||||
};
|
||||
use ely_platform::AppId;
|
||||
use ely_ui::Root;
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, Entity, FocusHandle, SharedString, Subscription, WeakEntity,
|
||||
Window,
|
||||
};
|
||||
use notify::RecommendedWatcher;
|
||||
|
||||
use crate::{
|
||||
changes::Changes,
|
||||
commit::Me,
|
||||
commits::Find,
|
||||
git::Git,
|
||||
hist::Hist,
|
||||
look::Look,
|
||||
repo::{self, Repo},
|
||||
status::{self, Status},
|
||||
work::Write,
|
||||
};
|
||||
|
||||
/// An open repository and what was last read of it.
|
||||
pub struct Opened {
|
||||
pub id: u64,
|
||||
pub repo: Repo,
|
||||
pub status: Option<Result<Status, String>>,
|
||||
/// A read runs; `again` asks one more.
|
||||
pub reading: bool,
|
||||
pub again: bool,
|
||||
/// Writes wait here and run one at a time (§13.3).
|
||||
pub writes: VecDeque<Write>,
|
||||
pub writing: bool,
|
||||
pub hist: Hist,
|
||||
pub refs: Option<Result<Vec<crate::branches::Ref>, String>>,
|
||||
pub refs_asks: u64,
|
||||
/// A merge, rebase, pick or revert under way.
|
||||
pub doing: Option<Result<Option<crate::ops::Doing>, String>>,
|
||||
pub doing_asks: u64,
|
||||
/// This repository's commit box while another is shown.
|
||||
pub held: crate::committing::Held,
|
||||
/// Who commits here, and whether Git signs.
|
||||
pub me: Option<Result<Me, String>>,
|
||||
/// Identity reads started; only the latest lands.
|
||||
pub me_asks: u64,
|
||||
pub places: crate::places::Places,
|
||||
pub net: Option<crate::network::Net>,
|
||||
/// Kept alive; dropping it stops the watch.
|
||||
_watcher: Option<RecommendedWatcher>,
|
||||
}
|
||||
|
||||
/// What the left panel lists.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Side {
|
||||
Changes,
|
||||
History,
|
||||
Branches,
|
||||
Places,
|
||||
}
|
||||
|
||||
pub struct Tree {
|
||||
pub git: Result<Git, String>,
|
||||
pub repos: Vec<Opened>,
|
||||
pub active: Option<u64>,
|
||||
next: u64,
|
||||
pub recent: Vec<PathBuf>,
|
||||
/// Why the recent list could not be read; shown once.
|
||||
unread: Option<String>,
|
||||
recent_file: PathBuf,
|
||||
pub tools: crate::tools::Tools,
|
||||
pub cloning: Option<crate::cloning::Cloning>,
|
||||
pub ask: Option<crate::asks::Ask>,
|
||||
pub focus: FocusHandle,
|
||||
pub root: WeakEntity<Root>,
|
||||
pub changes: Entity<Changes>,
|
||||
pub shown: Option<crate::pick::Shown>,
|
||||
pub space: crate::diff::Space,
|
||||
pub layout: ely_gpui_component::git::DiffLayout,
|
||||
/// Diff reads started, numbering each.
|
||||
pub reads: u64,
|
||||
pub side: Side,
|
||||
pub look: Option<Look>,
|
||||
pub subject: Entity<TextInput>,
|
||||
pub body: Entity<TextInput>,
|
||||
pub amend: bool,
|
||||
/// The message typed before amend filled in the last one.
|
||||
pub draft: Option<(String, String)>,
|
||||
pub search: Entity<TextInput>,
|
||||
pub find: Find,
|
||||
/// Amend reads asked; switching or unticking voids older ones.
|
||||
pub amend_ask: u64,
|
||||
/// Amend is ticked; the last message is on its way.
|
||||
pub amend_loading: bool,
|
||||
/// File history and blame opened, numbering each.
|
||||
pub looks: u64,
|
||||
/// The branch or tag picked in the Branches panel.
|
||||
pub picked_ref: Option<(crate::branches::Kind, String)>,
|
||||
pub picked_place: Option<crate::places::Place>,
|
||||
/// The conflicted file being merged by hand.
|
||||
pub merging: Option<crate::conflict::Merging>,
|
||||
pub merge_editor: Entity<ely_gpui_component::editor::CodeEditor>,
|
||||
_quit: Subscription,
|
||||
_searching: Subscription,
|
||||
_returning: Subscription,
|
||||
}
|
||||
|
||||
pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString {
|
||||
ely_ui::i18n::text(cx, key, args)
|
||||
}
|
||||
|
||||
/// The window, with the repositories it was started with.
|
||||
pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
|
||||
crate::menu::bind(cx);
|
||||
let recent_file = match ely_platform::data_dir(app) {
|
||||
Ok(dir) => dir.join("recent.json"),
|
||||
Err(error) => ely_platform::startup::fail(app, &format!("{error:#}")),
|
||||
};
|
||||
let tree = cx.new(|cx| Tree::new(recent_file, window, cx));
|
||||
let root = ely_ui::tool_root(app, tree.clone().into(), crate::menu::menus(cx), window, cx);
|
||||
tree.update(cx, |tree, cx| {
|
||||
tree.root = root.downgrade();
|
||||
window.focus(&tree.focus, cx);
|
||||
if let Some(error) = tree.unread.take() {
|
||||
tree.failed("tree.error.recent", &error, cx);
|
||||
}
|
||||
if let Some(error) = tree.tools.broken.clone() {
|
||||
tree.failed("tree.error.tools_file", &error, cx);
|
||||
}
|
||||
});
|
||||
for path in ely_ui::launch_files(cx) {
|
||||
tree.update(cx, |tree, cx| tree.open_path(path, cx));
|
||||
}
|
||||
let opening = tree.clone();
|
||||
ely_ui::on_open(cx, move |path, cx| {
|
||||
opening.update(cx, |tree, cx| tree.open_path(path, cx))
|
||||
});
|
||||
// A clone is not left running behind a closed window.
|
||||
let closing = tree.clone();
|
||||
window.on_window_should_close(cx, move |_, cx| {
|
||||
closing.update(cx, |tree, cx| tree.stop_all(cx));
|
||||
true
|
||||
});
|
||||
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 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"))
|
||||
}
|
||||
|
||||
impl Tree {
|
||||
pub(crate) fn new(recent_file: PathBuf, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let git = Git::find().map_err(|error| error.to_string());
|
||||
if let Err(error) = &git {
|
||||
log::error!("tree: {error}");
|
||||
}
|
||||
let (recent, unread) = match ely_files::recent::recent(&recent_file) {
|
||||
Ok(list) => (list, None),
|
||||
Err(error) => (Vec::new(), Some(error.to_string())),
|
||||
};
|
||||
let this = cx.entity();
|
||||
let changes = cx.new(|cx| Changes::new(&this, cx));
|
||||
let subject = text(cx, "tree.commit.subject", &[]);
|
||||
let subject = cx.new(|cx| TextInput::new(window, cx).placeholder(subject));
|
||||
let body = text(cx, "tree.commit.body", &[]);
|
||||
let body = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.placeholder(body)
|
||||
.multi_line(3, 8)
|
||||
});
|
||||
let merge_editor =
|
||||
cx.new(|cx| ely_gpui_component::editor::CodeEditor::new(String::new(), window, cx));
|
||||
let search = text(cx, "tree.search", &[]);
|
||||
let search = cx.new(|cx| TextInput::new(window, cx).placeholder(search));
|
||||
let searching = cx.subscribe(&search, |tree: &mut Tree, _, event: &InputEvent, cx| {
|
||||
if *event == InputEvent::Submit {
|
||||
tree.search(cx);
|
||||
}
|
||||
});
|
||||
Tree {
|
||||
git,
|
||||
repos: Vec::new(),
|
||||
active: None,
|
||||
next: 1,
|
||||
recent,
|
||||
unread,
|
||||
tools: crate::tools::Tools::load(recent_file.with_file_name("tools.json")),
|
||||
recent_file,
|
||||
cloning: None,
|
||||
ask: None,
|
||||
focus: cx.focus_handle(),
|
||||
root: WeakEntity::new_invalid(),
|
||||
changes,
|
||||
shown: None,
|
||||
space: crate::diff::Space::Exact,
|
||||
layout: ely_gpui_component::git::DiffLayout::Unified,
|
||||
reads: 0,
|
||||
side: Side::Changes,
|
||||
look: None,
|
||||
subject,
|
||||
body,
|
||||
amend: false,
|
||||
draft: None,
|
||||
search,
|
||||
find: Find::Message,
|
||||
amend_ask: 0,
|
||||
amend_loading: false,
|
||||
looks: 0,
|
||||
picked_ref: None,
|
||||
picked_place: None,
|
||||
merging: None,
|
||||
merge_editor,
|
||||
_searching: searching,
|
||||
// Global settings change outside any repository's watch.
|
||||
_returning: cx.observe_window_activation(window, |tree, window, cx| {
|
||||
if let Some(id) = tree.active.filter(|_| window.is_window_active()) {
|
||||
tree.read_me(id, cx);
|
||||
}
|
||||
}),
|
||||
_quit: cx.on_app_quit(|tree, cx| {
|
||||
tree.stop_all(cx);
|
||||
async {}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toast(&self, text: SharedString, severity: Severity, cx: &mut App) {
|
||||
log::warn!("tree: {text}");
|
||||
if let Some(root) = self.root.upgrade() {
|
||||
root.update(cx, |root, cx| root.toast(text, severity, cx));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn failed(&self, key: &str, error: &str, cx: &mut App) {
|
||||
let words = text(cx, key, &[("error", error)]);
|
||||
self.toast(words, Severity::Danger, cx);
|
||||
}
|
||||
|
||||
/// The Git to run, or why there is none.
|
||||
pub fn git(&self, cx: &mut App) -> Option<Git> {
|
||||
match &self.git {
|
||||
Ok(git) => Some(git.clone()),
|
||||
Err(error) => {
|
||||
self.failed("tree.error.no_git", error, cx);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn opened(&self, id: u64) -> Option<&Opened> {
|
||||
self.repos.iter().find(|opened| opened.id == id)
|
||||
}
|
||||
|
||||
pub fn opened_mut(&mut self, id: u64) -> Option<&mut Opened> {
|
||||
self.repos.iter_mut().find(|opened| opened.id == id)
|
||||
}
|
||||
|
||||
pub fn active(&self) -> Option<&Opened> {
|
||||
self.active.and_then(|id| self.opened(id))
|
||||
}
|
||||
|
||||
/// Opens `path`'s repository; a file also shows its history.
|
||||
pub fn open_path(&mut self, path: PathBuf, cx: &mut Context<Self>) {
|
||||
let Some(git) = self.git(cx) else { return };
|
||||
let task = cx.background_spawn(async move {
|
||||
let file = path.is_file().then(|| path.clone());
|
||||
let dir = file.as_ref().and_then(|file| file.parent()).unwrap_or(&path);
|
||||
let opened = repo::open(&git, dir).map_err(|error| (path.clone(), error));
|
||||
// The file as Git names it, from the repository's top.
|
||||
let inside = match (&opened, file) {
|
||||
(Ok(repo), Some(file)) => Some(inside(&repo.root, &file).map_err(|error| (file, error))),
|
||||
_ => None,
|
||||
};
|
||||
(opened, inside)
|
||||
});
|
||||
cx.spawn(async move |this, cx| {
|
||||
let (opened, inside) = task.await;
|
||||
this.update(cx, |tree, cx| {
|
||||
tree.add(opened, cx);
|
||||
match inside {
|
||||
Some(Ok(rel)) => tree.file_log(&rel, cx),
|
||||
Some(Err((file, error))) => {
|
||||
let shown = file.display().to_string();
|
||||
let words = text(cx, "tree.error.open", &[("path", &shown), ("error", &error.to_string())]);
|
||||
tree.toast(words, Severity::Danger, cx);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Starts a repository in `dir`, then opens it.
|
||||
pub fn init_at(&mut self, dir: PathBuf, cx: &mut Context<Self>) {
|
||||
let Some(git) = self.git(cx) else { return };
|
||||
let task = cx
|
||||
.background_spawn(async move { repo::init(&git, &dir).map_err(|error| (dir, error)) });
|
||||
cx.spawn(async move |this, cx| {
|
||||
let made = task.await;
|
||||
this.update(cx, |tree, cx| tree.add(made, cx)).ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn add(&mut self, opened: Result<Repo, (PathBuf, io::Error)>, cx: &mut Context<Self>) {
|
||||
let repo = match opened {
|
||||
Ok(repo) => repo,
|
||||
Err((path, error)) => return self.not_opened(&path, &error, cx),
|
||||
};
|
||||
self.recent = match ely_files::recent::remember(&self.recent_file, &repo.root) {
|
||||
Ok(list) => list,
|
||||
Err(error) => {
|
||||
self.failed("tree.error.recent", &error.to_string(), cx);
|
||||
self.recent.clone()
|
||||
}
|
||||
};
|
||||
if let Some(known) = self
|
||||
.repos
|
||||
.iter()
|
||||
.find(|opened| opened.repo.root == repo.root)
|
||||
{
|
||||
let known = known.id;
|
||||
return self.activate(Some(known), cx);
|
||||
}
|
||||
let id = self.next;
|
||||
self.next += 1;
|
||||
let watcher = match crate::watch::start(id, &repo, cx) {
|
||||
Ok(watcher) => Some(watcher),
|
||||
Err(error) => {
|
||||
self.failed("tree.error.watch", &error.to_string(), cx);
|
||||
None
|
||||
}
|
||||
};
|
||||
self.repos.push(Opened {
|
||||
id,
|
||||
repo,
|
||||
status: None,
|
||||
reading: false,
|
||||
again: false,
|
||||
writes: VecDeque::new(),
|
||||
writing: false,
|
||||
hist: Hist::default(),
|
||||
refs: None,
|
||||
refs_asks: 0,
|
||||
doing: None,
|
||||
doing_asks: 0,
|
||||
held: Default::default(),
|
||||
me: None,
|
||||
me_asks: 0,
|
||||
places: Default::default(),
|
||||
net: None,
|
||||
_watcher: watcher,
|
||||
});
|
||||
self.activate(Some(id), cx);
|
||||
self.read(id, cx);
|
||||
self.read_me(id, cx);
|
||||
}
|
||||
|
||||
/// A recent entry that no longer opens leaves the list.
|
||||
fn not_opened(&mut self, path: &Path, error: &io::Error, cx: &mut Context<Self>) {
|
||||
let shown = path.display().to_string();
|
||||
if self.recent.iter().any(|known| known == path) {
|
||||
self.recent.retain(|known| known != path);
|
||||
if let Err(error) = ely_files::recent::forget(&self.recent_file, path) {
|
||||
self.failed("tree.error.recent", &error.to_string(), cx);
|
||||
}
|
||||
let words = text(
|
||||
cx,
|
||||
"tree.error.recent_gone",
|
||||
&[("path", &shown), ("error", &error.to_string())],
|
||||
);
|
||||
self.toast(words, Severity::Danger, cx);
|
||||
} else {
|
||||
let words = text(
|
||||
cx,
|
||||
"tree.error.open",
|
||||
&[("path", &shown), ("error", &error.to_string())],
|
||||
);
|
||||
self.toast(words, Severity::Danger, cx);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Reads the work tree; overlapping asks become one.
|
||||
pub fn read(&mut self, id: u64, cx: &mut Context<Self>) {
|
||||
let Some(git) = self.git.as_ref().ok().cloned() else {
|
||||
return;
|
||||
};
|
||||
let Some(opened) = self.opened_mut(id) else {
|
||||
return;
|
||||
};
|
||||
if opened.reading {
|
||||
opened.again = true;
|
||||
return;
|
||||
}
|
||||
opened.reading = true;
|
||||
let root = opened.repo.root.clone();
|
||||
let task = cx.background_spawn(async move { status::read(&git, &root) });
|
||||
cx.spawn(async move |this, cx| {
|
||||
let read = task.await.map_err(|error| error.to_string());
|
||||
this.update(cx, |tree, cx| tree.read_done(id, read, cx))
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn read_done(&mut self, id: u64, read: Result<Status, String>, cx: &mut Context<Self>) {
|
||||
let Some(opened) = self.opened_mut(id) else {
|
||||
return;
|
||||
};
|
||||
if let Err(error) = &read {
|
||||
log::warn!(
|
||||
"tree: reading {} failed: {error}",
|
||||
opened.repo.root.display()
|
||||
);
|
||||
}
|
||||
opened.status = Some(read);
|
||||
opened.reading = false;
|
||||
if std::mem::take(&mut opened.again) {
|
||||
self.read(id, cx);
|
||||
}
|
||||
if self.shown.as_ref().is_some_and(|shown| shown.repo == id) {
|
||||
self.read_shown(cx);
|
||||
}
|
||||
self.refresh_log(id, cx);
|
||||
// Settings may have moved with the files.
|
||||
self.read_me(id, cx);
|
||||
self.read_doing(id, cx);
|
||||
if self.opened(id).is_some_and(|opened| opened.refs.is_some()) {
|
||||
self.read_refs(id, cx);
|
||||
}
|
||||
if self
|
||||
.opened(id)
|
||||
.is_some_and(|opened| opened.places.read.is_some())
|
||||
{
|
||||
self.read_places(id, cx);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn close_active(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(id) = self.active else { return };
|
||||
self.close(id, cx);
|
||||
}
|
||||
|
||||
/// Closes a tab; writes already queued finish first.
|
||||
pub fn close(&mut self, id: u64, cx: &mut Context<Self>) {
|
||||
let Some(at) = self.repos.iter().position(|opened| opened.id == id) else {
|
||||
return;
|
||||
};
|
||||
if self.repos[at].writing || !self.repos[at].writes.is_empty() {
|
||||
let words = text(cx, "tree.busy", &[]);
|
||||
return self.toast(words, Severity::Warning, cx);
|
||||
}
|
||||
let closed = self.repos.remove(at);
|
||||
if self.shown.as_ref().is_some_and(|shown| shown.repo == id) {
|
||||
self.shown = None;
|
||||
}
|
||||
if self.look.as_ref().is_some_and(|look| look.repo() == id) {
|
||||
self.look = None;
|
||||
}
|
||||
log::info!("tree: closed {}", closed.repo.root.display());
|
||||
if self.active == Some(id) {
|
||||
// The closed box goes with its repository.
|
||||
self.active = None;
|
||||
let next = self
|
||||
.repos
|
||||
.get(at.min(self.repos.len().saturating_sub(1)))
|
||||
.map(|opened| opened.id);
|
||||
self.activate(next, cx);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user