ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git), ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
393 lines
12 KiB
Rust
393 lines
12 KiB
Rust
use std::{
|
|
fmt, io,
|
|
path::{Path, PathBuf},
|
|
time::SystemTime,
|
|
};
|
|
|
|
/// Length and mtime when last loaded or saved.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct Fingerprint {
|
|
pub len: u64,
|
|
pub modified: SystemTime,
|
|
}
|
|
|
|
impl Fingerprint {
|
|
pub fn of(path: &Path) -> io::Result<Self> {
|
|
let meta = std::fs::metadata(path)?;
|
|
Ok(Self {
|
|
len: meta.len(),
|
|
modified: meta.modified()?,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// The save states a session shows (FILE-02).
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum SaveState {
|
|
Loading,
|
|
LoadFailed,
|
|
Saved,
|
|
Dirty,
|
|
Saving,
|
|
ReadOnly,
|
|
ExternalModified,
|
|
SaveFailed,
|
|
}
|
|
|
|
impl fmt::Display for SaveState {
|
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
let name = match self {
|
|
SaveState::Loading => "loading",
|
|
SaveState::LoadFailed => "load-failed",
|
|
SaveState::Saved => "saved",
|
|
SaveState::Dirty => "dirty",
|
|
SaveState::Saving => "saving",
|
|
SaveState::ReadOnly => "read-only",
|
|
SaveState::ExternalModified => "external-modified",
|
|
SaveState::SaveFailed => "save-failed",
|
|
};
|
|
f.write_str(name)
|
|
}
|
|
}
|
|
|
|
/// A save in flight: its task, generation and revision (FILE-03).
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct SaveTicket {
|
|
task: u64,
|
|
generation: u64,
|
|
revision: u64,
|
|
}
|
|
|
|
/// A ticket from before a reload; its content is obsolete.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct StaleTicket;
|
|
|
|
/// One open document and its session data (§6.1).
|
|
pub struct Session {
|
|
path: Option<PathBuf>,
|
|
fingerprint: Option<Fingerprint>,
|
|
generation: u64,
|
|
revision: u64,
|
|
saved_revision: u64,
|
|
next_task: u64,
|
|
state: SaveState,
|
|
external_unresolved: bool,
|
|
loading: bool,
|
|
loaded: bool,
|
|
saving: Option<SaveTicket>,
|
|
pub cursor: Option<String>,
|
|
pub zoom: f32,
|
|
recovery: Option<PathBuf>,
|
|
}
|
|
|
|
impl Session {
|
|
/// A brand-new document: no target path until first saved (FILE-01).
|
|
pub fn new_file() -> Self {
|
|
Self {
|
|
path: None,
|
|
fingerprint: None,
|
|
generation: 0,
|
|
revision: 0,
|
|
saved_revision: 0,
|
|
next_task: 0,
|
|
state: SaveState::Saved,
|
|
external_unresolved: false,
|
|
loading: false,
|
|
loaded: true,
|
|
saving: None,
|
|
cursor: None,
|
|
zoom: 1.0,
|
|
recovery: None,
|
|
}
|
|
}
|
|
|
|
/// A document the kernel still loads asynchronously.
|
|
pub fn loading(path: PathBuf) -> Self {
|
|
Self {
|
|
state: SaveState::Loading,
|
|
loading: true,
|
|
loaded: false,
|
|
..Self::new_file()
|
|
}
|
|
.with_path(path)
|
|
}
|
|
|
|
fn with_path(mut self, path: PathBuf) -> Self {
|
|
self.path = Some(path);
|
|
self
|
|
}
|
|
|
|
/// Loaded from disk; the fingerprint is what was read.
|
|
pub fn load(path: PathBuf) -> io::Result<Self> {
|
|
let fingerprint = Fingerprint::of(&path)?;
|
|
Ok(Self {
|
|
fingerprint: Some(fingerprint),
|
|
..Self::new_file()
|
|
}
|
|
.with_path(path))
|
|
}
|
|
|
|
/// A read-only view: it never saves.
|
|
pub fn read_only(path: PathBuf) -> io::Result<Self> {
|
|
let mut session = Self::load(path)?;
|
|
session.state = SaveState::ReadOnly;
|
|
Ok(session)
|
|
}
|
|
|
|
pub fn path(&self) -> Option<&Path> {
|
|
self.path.as_deref()
|
|
}
|
|
|
|
/// The file moved to `path`; its contents did not change.
|
|
pub fn renamed(&mut self, path: PathBuf) {
|
|
self.path = Some(path);
|
|
}
|
|
|
|
pub fn fingerprint(&self) -> Option<Fingerprint> {
|
|
self.fingerprint
|
|
}
|
|
|
|
/// Whether the file on disk differs from our fingerprint (FILE-05).
|
|
pub fn external_changed(&self) -> io::Result<bool> {
|
|
let (Some(path), Some(fingerprint)) = (self.path(), self.fingerprint()) else {
|
|
return Ok(false);
|
|
};
|
|
Ok(Fingerprint::of(path)? != fingerprint)
|
|
}
|
|
|
|
pub fn revision(&self) -> u64 {
|
|
self.revision
|
|
}
|
|
|
|
pub fn saved_revision(&self) -> u64 {
|
|
self.saved_revision
|
|
}
|
|
|
|
pub fn state(&self) -> SaveState {
|
|
self.state
|
|
}
|
|
|
|
pub fn recovery(&self) -> Option<&Path> {
|
|
self.recovery.as_deref()
|
|
}
|
|
|
|
pub fn set_recovery(&mut self, location: PathBuf) {
|
|
self.recovery = Some(location);
|
|
}
|
|
|
|
/// Confirms the actually loaded content; a conflict outlives it.
|
|
pub fn finish_load(&mut self, loaded: Fingerprint) -> Result<(), StaleTicket> {
|
|
if !self.loading {
|
|
log::warn!("session: a stale load callback is dropped");
|
|
return Err(StaleTicket);
|
|
}
|
|
self.fingerprint = Some(loaded);
|
|
self.loading = false;
|
|
self.loaded = true;
|
|
self.state = if self.external_unresolved {
|
|
SaveState::ExternalModified
|
|
} else {
|
|
SaveState::Saved
|
|
};
|
|
log::info!("session: loaded at {} bytes", loaded.len);
|
|
Ok(())
|
|
}
|
|
|
|
/// The load failed; nothing is editable yet.
|
|
pub fn fail_load(&mut self) -> Result<(), StaleTicket> {
|
|
if !self.loading {
|
|
log::warn!("session: a stale load failure is dropped");
|
|
return Err(StaleTicket);
|
|
}
|
|
let failed_path = self.path().unwrap_or(Path::new("")).display().to_string();
|
|
log::warn!("session: load failed for {failed_path}");
|
|
self.loading = false;
|
|
self.loaded = false;
|
|
self.state = SaveState::LoadFailed;
|
|
Ok(())
|
|
}
|
|
|
|
/// An edit bumps the revision; a conflict stays shown (FILE-02).
|
|
pub fn edit(&mut self) {
|
|
self.revision += 1;
|
|
if self.state == SaveState::Saved {
|
|
self.state = SaveState::Dirty;
|
|
}
|
|
}
|
|
|
|
/// Undo reached the saved content; other states stay (FILE-02).
|
|
pub fn reverted(&mut self) {
|
|
if self.state == SaveState::Dirty {
|
|
log::info!("session: back to the saved content");
|
|
self.state = SaveState::Saved;
|
|
}
|
|
}
|
|
|
|
/// Starts a save; read-only, loading and conflicted states refuse.
|
|
pub fn begin_save(&mut self) -> Result<SaveTicket, SaveState> {
|
|
self.ready_to_save(false)
|
|
}
|
|
|
|
/// The user chose keep-local in a conflict; overwrite anyway.
|
|
pub fn begin_save_overwrite(&mut self) -> Result<SaveTicket, SaveState> {
|
|
let ticket = self.ready_to_save(true)?;
|
|
log::info!("session: keep-local overwrites the external change");
|
|
self.external_unresolved = false;
|
|
Ok(ticket)
|
|
}
|
|
|
|
/// Shared guards: loading, saving, unloaded, read-only, conflict.
|
|
fn ready_to_save(&mut self, overwrite: bool) -> Result<SaveTicket, SaveState> {
|
|
if self.loading {
|
|
log::warn!("session: a load is still running");
|
|
return Err(SaveState::Loading);
|
|
}
|
|
if self.saving.is_some() {
|
|
log::warn!("session: a save is already running");
|
|
return Err(SaveState::Saving);
|
|
}
|
|
if !self.loaded {
|
|
log::warn!("session: nothing loaded yet to save");
|
|
return Err(SaveState::LoadFailed);
|
|
}
|
|
if self.state == SaveState::ReadOnly {
|
|
log::warn!("session: a read-only view cannot save");
|
|
return Err(self.state);
|
|
}
|
|
if !overwrite && self.external_unresolved {
|
|
log::warn!("session: an unresolved conflict refuses a plain save");
|
|
return Err(SaveState::ExternalModified);
|
|
}
|
|
Ok(self.ticket())
|
|
}
|
|
|
|
fn ticket(&mut self) -> SaveTicket {
|
|
self.state = SaveState::Saving;
|
|
let ticket = SaveTicket {
|
|
task: self.next_task,
|
|
generation: self.generation,
|
|
revision: self.revision,
|
|
};
|
|
self.next_task += 1;
|
|
self.saving = Some(ticket);
|
|
ticket
|
|
}
|
|
|
|
/// Confirms the written revision; a conflict outlives it (FILE-03).
|
|
pub fn finish_save(
|
|
&mut self,
|
|
ticket: SaveTicket,
|
|
path: PathBuf,
|
|
written: Fingerprint,
|
|
) -> Result<(), StaleTicket> {
|
|
let Some(running) = self.saving else {
|
|
log::warn!("session: no save is running for {}", path.display());
|
|
return Err(StaleTicket);
|
|
};
|
|
if running.task != ticket.task {
|
|
log::warn!(
|
|
"session: an unknown ticket for {} is dropped",
|
|
path.display()
|
|
);
|
|
return Err(StaleTicket);
|
|
}
|
|
self.saving = None;
|
|
if ticket.generation != self.generation {
|
|
log::warn!("session: a stale ticket for {} is dropped", path.display());
|
|
return Err(StaleTicket);
|
|
}
|
|
log::info!(
|
|
"session: saved revision {} to {}",
|
|
ticket.revision,
|
|
path.display()
|
|
);
|
|
self.path = Some(path);
|
|
self.fingerprint = Some(written);
|
|
self.saved_revision = ticket.revision;
|
|
self.state = if self.external_unresolved {
|
|
SaveState::ExternalModified
|
|
} else if self.saved_revision == self.revision {
|
|
SaveState::Saved
|
|
} else {
|
|
SaveState::Dirty
|
|
};
|
|
Ok(())
|
|
}
|
|
|
|
/// A failed save keeps the file and the unsaved content.
|
|
pub fn fail_save(&mut self, ticket: SaveTicket) -> Result<(), StaleTicket> {
|
|
if self.saving.map(|running| running.task) != Some(ticket.task) {
|
|
log::warn!("session: a failure for an unknown save is dropped");
|
|
return Err(StaleTicket);
|
|
}
|
|
self.saving = None;
|
|
if ticket.generation != self.generation {
|
|
log::warn!("session: a stale save failure is dropped");
|
|
return Err(StaleTicket);
|
|
}
|
|
log::warn!("session: save failed at revision {}", self.revision);
|
|
self.state = SaveState::SaveFailed;
|
|
Ok(())
|
|
}
|
|
|
|
/// A save that failed before any write (FILE-02).
|
|
pub fn save_failed(&mut self) {
|
|
if self.saving.is_none() && matches!(self.state, SaveState::Saved | SaveState::Dirty) {
|
|
log::warn!("session: a save failed before writing");
|
|
self.state = SaveState::SaveFailed;
|
|
}
|
|
}
|
|
|
|
/// The file changed on disk since our fingerprint.
|
|
pub fn external_modified(&mut self) {
|
|
if self.state == SaveState::ReadOnly {
|
|
return;
|
|
}
|
|
self.external_unresolved = true;
|
|
self.state = SaveState::ExternalModified;
|
|
}
|
|
|
|
/// Reloaded from disk; the access mode survives, tickets die.
|
|
pub fn reload(&mut self) -> io::Result<()> {
|
|
let Some(path) = self.path.clone() else {
|
|
return Err(io::Error::new(io::ErrorKind::NotFound, "no path to reload"));
|
|
};
|
|
self.reload_at(Fingerprint::of(&path)?)
|
|
}
|
|
|
|
/// The disk at `fingerprint` is seen; edits stay unsaved.
|
|
pub fn rebase(&mut self, fingerprint: Fingerprint) {
|
|
self.fingerprint = Some(fingerprint);
|
|
self.external_unresolved = false;
|
|
if self.state != SaveState::ReadOnly {
|
|
self.state = SaveState::Dirty;
|
|
}
|
|
log::info!("session: the disk version is seen; local edits stay");
|
|
}
|
|
|
|
/// Reloaded from bytes read at `fingerprint`, not disk now.
|
|
pub fn reload_at(&mut self, fingerprint: Fingerprint) -> io::Result<()> {
|
|
let Some(path) = self.path.clone() else {
|
|
return Err(io::Error::new(io::ErrorKind::NotFound, "no path to reload"));
|
|
};
|
|
self.fingerprint = Some(fingerprint);
|
|
self.generation += 1;
|
|
self.revision = 0;
|
|
self.saved_revision = 0;
|
|
self.external_unresolved = false;
|
|
self.loading = false;
|
|
self.loaded = true;
|
|
if self.state != SaveState::ReadOnly {
|
|
self.state = SaveState::Saved;
|
|
}
|
|
log::info!("session: reloaded {}", path.display());
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
mod registry;
|
|
pub use registry::{Open, Sessions};
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|