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,234 @@
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// A file's device and index.
|
||||
type FileId = (u64, u64);
|
||||
|
||||
/// The app's open sessions (§6.1).
|
||||
#[derive(Default)]
|
||||
pub struct Sessions {
|
||||
/// Path, file identity when opened, session id.
|
||||
open: Vec<(PathBuf, Option<FileId>, usize)>,
|
||||
next: usize,
|
||||
}
|
||||
|
||||
/// What opening a path did.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Open {
|
||||
/// A new session id.
|
||||
Fresh(usize),
|
||||
/// The path is already open; focus that session.
|
||||
Focus(usize),
|
||||
/// A new read-only view id beside the existing session.
|
||||
View(usize),
|
||||
}
|
||||
|
||||
/// The dedup key: canonical path plus file identity.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Key {
|
||||
canonical: PathBuf,
|
||||
id: Option<(u64, u64)>,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn file_id(path: &Path) -> io::Result<Option<(u64, u64)>> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let meta = std::fs::metadata(path)?;
|
||||
Ok(Some((meta.dev(), meta.ino())))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn file_id(path: &Path) -> io::Result<Option<(u64, u64)>> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
BY_HANDLE_FILE_INFORMATION, CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE,
|
||||
FILE_SHARE_READ, FILE_SHARE_WRITE, GetFileInformationByHandle, OPEN_EXISTING,
|
||||
};
|
||||
let wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
|
||||
let handle = unsafe {
|
||||
CreateFileW(
|
||||
wide.as_ptr(),
|
||||
0,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||
std::ptr::null(),
|
||||
OPEN_EXISTING,
|
||||
FILE_FLAG_BACKUP_SEMANTICS,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if handle == INVALID_HANDLE_VALUE {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
|
||||
let ok = unsafe { GetFileInformationByHandle(handle, &mut info) };
|
||||
unsafe { CloseHandle(handle) };
|
||||
if ok == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(Some((
|
||||
info.dwVolumeSerialNumber as u64,
|
||||
((info.nFileIndexHigh as u64) << 32) | info.nFileIndexLow as u64,
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn file_id(_: &Path) -> io::Result<Option<(u64, u64)>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
impl Sessions {
|
||||
/// The key of an existing file; missing fails loud.
|
||||
fn key(path: &Path) -> io::Result<Key> {
|
||||
Ok(Key {
|
||||
canonical: std::fs::canonicalize(path)?,
|
||||
id: file_id(path)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// A match by path, identity, or a moved file's.
|
||||
fn matches(have: &Path, had: Option<FileId>, key: &Key) -> io::Result<bool> {
|
||||
if *have == key.canonical {
|
||||
return Ok(true);
|
||||
}
|
||||
match file_id(have) {
|
||||
Ok(Some(a)) => Ok(key.id == Some(a)),
|
||||
Ok(None) => Ok(false),
|
||||
// Gone from its path: the same file renamed.
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
Ok(had.is_some() && key.id == had)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// The first live match, else the first identity error.
|
||||
fn focus(&self, key: &Key) -> io::Result<Option<usize>> {
|
||||
let mut error = None;
|
||||
for (have, had, id) in &self.open {
|
||||
match Self::matches(have, *had, key) {
|
||||
Ok(true) => return Ok(Some(*id)),
|
||||
Ok(false) => {}
|
||||
Err(current) => {
|
||||
if error.is_none() {
|
||||
error = Some(current);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match error {
|
||||
Some(current) => Err(current),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// A second open focuses, or views read-only.
|
||||
pub fn open(&mut self, path: &Path, read_only: bool) -> io::Result<Open> {
|
||||
let key = Self::key(path)?;
|
||||
let found = self.focus(&key)?;
|
||||
if let Some(id) = found
|
||||
&& !read_only
|
||||
{
|
||||
log::info!("sessions: {path:?} is open as {id}; focusing it");
|
||||
return Ok(Open::Focus(id));
|
||||
}
|
||||
let id = self.next;
|
||||
self.next += 1;
|
||||
self.open.push((key.canonical.clone(), key.id, id));
|
||||
if found.is_some() {
|
||||
log::info!("sessions: {path:?} also opens as read-only view {id}");
|
||||
return Ok(Open::View(id));
|
||||
}
|
||||
Ok(Open::Fresh(id))
|
||||
}
|
||||
|
||||
/// Drops a closed session's record.
|
||||
pub fn close(&mut self, id: usize) {
|
||||
self.open.retain(|(_, _, have)| *have != id);
|
||||
}
|
||||
|
||||
/// Session `id` now names the file at `path`.
|
||||
pub fn track(&mut self, id: usize, path: &Path) -> io::Result<()> {
|
||||
let key = Self::key(path)?;
|
||||
for entry in self.open.iter_mut().filter(|(_, _, have)| *have == id) {
|
||||
(entry.0, entry.1) = (key.canonical.clone(), key.id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where session `id`'s file went in `dir`, if renamed there.
|
||||
pub fn renamed_in(&self, id: usize, dir: &Path) -> io::Result<Option<PathBuf>> {
|
||||
let had = self.open.iter().find(|(_, _, have)| *have == id);
|
||||
let Some(had) = had.and_then(|(_, had, _)| *had) else {
|
||||
return Ok(None);
|
||||
};
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
// A link to the file is not the file.
|
||||
if entry.file_type()?.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
match file_id(&entry.path()) {
|
||||
Ok(found) if found == Some(had) => return Ok(Some(entry.path())),
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn is_open(&self, path: &Path) -> io::Result<bool> {
|
||||
let key = Self::key(path)?;
|
||||
Ok(self.focus(&key)?.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Open, Sessions};
|
||||
|
||||
#[test]
|
||||
fn a_renamed_file_stays_one_session() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (a, b) = (dir.path().join("a.md"), dir.path().join("b.md"));
|
||||
std::fs::write(&a, "a").expect("writes");
|
||||
let mut sessions = Sessions::default();
|
||||
let Open::Fresh(id) = sessions.open(&a, false).expect("opens") else {
|
||||
panic!("a first open is fresh");
|
||||
};
|
||||
std::fs::rename(&a, &b).expect("renames");
|
||||
// Opened in the gap: the same file, so focus.
|
||||
assert_eq!(sessions.open(&b, false).expect("opens"), Open::Focus(id));
|
||||
sessions.track(id, &b).expect("moves");
|
||||
assert_eq!(sessions.open(&b, false).expect("opens"), Open::Focus(id));
|
||||
sessions.close(id);
|
||||
assert!(matches!(
|
||||
sessions.open(&b, false).expect("opens"),
|
||||
Open::Fresh(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tracked_save_keeps_a_later_rename_one_session() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (a, b, temp) = (
|
||||
dir.path().join("a.md"),
|
||||
dir.path().join("b.md"),
|
||||
dir.path().join("t"),
|
||||
);
|
||||
std::fs::write(&a, "a").expect("writes");
|
||||
let mut sessions = Sessions::default();
|
||||
let Open::Fresh(id) = sessions.open(&a, false).expect("opens") else {
|
||||
panic!("a first open is fresh");
|
||||
};
|
||||
// An atomic save: a new file takes the name.
|
||||
std::fs::write(&temp, "saved").expect("writes");
|
||||
std::fs::rename(&temp, &a).expect("replaces");
|
||||
sessions.track(id, &a).expect("tracks");
|
||||
std::fs::rename(&a, &b).expect("renames");
|
||||
assert_eq!(sessions.open(&b, false).expect("opens"), Open::Focus(id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_failure_before_writing_shows_until_a_save_lands() {
|
||||
let mut session = Session::new_file();
|
||||
session.edit();
|
||||
session.save_failed();
|
||||
assert_eq!(session.state(), SaveState::SaveFailed);
|
||||
let ticket = session.begin_save().expect("a retry may start");
|
||||
assert_eq!(session.state(), SaveState::Saving);
|
||||
let _ = ticket;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let file = dir.path().join("ro.txt");
|
||||
std::fs::write(&file, "ORIGINAL").expect("writes");
|
||||
let mut read_only = Session::read_only(file.clone()).expect("a read-only view");
|
||||
read_only.save_failed();
|
||||
assert_eq!(
|
||||
read_only.state(),
|
||||
SaveState::ReadOnly,
|
||||
"a failure keeps read-only"
|
||||
);
|
||||
assert!(crate::save(&mut read_only, b"OVERWRITTEN").is_err());
|
||||
assert_eq!(std::fs::read_to_string(&file).expect("reads"), "ORIGINAL");
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
use super::*;
|
||||
|
||||
mod registry;
|
||||
mod revert;
|
||||
|
||||
fn write(path: &Path, body: &str) {
|
||||
std::fs::write(path, body).expect("the fixture file writes");
|
||||
}
|
||||
|
||||
fn saved(session: &mut Session, target: &Path) {
|
||||
let ticket = session.begin_save().expect("the save starts");
|
||||
write(target, "saved");
|
||||
let written = Fingerprint::of(target).expect("the written fingerprint");
|
||||
session
|
||||
.finish_save(ticket, target.to_path_buf(), written)
|
||||
.expect("the save lands");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_file_has_no_path_until_saved() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut session = Session::new_file();
|
||||
assert_eq!(session.path(), None);
|
||||
session.edit();
|
||||
assert_eq!(session.state(), SaveState::Dirty);
|
||||
let target = dir.path().join("草稿 01.md");
|
||||
saved(&mut session, &target);
|
||||
assert_eq!(session.path(), Some(target.as_path()));
|
||||
assert_eq!(session.state(), SaveState::Saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_ticket_never_marks_newer_edits_saved() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("r.txt");
|
||||
write(&target, "0");
|
||||
let mut session = Session::load(target.clone()).expect("the file loads");
|
||||
session.edit();
|
||||
let stale = session.begin_save().expect("the first save starts");
|
||||
session.edit();
|
||||
write(&target, "1");
|
||||
let written = Fingerprint::of(&target).expect("the written fingerprint");
|
||||
session
|
||||
.finish_save(stale, target.clone(), written)
|
||||
.expect("the stale save lands");
|
||||
assert_eq!(session.saved_revision(), 1);
|
||||
assert_eq!(session.revision(), 2);
|
||||
assert_eq!(session.state(), SaveState::Dirty);
|
||||
saved(&mut session, &target);
|
||||
assert_eq!(session.state(), SaveState::Saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reload_kills_old_tickets() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("k.txt");
|
||||
write(&target, "0");
|
||||
let mut session = Session::load(target.clone()).expect("the file loads");
|
||||
session.edit();
|
||||
let stale = session.begin_save().expect("the save starts");
|
||||
session.reload().expect("the file reloads");
|
||||
session.edit();
|
||||
let written = Fingerprint::of(&target).expect("the fingerprint");
|
||||
assert_eq!(
|
||||
session.finish_save(stale, target, written),
|
||||
Err(StaleTicket)
|
||||
);
|
||||
assert_eq!(session.saved_revision(), 0);
|
||||
assert_eq!(session.state(), SaveState::Dirty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_only_view_stays_read_only_through_reload() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("v.txt");
|
||||
write(&target, "0");
|
||||
let mut session = Session::read_only(target.clone()).expect("the view loads");
|
||||
write(&target, "1");
|
||||
session.external_modified();
|
||||
assert_eq!(session.state(), SaveState::ReadOnly);
|
||||
session.reload().expect("the view reloads");
|
||||
assert_eq!(session.state(), SaveState::ReadOnly);
|
||||
assert!(matches!(session.begin_save(), Err(SaveState::ReadOnly)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_save_completion_keeps_a_newer_conflict() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("c.txt");
|
||||
write(&target, "0");
|
||||
let mut session = Session::load(target.clone()).expect("the file loads");
|
||||
session.edit();
|
||||
let ticket = session.begin_save().expect("the save starts");
|
||||
write(&target, "ours");
|
||||
let written = Fingerprint::of(&target).expect("our written fingerprint");
|
||||
write(&target, "external content, longer");
|
||||
session.external_modified();
|
||||
session
|
||||
.finish_save(ticket, target.clone(), written)
|
||||
.expect("our save lands");
|
||||
assert_eq!(session.state(), SaveState::ExternalModified);
|
||||
assert_eq!(session.saved_revision(), 1);
|
||||
assert_eq!(session.fingerprint(), Some(written));
|
||||
assert_ne!(
|
||||
session.fingerprint(),
|
||||
Fingerprint::of(&target).ok(),
|
||||
"the foreign fingerprint is never adopted"
|
||||
);
|
||||
session.reload().expect("the file reloads");
|
||||
assert_eq!(session.state(), SaveState::Saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conflict_refuses_a_plain_save() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("m.txt");
|
||||
write(&target, "0");
|
||||
let mut session = Session::load(target.clone()).expect("the file loads");
|
||||
write(&target, "1");
|
||||
session.external_modified();
|
||||
assert!(matches!(
|
||||
session.begin_save(),
|
||||
Err(SaveState::ExternalModified)
|
||||
));
|
||||
let ticket = session
|
||||
.begin_save_overwrite()
|
||||
.expect("the overwrite starts");
|
||||
write(&target, "kept local");
|
||||
let written = Fingerprint::of(&target).expect("the fingerprint");
|
||||
session
|
||||
.finish_save(ticket, target, written)
|
||||
.expect("the overwrite lands");
|
||||
assert_eq!(session.state(), SaveState::Saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overwrite_keeps_the_access_mode_guards() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("g.txt");
|
||||
write(&target, "0");
|
||||
let mut view = Session::read_only(target.clone()).expect("the view loads");
|
||||
assert!(matches!(
|
||||
view.begin_save_overwrite(),
|
||||
Err(SaveState::ReadOnly)
|
||||
));
|
||||
let mut loading = Session::loading(target);
|
||||
assert!(matches!(
|
||||
loading.begin_save_overwrite(),
|
||||
Err(SaveState::Loading)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conflict_survives_edits_until_resolved() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("e.txt");
|
||||
write(&target, "0");
|
||||
let mut session = Session::load(target.clone()).expect("the file loads");
|
||||
write(&target, "1");
|
||||
session.external_modified();
|
||||
session.edit();
|
||||
assert_eq!(session.state(), SaveState::ExternalModified);
|
||||
assert!(matches!(
|
||||
session.begin_save(),
|
||||
Err(SaveState::ExternalModified)
|
||||
));
|
||||
let ticket = session.begin_save_overwrite().expect("keep-local starts");
|
||||
write(&target, "kept");
|
||||
let written = Fingerprint::of(&target).expect("the fingerprint");
|
||||
session
|
||||
.finish_save(ticket, target, written)
|
||||
.expect("the overwrite lands");
|
||||
assert_eq!(session.state(), SaveState::Saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conflict_survives_a_failed_save() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("s.txt");
|
||||
write(&target, "0");
|
||||
let mut session = Session::load(target.clone()).expect("the file loads");
|
||||
session.edit();
|
||||
let ticket = session.begin_save().expect("the save starts");
|
||||
write(&target, "external");
|
||||
session.external_modified();
|
||||
session.fail_save(ticket).expect("the failure lands");
|
||||
assert_eq!(session.state(), SaveState::SaveFailed);
|
||||
assert!(matches!(
|
||||
session.begin_save(),
|
||||
Err(SaveState::ExternalModified)
|
||||
));
|
||||
session.reload().expect("the file reloads");
|
||||
assert_eq!(session.state(), SaveState::Saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delayed_load_keeps_a_newer_conflict() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("d.txt");
|
||||
write(&target, "fourteen bytes");
|
||||
let loaded = Fingerprint::of(&target).expect("the loaded fingerprint");
|
||||
let mut session = Session::loading(target.clone());
|
||||
write(&target, "external content, much longer than before");
|
||||
session.external_modified();
|
||||
session.finish_load(loaded).expect("the load lands");
|
||||
assert_eq!(session.state(), SaveState::ExternalModified);
|
||||
assert_eq!(session.fingerprint(), Some(loaded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_save_keeps_the_unsaved_content() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("f.txt");
|
||||
write(&target, "0");
|
||||
let mut session = Session::load(target).expect("the file loads");
|
||||
session.edit();
|
||||
session.edit();
|
||||
let ticket = session.begin_save().expect("the save starts");
|
||||
session.fail_save(ticket).expect("the failure lands");
|
||||
assert_eq!(session.state(), SaveState::SaveFailed);
|
||||
assert_eq!(session.revision(), 2);
|
||||
let retry = session.begin_save();
|
||||
assert!(retry.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loading_transitions_cover_success_and_failure() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("big.docx");
|
||||
write(&target, "0");
|
||||
let mut session = Session::loading(target.clone());
|
||||
assert_eq!(session.state(), SaveState::Loading);
|
||||
assert!(matches!(session.begin_save(), Err(SaveState::Loading)));
|
||||
let loaded = Fingerprint::of(&target).expect("the loaded fingerprint");
|
||||
session.finish_load(loaded).expect("the load lands");
|
||||
assert_eq!(session.state(), SaveState::Saved);
|
||||
assert!(session.fingerprint().is_some());
|
||||
let mut failed = Session::loading(target);
|
||||
failed.fail_load().expect("the failure lands");
|
||||
assert_eq!(failed.state(), SaveState::LoadFailed);
|
||||
assert!(matches!(failed.begin_save(), Err(SaveState::LoadFailed)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_running_task_refuses_another() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("t.txt");
|
||||
write(&target, "0");
|
||||
let mut loading = Session::loading(target.clone());
|
||||
write(&target, "external");
|
||||
loading.external_modified();
|
||||
assert!(matches!(
|
||||
loading.begin_save_overwrite(),
|
||||
Err(SaveState::Loading)
|
||||
));
|
||||
|
||||
let mut session = Session::load(target.clone()).expect("the file loads");
|
||||
session.edit();
|
||||
let first = session.begin_save().expect("the first save starts");
|
||||
write(&target, "external again");
|
||||
session.external_modified();
|
||||
session.edit();
|
||||
assert!(matches!(
|
||||
session.begin_save_overwrite(),
|
||||
Err(SaveState::Saving)
|
||||
));
|
||||
write(&target, "first");
|
||||
let written = Fingerprint::of(&target).expect("the fingerprint");
|
||||
session
|
||||
.finish_save(first, target.clone(), written)
|
||||
.expect("the first save lands");
|
||||
assert_eq!(session.state(), SaveState::ExternalModified);
|
||||
let second = session.begin_save_overwrite().expect("keep-local starts");
|
||||
write(&target, "second");
|
||||
let written = Fingerprint::of(&target).expect("the fingerprint");
|
||||
session
|
||||
.finish_save(second, target, written)
|
||||
.expect("the second save lands");
|
||||
assert_eq!(session.state(), SaveState::Saved);
|
||||
assert_eq!(session.saved_revision(), session.revision());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_completion_releases_the_save_slot() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("slot.txt");
|
||||
write(&target, "0");
|
||||
let mut session = Session::load(target.clone()).expect("the file loads");
|
||||
session.edit();
|
||||
let stale = session.begin_save().expect("the save starts");
|
||||
write(&target, "external");
|
||||
session.external_modified();
|
||||
session.reload().expect("the file reloads");
|
||||
let written = Fingerprint::of(&target).expect("the fingerprint");
|
||||
assert_eq!(
|
||||
session.finish_save(stale, target.clone(), written),
|
||||
Err(StaleTicket)
|
||||
);
|
||||
session.edit();
|
||||
let fresh = session.begin_save().expect("the slot released");
|
||||
write(&target, "fresh");
|
||||
let written = Fingerprint::of(&target).expect("the fingerprint");
|
||||
session
|
||||
.finish_save(fresh, target.clone(), written)
|
||||
.expect("the fresh save lands");
|
||||
assert_eq!(session.state(), SaveState::Saved);
|
||||
let written = Fingerprint::of(&target).expect("the fingerprint");
|
||||
assert_eq!(
|
||||
session.finish_save(fresh, target, written),
|
||||
Err(StaleTicket),
|
||||
"a second completion of one ticket is stale"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_load_never_saves_after_external_events() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("fl.txt");
|
||||
write(&target, "0");
|
||||
let mut session = Session::loading(target.clone());
|
||||
session.fail_load().expect("the failure lands");
|
||||
write(&target, "external");
|
||||
session.external_modified();
|
||||
assert!(matches!(
|
||||
session.begin_save_overwrite(),
|
||||
Err(SaveState::LoadFailed)
|
||||
));
|
||||
assert!(matches!(session.begin_save(), Err(SaveState::LoadFailed)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_duplicate_stale_completion_keeps_the_running_save() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("dup.txt");
|
||||
write(&target, "0");
|
||||
let mut session = Session::load(target.clone()).expect("the file loads");
|
||||
session.edit();
|
||||
let stale = session.begin_save().expect("the old save starts");
|
||||
session.reload().expect("the file reloads");
|
||||
let written = Fingerprint::of(&target).expect("the fingerprint");
|
||||
assert_eq!(
|
||||
session.finish_save(stale, target.clone(), written),
|
||||
Err(StaleTicket)
|
||||
);
|
||||
session.edit();
|
||||
let fresh = session.begin_save().expect("the new save starts");
|
||||
let written = Fingerprint::of(&target).expect("the fingerprint");
|
||||
assert_eq!(
|
||||
session.finish_save(stale, target.clone(), written),
|
||||
Err(StaleTicket),
|
||||
"a duplicate stale completion never frees the running save"
|
||||
);
|
||||
write(&target, "fresh");
|
||||
let written = Fingerprint::of(&target).expect("the fingerprint");
|
||||
session
|
||||
.finish_save(fresh, target, written)
|
||||
.expect("the running save lands");
|
||||
assert_eq!(session.state(), SaveState::Saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_load_then_reload_recovers_saving() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("rec.txt");
|
||||
write(&target, "0");
|
||||
let mut session = Session::loading(target.clone());
|
||||
session.fail_load().expect("the failure lands");
|
||||
assert!(matches!(session.begin_save(), Err(SaveState::LoadFailed)));
|
||||
session.reload().expect("the file reloads");
|
||||
assert_eq!(session.state(), SaveState::Saved);
|
||||
session.edit();
|
||||
let ticket = session.begin_save().expect("the save starts after reload");
|
||||
write(&target, "edited");
|
||||
let written = Fingerprint::of(&target).expect("the fingerprint");
|
||||
session
|
||||
.finish_save(ticket, target, written)
|
||||
.expect("the save lands");
|
||||
assert_eq!(session.state(), SaveState::Saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_tasks_have_unique_identities() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let first = dir.path().join("first.md");
|
||||
let second = dir.path().join("second.md");
|
||||
write(&first, "0");
|
||||
let mut session = Session::load(first.clone()).expect("the file loads");
|
||||
session.edit();
|
||||
let one = session.begin_save().expect("the first save starts");
|
||||
write(&first, "1");
|
||||
let written = Fingerprint::of(&first).expect("the fingerprint");
|
||||
session
|
||||
.finish_save(one, first.clone(), written)
|
||||
.expect("the first save lands");
|
||||
let two = session.begin_save().expect("the save-as starts");
|
||||
let written = Fingerprint::of(&first).expect("the fingerprint");
|
||||
assert_eq!(
|
||||
session.finish_save(one, first, written),
|
||||
Err(StaleTicket),
|
||||
"a repeated old callback never frees the save-as task"
|
||||
);
|
||||
write(&second, "1");
|
||||
let written = Fingerprint::of(&second).expect("the fingerprint");
|
||||
session
|
||||
.finish_save(two, second.clone(), written)
|
||||
.expect("the save-as lands");
|
||||
assert_eq!(session.path(), Some(second.as_path()));
|
||||
assert_eq!(session.state(), SaveState::Saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_duplicate_failure_keeps_the_running_save() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("df.txt");
|
||||
write(&target, "0");
|
||||
let mut session = Session::load(target.clone()).expect("the file loads");
|
||||
session.edit();
|
||||
let old = session.begin_save().expect("task A starts");
|
||||
session.fail_save(old).expect("task A fails");
|
||||
session.edit();
|
||||
let running = session.begin_save().expect("task B starts");
|
||||
assert_eq!(
|
||||
session.fail_save(old),
|
||||
Err(StaleTicket),
|
||||
"a repeated old failure never frees task B"
|
||||
);
|
||||
assert_eq!(session.state(), SaveState::Saving);
|
||||
write(&target, "done");
|
||||
let written = Fingerprint::of(&target).expect("the fingerprint");
|
||||
session
|
||||
.finish_save(running, target, written)
|
||||
.expect("task B lands");
|
||||
assert_eq!(session.state(), SaveState::Saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_failure_keeps_the_reloaded_state() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("sf.txt");
|
||||
write(&target, "0");
|
||||
let mut session = Session::load(target.clone()).expect("the file loads");
|
||||
session.edit();
|
||||
let stale = session.begin_save().expect("the save starts");
|
||||
write(&target, "external");
|
||||
session.external_modified();
|
||||
session.reload().expect("the file reloads");
|
||||
assert_eq!(session.fail_save(stale), Err(StaleTicket));
|
||||
assert_eq!(session.state(), SaveState::Saved);
|
||||
session.edit();
|
||||
let fresh = session.begin_save().expect("the slot released");
|
||||
write(&target, "fresh");
|
||||
let written = Fingerprint::of(&target).expect("the fingerprint");
|
||||
session
|
||||
.finish_save(fresh, target, written)
|
||||
.expect("the fresh save lands");
|
||||
assert_eq!(session.state(), SaveState::Saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_load_callback_keeps_the_reloaded_state() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("sl.txt");
|
||||
write(&target, "0");
|
||||
let old = Fingerprint::of(&target).expect("the old fingerprint");
|
||||
let mut session = Session::loading(target.clone());
|
||||
write(&target, "external, longer");
|
||||
session.external_modified();
|
||||
session.reload().expect("the file reloads");
|
||||
session.edit();
|
||||
assert_eq!(session.finish_load(old), Err(StaleTicket));
|
||||
assert_eq!(session.state(), SaveState::Dirty);
|
||||
assert_ne!(session.fingerprint(), Some(old));
|
||||
assert_eq!(session.fail_load(), Err(StaleTicket));
|
||||
assert_eq!(session.state(), SaveState::Dirty);
|
||||
let ticket = session.begin_save().expect("the save starts");
|
||||
write(&target, "current");
|
||||
let written = Fingerprint::of(&target).expect("the fingerprint");
|
||||
session
|
||||
.finish_save(ticket, target, written)
|
||||
.expect("the save lands");
|
||||
assert_eq!(session.state(), SaveState::Saved);
|
||||
}
|
||||
|
||||
mod failed;
|
||||
@@ -0,0 +1,287 @@
|
||||
use super::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_replace_rekeys_links_and_forks_old_ones() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("doc.md");
|
||||
let old = dir.path().join("old.md");
|
||||
write(&target, "0");
|
||||
std::fs::hard_link(&target, &old).expect("the old link");
|
||||
let mut sessions = Sessions::default();
|
||||
assert!(matches!(
|
||||
sessions.open(&target, false).expect("the first open"),
|
||||
Open::Fresh(_)
|
||||
));
|
||||
let staging = dir.path().join("doc.md.tmp");
|
||||
write(&staging, "1");
|
||||
std::fs::rename(&staging, &target).expect("the atomic replace");
|
||||
let alias = dir.path().join("alias.md");
|
||||
std::fs::hard_link(&target, &alias).expect("the new link");
|
||||
assert!(sessions.is_open(&target).expect("the check"));
|
||||
assert_eq!(
|
||||
sessions.open(&alias, false).expect("the alias open"),
|
||||
Open::Focus(0)
|
||||
);
|
||||
assert!(matches!(
|
||||
sessions
|
||||
.open(&old, false)
|
||||
.expect("the old link opens alone"),
|
||||
Open::Fresh(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_twice_focuses_or_marks_a_view() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("doc.txt");
|
||||
write(&target, "0");
|
||||
let mut sessions = Sessions::default();
|
||||
assert!(matches!(
|
||||
sessions.open(&target, false).expect("the first open"),
|
||||
Open::Fresh(_)
|
||||
));
|
||||
assert_eq!(
|
||||
sessions.open(&target, false).expect("the second open"),
|
||||
Open::Focus(0)
|
||||
);
|
||||
assert_eq!(
|
||||
sessions.open(&target, true).expect("the view open"),
|
||||
Open::View(1)
|
||||
);
|
||||
assert!(sessions.is_open(&target).expect("the check"));
|
||||
sessions.close(0);
|
||||
sessions.close(1);
|
||||
assert!(!sessions.is_open(&target).expect("the check"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_links_share_one_session() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("doc.txt");
|
||||
let alias = dir.path().join("alias.txt");
|
||||
write(&target, "0");
|
||||
std::fs::hard_link(&target, &alias).expect("the hard link");
|
||||
let mut sessions = Sessions::default();
|
||||
assert!(matches!(
|
||||
sessions.open(&target, false).expect("the first open"),
|
||||
Open::Fresh(_)
|
||||
));
|
||||
assert_eq!(
|
||||
sessions.open(&alias, false).expect("the alias open"),
|
||||
Open::Focus(0)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_dangling_symlink_fails_loud_and_registers_nothing() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("gone.txt");
|
||||
let link = dir.path().join("link.txt");
|
||||
write(&target, "0");
|
||||
std::os::unix::fs::symlink(&target, &link).expect("the symlink");
|
||||
std::fs::remove_file(&target).expect("the target goes");
|
||||
let mut sessions = Sessions::default();
|
||||
assert!(sessions.open(&link, false).is_err());
|
||||
assert!(sessions.is_open(&link).is_err());
|
||||
let real = dir.path().join("real.txt");
|
||||
write(&real, "0");
|
||||
assert_eq!(
|
||||
sessions.open(&real, false).expect("the open"),
|
||||
Open::Fresh(0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_atomic_replace_keeps_the_session() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("doc.md");
|
||||
write(&target, "0");
|
||||
let mut sessions = Sessions::default();
|
||||
assert!(matches!(
|
||||
sessions.open(&target, false).expect("the first open"),
|
||||
Open::Fresh(_)
|
||||
));
|
||||
let staging = dir.path().join("doc.md.tmp");
|
||||
write(&staging, "1");
|
||||
std::fs::rename(&staging, &target).expect("the atomic replace");
|
||||
assert!(sessions.is_open(&target).expect("the check"));
|
||||
assert_eq!(
|
||||
sessions.open(&target, false).expect("the second open"),
|
||||
Open::Focus(0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_file_fails_open_loud() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let missing = dir.path().join("nope.txt");
|
||||
let mut sessions = Sessions::default();
|
||||
assert!(sessions.open(&missing, false).is_err());
|
||||
assert!(sessions.is_open(&missing).is_err());
|
||||
let real = dir.path().join("real.txt");
|
||||
write(&real, "0");
|
||||
assert_eq!(
|
||||
sessions.open(&real, false).expect("the open"),
|
||||
Open::Fresh(0)
|
||||
);
|
||||
std::fs::remove_file(&real).expect("the file goes");
|
||||
assert!(sessions.is_open(&real).is_err());
|
||||
assert!(sessions.open(&real, false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_file_fails_loud() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let missing = dir.path().join("nope.txt");
|
||||
assert!(Session::load(missing.clone()).is_err());
|
||||
assert!(Fingerprint::of(&missing).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_revoked_entry_path_fails_loud() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let restricted = dir.path().join("restricted");
|
||||
std::fs::create_dir(&restricted).expect("the dir");
|
||||
let target = restricted.join("doc.md");
|
||||
let alias = dir.path().join("alias.md");
|
||||
write(&target, "0");
|
||||
std::fs::hard_link(&target, &alias).expect("the hard link");
|
||||
let mut sessions = Sessions::default();
|
||||
assert!(matches!(
|
||||
sessions.open(&target, false).expect("the first open"),
|
||||
Open::Fresh(_)
|
||||
));
|
||||
std::fs::set_permissions(&restricted, std::fs::Permissions::from_mode(0o000))
|
||||
.expect("the lockdown");
|
||||
let opened = sessions.open(&alias, false);
|
||||
let checked = sessions.is_open(&alias);
|
||||
std::fs::set_permissions(&restricted, std::fs::Permissions::from_mode(0o755))
|
||||
.expect("the restore");
|
||||
assert!(opened.is_err());
|
||||
assert!(checked.is_err());
|
||||
assert_eq!(
|
||||
sessions.open(&alias, false).expect("the alias open"),
|
||||
Open::Focus(0)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_failed_identity_query_registers_nothing() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let first = dir.path().join("first.md");
|
||||
let restricted = dir.path().join("restricted");
|
||||
std::fs::create_dir(&restricted).expect("the dir");
|
||||
let second = restricted.join("second.md");
|
||||
write(&first, "0");
|
||||
write(&second, "0");
|
||||
let mut sessions = Sessions::default();
|
||||
assert!(matches!(
|
||||
sessions.open(&first, false).expect("the first open"),
|
||||
Open::Fresh(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
sessions.open(&second, false).expect("the second open"),
|
||||
Open::Fresh(_)
|
||||
));
|
||||
std::fs::set_permissions(&restricted, std::fs::Permissions::from_mode(0o000))
|
||||
.expect("the lockdown");
|
||||
let viewed = sessions.open(&first, true);
|
||||
std::fs::set_permissions(&restricted, std::fs::Permissions::from_mode(0o755))
|
||||
.expect("the restore");
|
||||
assert_eq!(
|
||||
viewed.expect("a definitive match outranks the stuck entry"),
|
||||
Open::View(2)
|
||||
);
|
||||
sessions.close(0);
|
||||
sessions.close(2);
|
||||
assert!(!sessions.is_open(&first).expect("no phantom entry"));
|
||||
assert!(matches!(
|
||||
sessions.open(&first, false).expect("the re-open"),
|
||||
Open::Fresh(3)
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_focus_returns_before_unreachable_entries() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let first = dir.path().join("first.md");
|
||||
let restricted = dir.path().join("restricted");
|
||||
std::fs::create_dir(&restricted).expect("the dir");
|
||||
let second = restricted.join("second.md");
|
||||
write(&first, "0");
|
||||
write(&second, "0");
|
||||
let mut sessions = Sessions::default();
|
||||
assert!(matches!(
|
||||
sessions.open(&first, false).expect("the first open"),
|
||||
Open::Fresh(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
sessions.open(&second, false).expect("the second open"),
|
||||
Open::Fresh(_)
|
||||
));
|
||||
std::fs::set_permissions(&restricted, std::fs::Permissions::from_mode(0o000))
|
||||
.expect("the lockdown");
|
||||
let focused = sessions.open(&first, false);
|
||||
let checked = sessions.is_open(&first);
|
||||
std::fs::set_permissions(&restricted, std::fs::Permissions::from_mode(0o755))
|
||||
.expect("the restore");
|
||||
assert_eq!(focused.expect("the focus"), Open::Focus(0));
|
||||
assert!(checked.expect("the check"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_focus_ignores_unreachable_entries_after_a_match() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let restricted = dir.path().join("restricted");
|
||||
std::fs::create_dir(&restricted).expect("the dir");
|
||||
let first = restricted.join("first.md");
|
||||
let second = dir.path().join("second.md");
|
||||
write(&first, "0");
|
||||
write(&second, "0");
|
||||
let mut sessions = Sessions::default();
|
||||
assert!(matches!(
|
||||
sessions.open(&first, false).expect("the first open"),
|
||||
Open::Fresh(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
sessions.open(&second, false).expect("the second open"),
|
||||
Open::Fresh(_)
|
||||
));
|
||||
std::fs::set_permissions(&restricted, std::fs::Permissions::from_mode(0o000))
|
||||
.expect("the lockdown");
|
||||
let focused = sessions.open(&second, false);
|
||||
let checked = sessions.is_open(&second);
|
||||
std::fs::set_permissions(&restricted, std::fs::Permissions::from_mode(0o755))
|
||||
.expect("the restore");
|
||||
assert_eq!(focused.expect("the focus"), Open::Focus(1));
|
||||
assert!(checked.expect("the check"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_rename_is_found_by_identity_not_by_link() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let first = dir.path().join("a.md");
|
||||
write(&first, "0");
|
||||
let mut sessions = Sessions::default();
|
||||
let Open::Fresh(id) = sessions.open(&first, false).expect("the open") else {
|
||||
panic!("a fresh session")
|
||||
};
|
||||
let moved = dir.path().join("b.md");
|
||||
std::fs::rename(&first, &moved).expect("the rename");
|
||||
std::os::unix::fs::symlink(&moved, dir.path().join("link.md")).expect("the link");
|
||||
let found = sessions.renamed_in(id, dir.path()).expect("the scan");
|
||||
assert_eq!(found, Some(moved.clone()));
|
||||
std::fs::remove_file(&moved).expect("the delete");
|
||||
assert_eq!(sessions.renamed_in(id, dir.path()).expect("the scan"), None);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Undo back to the saved content (FILE-02).
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn undo_to_the_saved_content_is_clean_but_a_conflict_stays() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("r.pdf");
|
||||
write(&target, "0");
|
||||
let mut session = Session::load(target.clone()).expect("the file loads");
|
||||
session.edit();
|
||||
session.reverted();
|
||||
assert_eq!(session.state(), SaveState::Saved);
|
||||
session.edit();
|
||||
let _ticket = session.begin_save().expect("the save starts");
|
||||
session.reverted();
|
||||
assert_eq!(
|
||||
session.state(),
|
||||
SaveState::Saving,
|
||||
"a save in flight decides"
|
||||
);
|
||||
let mut changed = Session::load(target.clone()).expect("the file loads");
|
||||
changed.external_modified();
|
||||
changed.reverted();
|
||||
assert_eq!(changed.state(), SaveState::ExternalModified);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rebase_saves_against_the_seen_disk_only() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let target = dir.path().join("m.md");
|
||||
write(&target, "base");
|
||||
let mut session = Session::load(target.clone()).expect("the file loads");
|
||||
session.edit();
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
write(&target, "seen change");
|
||||
session.external_modified();
|
||||
let seen = Fingerprint::of(&target).expect("the fingerprint");
|
||||
session.rebase(seen);
|
||||
assert_eq!(session.state(), SaveState::Dirty);
|
||||
assert!(!session.external_changed().expect("the check"));
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
write(&target, "a later change");
|
||||
assert!(session.external_changed().expect("the check"), "a later write still conflicts");
|
||||
assert!(matches!(
|
||||
crate::save(&mut session, b"local"),
|
||||
Err(crate::SaveError::Conflict)
|
||||
));
|
||||
assert_eq!(std::fs::read(&target).expect("the file"), b"a later change");
|
||||
}
|
||||
Reference in new issue
Block a user