2026-10-14 19:15:02 +02:00
|
|
|
//! The state the commands share: the engine, and what goes with it.
|
2026-10-14 08:51:38 +02:00
|
|
|
//!
|
2026-10-14 19:15:02 +02:00
|
|
|
//! The engine is not `Send` (it shares its data with `Rc`), so it lives on a thread of its own with
|
|
|
|
|
//! everything that works on it (its storage, the link between its files and the file system, the
|
|
|
|
|
//! watcher of the open folder), and the commands send that thread jobs: they are done one after the
|
|
|
|
|
//! other, in the order they were sent.
|
2026-10-14 08:51:38 +02:00
|
|
|
|
2026-10-16 18:14:59 +02:00
|
|
|
use std::path::PathBuf;
|
2026-10-14 08:51:38 +02:00
|
|
|
use std::sync::mpsc::{self, Sender};
|
2026-10-14 19:15:02 +02:00
|
|
|
use std::sync::{Arc, Mutex};
|
2026-10-14 08:51:38 +02:00
|
|
|
use std::thread;
|
2026-10-14 19:15:02 +02:00
|
|
|
use std::time::Duration;
|
2026-10-14 08:51:38 +02:00
|
|
|
|
2026-10-16 09:02:31 +02:00
|
|
|
use crate::Engine;
|
|
|
|
|
use gpx_engine::{Persister, Storage, api};
|
2026-10-14 19:15:02 +02:00
|
|
|
use notify::RecursiveMode;
|
|
|
|
|
use notify_debouncer_mini::{DebounceEventResult, Debouncer, new_debouncer};
|
|
|
|
|
use serde::Serialize;
|
2026-10-14 08:51:38 +02:00
|
|
|
|
2026-10-14 19:15:02 +02:00
|
|
|
use crate::store::FileStorage;
|
2026-10-17 23:55:46 +02:00
|
|
|
use crate::workspace::{Workspace, WorkspaceState, signature};
|
2026-10-14 08:51:38 +02:00
|
|
|
|
2026-10-14 19:15:02 +02:00
|
|
|
/// Tells the frontend that something happened that it did not ask for: a name and a JSON value.
|
|
|
|
|
pub type Emit = Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>;
|
|
|
|
|
|
|
|
|
|
type Job = Box<dyn FnOnce(&mut Core) + Send>;
|
|
|
|
|
|
|
|
|
|
/// Everything that lives on the thread of the engine.
|
|
|
|
|
pub struct Core {
|
|
|
|
|
pub engine: Engine,
|
|
|
|
|
pub workspace: Workspace,
|
|
|
|
|
storage: Arc<FileStorage>,
|
|
|
|
|
persister: Persister,
|
|
|
|
|
watcher: Option<Debouncer<notify::RecommendedWatcher>>,
|
2026-10-16 18:14:59 +02:00
|
|
|
/// What the watcher watches, see `Workspace::watched`.
|
|
|
|
|
watching: Vec<(PathBuf, bool)>,
|
2026-10-14 19:15:02 +02:00
|
|
|
jobs: Sender<Job>,
|
|
|
|
|
emit: Emit,
|
2026-10-14 08:51:38 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-14 19:15:02 +02:00
|
|
|
#[derive(Clone)]
|
|
|
|
|
pub struct AppState {
|
|
|
|
|
jobs: Arc<Mutex<Sender<Job>>>,
|
|
|
|
|
storage: Arc<FileStorage>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Core {
|
|
|
|
|
fn start(storage: Arc<FileStorage>, jobs: Sender<Job>, emit: Emit) -> Self {
|
|
|
|
|
let mut core = Self {
|
|
|
|
|
engine: Engine::default(),
|
|
|
|
|
workspace: Workspace::load(storage.root()),
|
|
|
|
|
persister: Persister::default(),
|
|
|
|
|
storage,
|
|
|
|
|
watcher: None,
|
2026-10-16 18:14:59 +02:00
|
|
|
watching: vec![],
|
2026-10-14 19:15:02 +02:00
|
|
|
jobs,
|
|
|
|
|
emit,
|
|
|
|
|
};
|
|
|
|
|
match pollster::block_on(core.persister.restore(&*core.storage)) {
|
|
|
|
|
Ok(restored) => {
|
|
|
|
|
core.engine.restore(restored);
|
|
|
|
|
}
|
|
|
|
|
// what is stored comes from a newer version, which is left alone
|
|
|
|
|
Err(gpx_engine::StorageError::Newer) => {}
|
|
|
|
|
Err(error) => eprintln!("the files could not be read: {error}"),
|
|
|
|
|
}
|
|
|
|
|
core.workspace.prune(&core.engine);
|
|
|
|
|
core.workspace.verify(&core.engine);
|
|
|
|
|
// what happened on the disk while the app was closed
|
|
|
|
|
core.workspace.reconcile(&mut core.engine, None);
|
|
|
|
|
core.workspace.write(core.storage.root());
|
|
|
|
|
core.watch();
|
|
|
|
|
core.persist();
|
|
|
|
|
core
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Writes what changed in the engine.
|
|
|
|
|
pub fn persist(&mut self) {
|
|
|
|
|
match pollster::block_on(self.persister.save(&*self.storage, self.engine.snapshot())) {
|
|
|
|
|
Ok(()) | Err(gpx_engine::CommitError::Failed(gpx_engine::StorageError::Newer)) => {}
|
|
|
|
|
Err(error) => eprintln!("the files could not be kept: {error}"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Opens files that the system gave to the app (open with..., double click, the command line),
|
|
|
|
|
/// which get selected. The frontend is told, if it already runs: when it starts it reads what
|
|
|
|
|
/// the engine has.
|
|
|
|
|
pub fn open_external(&mut self, paths: &[PathBuf]) {
|
|
|
|
|
if paths.is_empty() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-17 23:55:46 +02:00
|
|
|
let outcome = self.workspace.open(&mut self.engine, paths, false);
|
2026-10-14 19:15:02 +02:00
|
|
|
(self.emit)("files-opened", to_json(&outcome));
|
2026-10-17 23:55:46 +02:00
|
|
|
self.commit(None);
|
2026-10-14 19:15:02 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-17 22:17:17 +02:00
|
|
|
/// Runs `f` on the engine, which can change: what changed is kept, and the files on the disk are
|
|
|
|
|
/// renamed like the files of the engine were (an edit of the name, or its undo). Gives what
|
|
|
|
|
/// could not be renamed.
|
|
|
|
|
fn editing<T>(&mut self, f: impl FnOnce(&mut Engine) -> T) -> (T, Vec<String>) {
|
|
|
|
|
let names = self.workspace.names(&self.engine);
|
2026-10-17 23:23:29 +02:00
|
|
|
let preview = self.workspace.preview;
|
2026-10-17 23:55:46 +02:00
|
|
|
let preview_before = preview.and_then(|id| signature(&self.engine, id));
|
2026-10-17 22:17:17 +02:00
|
|
|
let result = f(&mut self.engine);
|
2026-10-17 23:23:29 +02:00
|
|
|
// the preview is open for good once something is done to it
|
2026-10-17 23:55:46 +02:00
|
|
|
let kept = preview.is_some_and(|id| signature(&self.engine, id) != preview_before);
|
|
|
|
|
if let Some(id) = preview.filter(|_| kept) {
|
2026-10-17 23:23:29 +02:00
|
|
|
self.workspace.keep(&[id]);
|
|
|
|
|
}
|
2026-10-17 22:17:17 +02:00
|
|
|
let (renamed, errors) = self.workspace.follow_names(&mut self.engine, &names);
|
2026-10-18 09:32:18 +02:00
|
|
|
// and it goes when something else is selected
|
|
|
|
|
let dropped = self.workspace.drop_preview(&mut self.engine);
|
|
|
|
|
if kept || renamed || dropped.is_some() {
|
|
|
|
|
self.commit(dropped.as_ref());
|
2026-10-17 23:55:46 +02:00
|
|
|
} else {
|
|
|
|
|
self.persist();
|
2026-10-17 22:17:17 +02:00
|
|
|
}
|
|
|
|
|
(result, errors)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Like `editing`, for an edit that can rename files: the outcome tells the first thing that
|
|
|
|
|
/// could not be renamed (a key of the dictionaries of the frontend, which shows it).
|
|
|
|
|
pub fn edit(&mut self, f: impl FnOnce(&mut Engine) -> api::Outcome) -> api::Outcome {
|
|
|
|
|
let (outcome, errors) = self.editing(f);
|
|
|
|
|
match errors.first() {
|
|
|
|
|
Some(error) if !outcome.has_error() => {
|
|
|
|
|
outcome.merge(api::Outcome::failed(error.clone()))
|
|
|
|
|
}
|
|
|
|
|
_ => outcome,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// After files were written (`result`): what changed in the engine (the files are called like
|
|
|
|
|
/// the files they were written to) is kept and told, with where the files are. Says whether
|
|
|
|
|
/// they were written.
|
|
|
|
|
pub fn wrote(&mut self, result: Result<api::Outcome, String>) -> Result<bool, String> {
|
2026-10-17 23:55:46 +02:00
|
|
|
self.commit(result.as_ref().ok().filter(|outcome| outcome.changed));
|
2026-10-17 22:17:17 +02:00
|
|
|
result.map(|_| true)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 23:55:46 +02:00
|
|
|
/// After a change of the files or of where they are: the workspace and the engine are kept, and
|
|
|
|
|
/// the frontend is told (`outcome` is what changed in the engine, if it did not ask for it).
|
|
|
|
|
pub fn commit(&mut self, outcome: Option<&api::Outcome>) {
|
|
|
|
|
self.keep_workspace();
|
|
|
|
|
self.persist();
|
|
|
|
|
self.announce(outcome);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 18:14:59 +02:00
|
|
|
/// Writes the workspace, and watches what it holds (a file may have been opened or closed).
|
|
|
|
|
pub fn keep_workspace(&mut self) {
|
2026-10-14 19:15:02 +02:00
|
|
|
self.workspace.write(self.storage.root());
|
2026-10-16 18:14:59 +02:00
|
|
|
self.watch();
|
2026-10-14 19:15:02 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn workspace_state(&self) -> WorkspaceState {
|
|
|
|
|
self.workspace.state(&self.engine)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Tells the frontend what changed without it asking, and where the files are.
|
|
|
|
|
pub fn announce(&self, outcome: Option<&api::Outcome>) {
|
|
|
|
|
if let Some(outcome) = outcome {
|
|
|
|
|
(self.emit)("engine-changed", to_json(outcome));
|
|
|
|
|
}
|
|
|
|
|
(self.emit)("workspace-changed", to_json(&self.workspace_state()));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 18:14:59 +02:00
|
|
|
/// Watches the open folder, and the folders of the files that are linked outside of it. Does
|
|
|
|
|
/// nothing if that is what is watched already.
|
2026-10-14 19:15:02 +02:00
|
|
|
pub fn watch(&mut self) {
|
2026-10-16 18:14:59 +02:00
|
|
|
let wanted = self.workspace.watched();
|
|
|
|
|
if wanted == self.watching && (self.watcher.is_some() || wanted.is_empty()) {
|
2026-10-14 19:15:02 +02:00
|
|
|
return;
|
2026-10-16 18:14:59 +02:00
|
|
|
}
|
|
|
|
|
self.watcher = None;
|
|
|
|
|
self.watching = wanted.clone();
|
|
|
|
|
if wanted.is_empty() {
|
2026-10-14 19:15:02 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let jobs = self.jobs.clone();
|
|
|
|
|
let debouncer = new_debouncer(
|
|
|
|
|
Duration::from_millis(400),
|
|
|
|
|
move |result: DebounceEventResult| {
|
|
|
|
|
let Ok(events) = result else { return };
|
|
|
|
|
let paths: Vec<PathBuf> = events.into_iter().map(|event| event.path).collect();
|
|
|
|
|
let _ = jobs.send(Box::new(move |core: &mut Core| {
|
|
|
|
|
core.on_disk_changed(&paths)
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
match debouncer {
|
|
|
|
|
Ok(mut debouncer) => {
|
2026-10-16 18:14:59 +02:00
|
|
|
for (path, recursive) in &wanted {
|
|
|
|
|
let mode = if *recursive {
|
|
|
|
|
RecursiveMode::Recursive
|
|
|
|
|
} else {
|
|
|
|
|
RecursiveMode::NonRecursive
|
|
|
|
|
};
|
|
|
|
|
if let Err(error) = debouncer.watcher().watch(path, mode) {
|
|
|
|
|
eprintln!("{} cannot be watched: {error}", path.display());
|
|
|
|
|
}
|
2026-10-14 08:51:38 +02:00
|
|
|
}
|
2026-10-16 18:14:59 +02:00
|
|
|
self.watcher = Some(debouncer);
|
2026-10-14 19:15:02 +02:00
|
|
|
}
|
2026-10-16 18:14:59 +02:00
|
|
|
Err(error) => eprintln!("the files cannot be watched: {error}"),
|
2026-10-14 19:15:02 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn on_disk_changed(&mut self, paths: &[PathBuf]) {
|
2026-10-16 18:14:59 +02:00
|
|
|
// the tree of the folder shows the disk
|
|
|
|
|
let in_folder: Vec<&PathBuf> = paths
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|path| {
|
|
|
|
|
self.workspace
|
|
|
|
|
.folder
|
|
|
|
|
.as_ref()
|
|
|
|
|
.is_some_and(|f| path.starts_with(f))
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
if !in_folder.is_empty() {
|
|
|
|
|
(self.emit)("folder-changed", to_json(&in_folder));
|
|
|
|
|
}
|
|
|
|
|
if let Some(outcome) = self.workspace.reconcile(&mut self.engine, Some(paths)) {
|
2026-10-17 23:55:46 +02:00
|
|
|
self.commit(Some(&outcome));
|
2026-10-14 08:51:38 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-10-16 18:14:59 +02:00
|
|
|
|
|
|
|
|
/// Runs `f`, which creates files (a new file, a copy, the result of a split...). Where they go
|
|
|
|
|
/// when they are saved is the folder of the files they come from (the selected ones, before
|
|
|
|
|
/// `f`), if it is in the open folder. `default_folder` says whether a file with nothing to
|
|
|
|
|
/// come from goes in the open folder: a new file does.
|
|
|
|
|
pub fn creating(
|
|
|
|
|
&mut self,
|
|
|
|
|
folder: Option<PathBuf>,
|
|
|
|
|
default_folder: bool,
|
|
|
|
|
f: impl FnOnce(&mut Engine) -> api::Outcome,
|
|
|
|
|
) -> api::Outcome {
|
|
|
|
|
let selected: Vec<uuid::Uuid> = self
|
|
|
|
|
.engine
|
|
|
|
|
.selection()
|
|
|
|
|
.file_ids()
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|gpx_engine::FileId(id)| id)
|
|
|
|
|
.collect();
|
|
|
|
|
let dir = folder
|
|
|
|
|
.map(|folder| crate::workspace::canonical(&folder))
|
|
|
|
|
.or_else(|| self.workspace.source_dir(&selected))
|
|
|
|
|
.or_else(|| {
|
|
|
|
|
default_folder
|
|
|
|
|
.then(|| self.workspace.folder.clone())
|
|
|
|
|
.flatten()
|
|
|
|
|
});
|
2026-10-17 16:14:17 +02:00
|
|
|
let mut outcome = f(&mut self.engine);
|
2026-10-16 18:14:59 +02:00
|
|
|
if let Some(dir) = dir {
|
|
|
|
|
self.workspace.inherit(outcome.added(), &dir);
|
2026-10-17 23:55:46 +02:00
|
|
|
// what is made in a folder is written there at once, so that the tree shows it
|
|
|
|
|
let made: Vec<uuid::Uuid> = outcome
|
2026-10-17 16:14:17 +02:00
|
|
|
.added()
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|id| uuid::Uuid::parse_str(id).ok())
|
|
|
|
|
.filter(|id| self.workspace.dirs.contains_key(id))
|
|
|
|
|
.collect();
|
2026-10-17 22:17:17 +02:00
|
|
|
match self.workspace.save_new(&mut self.engine, &made) {
|
|
|
|
|
Ok(renamed) => outcome = outcome.merge(renamed),
|
|
|
|
|
Err(error) if !outcome.has_error() => {
|
|
|
|
|
outcome = outcome.merge(api::Outcome::failed(error));
|
|
|
|
|
}
|
|
|
|
|
Err(_) => {}
|
2026-10-17 16:14:17 +02:00
|
|
|
}
|
2026-10-16 18:14:59 +02:00
|
|
|
}
|
2026-10-17 23:55:46 +02:00
|
|
|
self.commit(None);
|
2026-10-16 18:14:59 +02:00
|
|
|
outcome
|
|
|
|
|
}
|
2026-10-14 08:51:38 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-14 19:15:02 +02:00
|
|
|
fn to_json<T: Serialize>(value: &T) -> serde_json::Value {
|
|
|
|
|
serde_json::to_value(value).unwrap_or_default()
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-14 08:51:38 +02:00
|
|
|
impl AppState {
|
2026-10-14 19:15:02 +02:00
|
|
|
/// Starts the engine, which restores what was kept in `directory`.
|
|
|
|
|
pub fn open(directory: impl Into<PathBuf>, emit: Emit) -> Result<Self, String> {
|
|
|
|
|
let storage = Arc::new(FileStorage::open(directory).map_err(|error| error.to_string())?);
|
|
|
|
|
let (jobs, receiver) = mpsc::channel::<Job>();
|
|
|
|
|
let (thread_storage, thread_jobs) = (storage.clone(), jobs.clone());
|
|
|
|
|
thread::Builder::new()
|
|
|
|
|
.name("engine".into())
|
|
|
|
|
.spawn(move || {
|
|
|
|
|
let mut core = Core::start(thread_storage, thread_jobs, emit);
|
|
|
|
|
while let Ok(job) = receiver.recv() {
|
|
|
|
|
job(&mut core);
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.map_err(|error| error.to_string())?;
|
|
|
|
|
Ok(Self {
|
|
|
|
|
jobs: Arc::new(Mutex::new(jobs)),
|
|
|
|
|
storage,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// An engine that keeps its data in a directory of its own, and has nobody to tell.
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
pub fn temporary() -> Self {
|
|
|
|
|
let directory = std::env::temp_dir().join(format!("gpx-studio-{}", uuid::Uuid::new_v4()));
|
|
|
|
|
Self::open(directory, Arc::new(|_, _| {})).expect("the storage can be opened")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Opens files that the system gave to the app, see `Core::open_external`.
|
|
|
|
|
pub fn open_external(&self, paths: Vec<PathBuf>) {
|
|
|
|
|
self.core(move |core| core.open_external(&paths));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Runs `f` on the thread of the engine and waits for its result.
|
|
|
|
|
pub fn core<T: Send + 'static>(&self, f: impl FnOnce(&mut Core) -> T + Send + 'static) -> T {
|
2026-10-14 08:51:38 +02:00
|
|
|
let (reply, result) = mpsc::channel();
|
2026-10-14 19:15:02 +02:00
|
|
|
let job: Job = Box::new(move |core| {
|
|
|
|
|
let _ = reply.send(f(core));
|
2026-10-14 08:51:38 +02:00
|
|
|
});
|
|
|
|
|
self.jobs
|
|
|
|
|
.lock()
|
|
|
|
|
.unwrap()
|
|
|
|
|
.send(job)
|
|
|
|
|
.expect("the engine thread stopped");
|
|
|
|
|
result.recv().expect("the engine thread stopped")
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-17 22:17:17 +02:00
|
|
|
/// Runs `f` on the engine, which can change: what changed is kept (see `Core::editing`).
|
2026-10-14 19:15:02 +02:00
|
|
|
pub fn run<T: Send + 'static>(&self, f: impl FnOnce(&mut Engine) -> T + Send + 'static) -> T {
|
|
|
|
|
self.core(move |core| {
|
2026-10-17 22:17:17 +02:00
|
|
|
let (result, errors) = core.editing(f);
|
|
|
|
|
for error in errors {
|
|
|
|
|
eprintln!("{error}");
|
|
|
|
|
}
|
2026-10-14 19:15:02 +02:00
|
|
|
result
|
|
|
|
|
})
|
2026-10-14 08:51:38 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-17 22:17:17 +02:00
|
|
|
/// Like `run`, for an edit that can rename files: the outcome tells what could not be renamed.
|
|
|
|
|
pub fn edit(
|
|
|
|
|
&self,
|
|
|
|
|
f: impl FnOnce(&mut Engine) -> api::Outcome + Send + 'static,
|
|
|
|
|
) -> api::Outcome {
|
|
|
|
|
self.core(move |core| core.edit(f))
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-14 19:15:02 +02:00
|
|
|
/// Reads from the engine.
|
|
|
|
|
pub fn read<T: Send + 'static>(&self, f: impl FnOnce(&Engine) -> T + Send + 'static) -> T {
|
|
|
|
|
self.core(move |core| f(&core.engine))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn storage(&self) -> &FileStorage {
|
|
|
|
|
&self.storage
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// the settings only are read and written by the commands, from any thread
|
|
|
|
|
impl AppState {
|
|
|
|
|
pub fn put_setting(&self, key: &str, value: &str) {
|
|
|
|
|
if let Err(error) = pollster::block_on(self.storage.put_setting(key, value)) {
|
|
|
|
|
eprintln!("the setting could not be kept: {error}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn delete_setting(&self, key: &str) {
|
|
|
|
|
if let Err(error) = pollster::block_on(self.storage.delete_setting(key)) {
|
|
|
|
|
eprintln!("the setting could not be kept: {error}");
|
|
|
|
|
}
|
2026-10-14 08:51:38 +02:00
|
|
|
}
|
|
|
|
|
}
|