mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
M3.7: auto-save + crash recovery
PRJ-7. `engine::autosave` with the session hooks the frontends call:
- Auto-save: every N minutes a dirty project is written to "EffectCraft Auto-Save" next to
it (or a custom folder; untitled projects use the custom folder or the config dir) as
"<name> auto-save N.ecproj", rotating through max versions (oldest overwritten, extra
slots dropped when the maximum shrinks). Optional save when the render queue starts.
- Atomic writes for projects, auto-saves and config files: temp file + fsync + rename; a
failed write removes the temp file and leaves the previous file intact.
- Crash recovery: a session.lock sentinel in the config dir records the open project and
its latest auto-save; a clean exit removes it. At launch a leftover sentinel offers the
latest auto-save in a Recover Project dialog (Settings > Startup can turn it off).
- File > Open Recent (stored in the settings) with Clear Recent Projects; Increment and
Save uses After Effects naming ("Intro 2.ecproj"); Revert reloads the saved file.
- Commands: file.autoSave, file.recoveryInfo, file.openRecent {index|path},
file.clearRecent.
- Tests: rotation, interval/dirty tick, simulated crash mid-write, unclean-exit detection
and recovery, Open Recent / Increment and Save / Revert.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
35f429363e
commit
590f2f67ba
17 files changed
+651
-22
No files matched your search
@@ -180,7 +180,7 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING
|
||||
|
||||
EffectCraft is young. The core of After Effects is in place and gets deeper every week: what is
|
||||
done and what comes next is in the [ROADMAP](ROADMAP.md). Not there yet: motion tracking, puppet,
|
||||
paint and roto tools, Lottie, preferences and a shortcut editor, and GPU compositing. The web build
|
||||
paint and roto tools, Lottie and GPU compositing. The web build
|
||||
([docs/web.md](docs/web.md)) runs the full app in the browser, single-threaded for now. Projects
|
||||
are saved as `.ecproj`, readable versioned JSON; After Effects `.aep` files cannot be opened.
|
||||
|
||||
|
||||
+2
-2
@@ -19,8 +19,8 @@ once.
|
||||
| M10 | Export: render queue, H.264, ProRes, image sequences, GIF, audio | Done |
|
||||
| M11 | Animation tools: audio playback, meters and waveforms (done); presets, Motion Sketch, Wiggler, Lottie | In progress |
|
||||
| M12 | Performance: layer cache and parallel compositing (done), GPU compositing, disk cache, motion tracking | In progress |
|
||||
| M13+ | Puppet, paint, roto, motion tracking, preferences and shortcut editor, a plugin API | Planned |
|
||||
| M13+ | Puppet, paint, roto, motion tracking, a plugin API | Planned |
|
||||
| M15 | The web app (WebAssembly, WebGPU) | Done, single-threaded |
|
||||
| M14 | Built for agents: MCP server, command-line tool, control channel | Done |
|
||||
| M14 | Built for agents: MCP server, command-line tool, control channel; Settings, keyboard shortcut editor, auto-save and crash recovery ([docs/preferences.md](docs/preferences.md)) | Done |
|
||||
|
||||
Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
@@ -46,11 +46,13 @@ fn main() -> eframe::Result {
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut session = effectcraft_host::session();
|
||||
// Settings and shortcut presets live in the platform config directory.
|
||||
// Settings, shortcut presets and the crash-recovery sentinel live in the platform
|
||||
// config directory. Agent-driven runs (`--control`) skip crash recovery.
|
||||
if let Some(dir) = config_dir() {
|
||||
session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir)));
|
||||
}
|
||||
session.load_settings();
|
||||
let recovery = if control_port.is_none() { session.begin_recovery() } else { None };
|
||||
let project = files.iter().find(|f| f.ends_with(".ecproj")).cloned();
|
||||
if let Some(p) = project {
|
||||
if let Err(e) = session.execute("file.open", json!({"path": p})) {
|
||||
@@ -68,6 +70,9 @@ fn main() -> eframe::Result {
|
||||
let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none());
|
||||
let mut app = EffectcraftApp::new(session);
|
||||
app.ui.start_screen = show_home;
|
||||
if let Some(r) = recovery {
|
||||
app.offer_recovery(r);
|
||||
}
|
||||
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
|
||||
rfd::FileDialog::new().add_filter("Media", exts).pick_files().unwrap_or_default().into_iter().map(|p| p.to_string_lossy().to_string()).collect()
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Auto-save and crash recovery (PRJ-7).
|
||||
//!
|
||||
//! - **Auto-save**: every *n* minutes a dirty project is written to an "EffectCraft Auto-Save"
|
||||
//! folder next to it (or a custom folder) as `<name> auto-save N.ecproj`, the After Effects
|
||||
//! naming. Slots rotate through 1…max versions, overwriting the oldest. Writes are atomic
|
||||
//! (temporary file + rename), so a crash mid-write never damages the previous auto-save.
|
||||
//! - **Crash recovery**: while the app runs, a sentinel (`session.lock` in the config store)
|
||||
//! records the open project and its latest auto-save. A clean exit removes it; finding one at
|
||||
//! launch means the last session ended unexpectedly, and the frontend offers to open the
|
||||
//! latest auto-save.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::ConfigStore;
|
||||
use crate::prefs::Prefs;
|
||||
|
||||
/// Folder created next to a project for its auto-saves.
|
||||
pub const AUTOSAVE_FOLDER: &str = "EffectCraft Auto-Save";
|
||||
/// Crash-recovery sentinel in the config store.
|
||||
pub const SENTINEL: &str = "session.lock";
|
||||
const EXT: &str = "ecproj";
|
||||
|
||||
/// Auto-save bookkeeping of a session.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct AutoSaveState {
|
||||
/// Wall-clock seconds of the last auto-save (or of the first tick).
|
||||
pub last: Option<f64>,
|
||||
/// Slot written last in this session.
|
||||
pub last_slot: Option<u32>,
|
||||
/// Revision written last (an unchanged project isn't saved again).
|
||||
pub saved_revision: Option<u64>,
|
||||
/// Path of the latest auto-save.
|
||||
pub last_path: Option<String>,
|
||||
/// This session owns the crash-recovery sentinel ([`crate::Session::begin_recovery`]).
|
||||
pub sentinel: bool,
|
||||
}
|
||||
|
||||
/// File-name stem of a project (`/a/Intro.ecproj` → `Intro`; untitled → `Untitled Project`).
|
||||
pub fn project_stem(path: Option<&str>) -> String {
|
||||
path.and_then(|p| Path::new(p).file_stem()).map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled Project".into())
|
||||
}
|
||||
|
||||
/// `<stem> auto-save N.ecproj`.
|
||||
pub fn file_name(stem: &str, n: u32) -> String {
|
||||
format!("{stem} auto-save {n}.{EXT}")
|
||||
}
|
||||
|
||||
/// The slot number of an auto-save file name for `stem`.
|
||||
pub fn slot_of(stem: &str, name: &str) -> Option<u32> {
|
||||
let rest = name.strip_prefix(stem)?.strip_prefix(" auto-save ")?;
|
||||
rest.strip_suffix(&format!(".{EXT}"))?.parse().ok()
|
||||
}
|
||||
|
||||
/// Where a project's auto-saves go: next to the project, or the custom folder; untitled projects
|
||||
/// use the custom folder or `fallback` (the app's own Auto-Save folder).
|
||||
pub fn folder(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) -> Option<PathBuf> {
|
||||
let a = &prefs.auto_save;
|
||||
if a.location == "custom" && !a.folder.trim().is_empty() {
|
||||
return Some(PathBuf::from(a.folder.trim()));
|
||||
}
|
||||
match project.and_then(|p| Path::new(p).parent()) {
|
||||
Some(dir) => Some(dir.join(AUTOSAVE_FOLDER)),
|
||||
None => fallback.map(|f| f.join(AUTOSAVE_FOLDER)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Existing auto-saves of `stem` in `dir`: (slot, path, modified).
|
||||
pub fn existing(dir: &Path, stem: &str) -> Vec<(u32, PathBuf, std::time::SystemTime)> {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
|
||||
let mut v: Vec<_> = rd
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
let n = slot_of(stem, &name)?;
|
||||
let m = e.metadata().ok()?.modified().ok()?;
|
||||
Some((n, e.path(), m))
|
||||
})
|
||||
.collect();
|
||||
v.sort_by_key(|x| x.0);
|
||||
v
|
||||
}
|
||||
|
||||
/// The slot to write next: after `last` (this session), else after the newest file on disk,
|
||||
/// wrapping at `max`.
|
||||
pub fn next_slot(existing: &[(u32, PathBuf, std::time::SystemTime)], last: Option<u32>, max: u32) -> u32 {
|
||||
let max = max.max(1);
|
||||
let after = last.or_else(|| existing.iter().filter(|e| e.0 <= max).max_by_key(|e| (e.2, e.0)).map(|e| e.0));
|
||||
match after {
|
||||
Some(n) => n % max + 1,
|
||||
None => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write one auto-save of `json` for the project at `project` and rotate. Returns its path.
|
||||
pub fn write(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>, last_slot: Option<u32>, json: &str) -> std::io::Result<(PathBuf, u32)> {
|
||||
let dir = folder(prefs, project, fallback).ok_or_else(|| std::io::Error::other("no auto-save folder for an untitled project"))?;
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let stem = project_stem(project);
|
||||
let max = prefs.auto_save.max_versions.max(1);
|
||||
let have = existing(&dir, &stem);
|
||||
let slot = next_slot(&have, last_slot, max);
|
||||
let path = dir.join(file_name(&stem, slot));
|
||||
crate::config::atomic_write(&path, json.as_bytes())?;
|
||||
// Max versions lowered: drop slots above it.
|
||||
for (n, p, _) in have {
|
||||
if n > max {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
}
|
||||
Ok((path, slot))
|
||||
}
|
||||
|
||||
/// The newest auto-save of a project, if any.
|
||||
pub fn latest(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>) -> Option<PathBuf> {
|
||||
let dir = folder(prefs, project, fallback)?;
|
||||
existing(&dir, &project_stem(project)).into_iter().max_by_key(|e| e.2).map(|e| e.1)
|
||||
}
|
||||
|
||||
/// What the crash-recovery sentinel records.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Sentinel {
|
||||
pub pid: u32,
|
||||
/// The open project (None = untitled).
|
||||
pub project: Option<String>,
|
||||
/// Latest auto-save of this run.
|
||||
pub autosave: Option<String>,
|
||||
/// Whether the project had unsaved changes.
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
/// A previous run that didn't exit cleanly.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Recovery {
|
||||
pub project: Option<String>,
|
||||
/// The auto-save to offer (exists on disk).
|
||||
pub autosave: Option<String>,
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
/// At launch: report an unclean previous exit (a leftover sentinel), then start this run's
|
||||
/// sentinel.
|
||||
pub fn begin(store: &dyn ConfigStore, prefs: &Prefs) -> Option<Recovery> {
|
||||
let found = store.read(SENTINEL).map(|t| serde_json::from_str::<Sentinel>(&t).unwrap_or_default());
|
||||
let _ = store.write(SENTINEL, &serde_json::to_string(&Sentinel { pid: pid(), ..Default::default() }).unwrap_or_default());
|
||||
let s = found?;
|
||||
let fallback = store.dir();
|
||||
let autosave = s
|
||||
.autosave
|
||||
.clone()
|
||||
.filter(|p| Path::new(p).is_file())
|
||||
.or_else(|| latest(prefs, s.project.as_deref(), fallback.as_deref()).map(|p| p.to_string_lossy().to_string()));
|
||||
Some(Recovery { project: s.project, autosave, dirty: s.dirty })
|
||||
}
|
||||
|
||||
/// This process id (0 where processes don't exist, e.g. the browser).
|
||||
pub fn pid() -> u32 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return std::process::id();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
0
|
||||
}
|
||||
|
||||
/// Record the current project / latest auto-save in the sentinel.
|
||||
pub fn update(store: &dyn ConfigStore, s: &Sentinel) {
|
||||
let _ = store.write(SENTINEL, &serde_json::to_string(s).unwrap_or_default());
|
||||
}
|
||||
|
||||
/// Clean exit: remove the sentinel.
|
||||
pub fn end(store: &dyn ConfigStore) {
|
||||
let _ = store.remove(SENTINEL);
|
||||
}
|
||||
|
||||
/// After Effects' Increment and Save name: `Intro.ecproj` → `Intro 2.ecproj`, `Intro 2.ecproj`
|
||||
/// → `Intro 3.ecproj` (skipping names that exist).
|
||||
pub fn increment_path(path: &str, exists: impl Fn(&str) -> bool) -> String {
|
||||
let p = Path::new(path);
|
||||
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let (base, mut n) = match stem.rsplit_once(' ') {
|
||||
Some((b, n)) if !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()) => (b.to_string(), n.parse::<u32>().unwrap_or(1) + 1),
|
||||
_ => (stem.clone(), 2),
|
||||
};
|
||||
loop {
|
||||
let cand = p.with_file_name(format!("{base} {n}.{EXT}")).to_string_lossy().to_string();
|
||||
if !exists(&cand) {
|
||||
return cand;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,11 @@ fn new_project(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let bytes = s.services.read_file(&tpl).map_err(|e| EngineError::Other(format!("cannot read the new project template {tpl}: {e}")))?;
|
||||
let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("the new project template is not a project file".into()))?;
|
||||
s.replace_project(Project::from_json(&text)?, None);
|
||||
s.update_sentinel();
|
||||
return Ok(json!({"template": tpl}));
|
||||
}
|
||||
s.replace_project(Project::default(), None);
|
||||
s.update_sentinel();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
@@ -34,12 +36,13 @@ fn demo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({"comp": s.state.active_comp.map(|c| c.0)}))
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?;
|
||||
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||||
let text = String::from_utf8(bytes).map_err(|_| EngineError::Other("not a text project file".into()))?;
|
||||
let proj = Project::from_json(&text)?;
|
||||
s.replace_project(proj, Some(path.to_string()));
|
||||
s.note_project_path(path);
|
||||
Ok(json!({"path": path}))
|
||||
}
|
||||
|
||||
@@ -48,6 +51,7 @@ fn save_to(s: &mut Session, path: &str) -> Result<Value> {
|
||||
s.services.write_file(path, json.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
||||
s.path = Some(path.to_string());
|
||||
s.saved_revision = s.revision;
|
||||
s.note_project_path(path);
|
||||
s.toast(format!("Saved {path}"));
|
||||
Ok(json!({"path": path, "bytes": json.len()}))
|
||||
}
|
||||
@@ -64,12 +68,9 @@ fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn increment_save(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let cur = s.path.clone().ok_or_else(|| bad("file.incrementAndSave", "save the project first"))?;
|
||||
let stem = cur.trim_end_matches(".ecproj");
|
||||
let (base, n) = match stem.rsplit_once('_') {
|
||||
Some((b, n)) if n.chars().all(|c| c.is_ascii_digit()) && !n.is_empty() => (b.to_string(), n.parse::<u32>().unwrap_or(0) + 1),
|
||||
_ => (stem.to_string(), 2),
|
||||
};
|
||||
save_to(s, &format!("{base}_{n}.ecproj"))
|
||||
// After Effects: `Intro.aep` → `Intro 2.aep` → `Intro 3.aep`.
|
||||
let next = crate::autosave::increment_path(&cur, |p| std::path::Path::new(p).exists());
|
||||
save_to(s, &next)
|
||||
}
|
||||
|
||||
fn revert(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
@@ -344,6 +344,14 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn render(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let wait = b_p(p, "wait").unwrap_or(true);
|
||||
// Settings ▸ Project ▸ Auto-Save ▸ Save When Starting Render Queue.
|
||||
if s.prefs.auto_save.enabled
|
||||
&& s.prefs.auto_save.save_on_render_start
|
||||
&& s.is_dirty()
|
||||
&& let Err(e) = s.autosave_now()
|
||||
{
|
||||
log::warn!("{e}");
|
||||
}
|
||||
let ids = s.start_render(wait).map_err(EngineError::Other)?;
|
||||
let items: Vec<Value> = s.project.render_queue.iter().enumerate().filter(|(_, i)| ids.contains(&i.id)).map(|(k, i)| item_json(s, i, k)).collect();
|
||||
Ok(json!({"rendering": s.is_rendering(), "items": items}))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Settings (`prefs.*`) and keyboard shortcut presets (`shortcuts.*`).
|
||||
//! Settings (`prefs.*`), keyboard shortcut presets (`shortcuts.*`), File ▸ Open Recent and
|
||||
//! auto-save commands.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -198,6 +199,55 @@ fn sc_conflicts(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!(c))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- recent / auto-save
|
||||
|
||||
fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = match (str_p(p, "path"), p.get("index").and_then(Value::as_u64)) {
|
||||
(Some(path), _) => path.to_string(),
|
||||
(None, Some(i)) => s.prefs.recent_projects.get(i as usize).cloned().ok_or_else(|| bad("file.openRecent", format!("no recent project #{i}")))?,
|
||||
_ => s.prefs.recent_projects.first().cloned().ok_or_else(|| bad("file.openRecent", "no recent projects"))?,
|
||||
};
|
||||
match super::file::open(s, &json!({"path": path})) {
|
||||
Ok(v) => Ok(v),
|
||||
Err(e) => {
|
||||
// A project that's gone drops out of the list.
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
s.prefs.recent_projects.retain(|x| *x != path);
|
||||
s.save_prefs();
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_recent(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.prefs.recent_projects.clear();
|
||||
s.save_prefs();
|
||||
s.prefs_revision += 1;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn has_recent(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.prefs.recent_projects.is_empty() { Err("no recent projects".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn auto_save(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let path = s.autosave_now()?;
|
||||
s.toast(format!("Auto-saved to {path}"));
|
||||
Ok(json!({"path": path}))
|
||||
}
|
||||
|
||||
fn recovery_info(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let root = s.default_autosave_root();
|
||||
let latest = crate::autosave::latest(&s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string());
|
||||
Ok(json!({
|
||||
"lastAutoSave": s.autosave.last_path,
|
||||
"latestOnDisk": latest,
|
||||
"folder": crate::autosave::folder(&s.prefs, s.path.as_deref(), root.as_deref()).map(|p| p.to_string_lossy().to_string()),
|
||||
"recentProjects": s.prefs.recent_projects,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
query!("prefs.get", "Get Setting", "{key?: e.g. general.undoLevels (omit for all settings)}", prefs_get),
|
||||
@@ -220,5 +270,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("shortcuts.export", "Export Keyboard Shortcuts", [], None, "{preset?, path?}", always, sc_export),
|
||||
cmd!("shortcuts.import", "Import Keyboard Shortcuts", [], None, "{path? | preset?: exported document, name?}", always, sc_import),
|
||||
query!("shortcuts.conflicts", "Keyboard Shortcut Conflicts", "{}", sc_conflicts),
|
||||
cmd!("file.openRecent", "Open Recent", [], None, "{index? | path?}", has_recent, open_recent),
|
||||
cmd!("file.clearRecent", "Clear Recent Projects", ["File", "Open Recent"], None, "{}", has_recent, clear_recent),
|
||||
cmd!("file.autoSave", "Auto-Save Now", [], None, "{}", always, auto_save),
|
||||
query!("file.recoveryInfo", "Auto-Save Status", "{}", recovery_info),
|
||||
]
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
//! The project is an `Arc<Project>` edited copy-on-write; undo keeps whole-project snapshots
|
||||
//! (compositions are `Arc`s, so untouched comps are shared).
|
||||
|
||||
pub mod autosave;
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod demo;
|
||||
@@ -68,10 +69,7 @@ impl Services for FsServices {
|
||||
std::fs::read(path)
|
||||
}
|
||||
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
|
||||
let p = std::path::Path::new(path);
|
||||
let tmp = p.with_extension("ecproj.tmp");
|
||||
std::fs::write(&tmp, data)?;
|
||||
std::fs::rename(&tmp, p)
|
||||
config::atomic_write(std::path::Path::new(path), data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +223,8 @@ pub struct Session {
|
||||
pub ui_commands: Vec<shortcuts::UiCommand>,
|
||||
/// Cache of the resolved active preset (read it with [`Session::shortcuts`]).
|
||||
pub shortcut_table: std::sync::OnceLock<shortcuts::ShortcutTable>,
|
||||
/// Auto-save bookkeeping.
|
||||
pub autosave: autosave::AutoSaveState,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -252,6 +252,7 @@ impl Default for Session {
|
||||
keymaps: shortcuts::Keymaps::default(),
|
||||
ui_commands: vec![],
|
||||
shortcut_table: std::sync::OnceLock::new(),
|
||||
autosave: autosave::AutoSaveState::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,6 +303,8 @@ File
|
||||
New Project | file.newProject
|
||||
New Folder | project.newFolder
|
||||
Open Project... | file.open
|
||||
Open Recent
|
||||
Clear Recent Projects | file.clearRecent
|
||||
---
|
||||
Close | file.close
|
||||
Close Project | file.closeProject
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! Session side of Settings and keyboard shortcut presets.
|
||||
//! Session side of Settings, keyboard shortcut presets, auto-save and crash recovery.
|
||||
|
||||
use crate::Session;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::autosave::{self, Sentinel};
|
||||
use crate::prefs::{PREFS_FILE, Prefs};
|
||||
use crate::shortcuts::{Keymaps, SHORTCUTS_FILE, ShortcutTable, UiCommand};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
impl Session {
|
||||
/// Load settings and shortcut presets from the config store and apply them.
|
||||
@@ -62,4 +65,88 @@ impl Session {
|
||||
pub fn shortcuts(&self) -> &ShortcutTable {
|
||||
self.shortcut_table.get_or_init(|| ShortcutTable::build(&self.keymaps, &self.ui_commands))
|
||||
}
|
||||
|
||||
/// Remember a project in File ▸ Open Recent and in the crash-recovery sentinel.
|
||||
pub(crate) fn note_project_path(&mut self, path: &str) {
|
||||
self.prefs.push_recent(path);
|
||||
self.save_prefs();
|
||||
self.autosave = autosave::AutoSaveState { last: self.autosave.last, sentinel: self.autosave.sentinel, ..Default::default() };
|
||||
self.update_sentinel();
|
||||
}
|
||||
|
||||
/// Refresh the crash-recovery sentinel (when one is active).
|
||||
pub fn update_sentinel(&self) {
|
||||
if let Some(c) = &self.config
|
||||
&& self.autosave.sentinel
|
||||
{
|
||||
let s = Sentinel { pid: autosave::pid(), project: self.path.clone(), autosave: self.autosave.last_path.clone(), dirty: self.is_dirty() };
|
||||
autosave::update(c.as_ref(), &s);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback folder for auto-saves of untitled projects (next to the settings).
|
||||
pub fn default_autosave_root(&self) -> Option<std::path::PathBuf> {
|
||||
self.config.as_ref().and_then(|c| c.dir())
|
||||
}
|
||||
|
||||
/// Write an auto-save now (whether or not the project is dirty). Returns its path.
|
||||
pub fn autosave_now(&mut self) -> Result<String> {
|
||||
let json = self.project.to_json();
|
||||
let root = self.default_autosave_root();
|
||||
let (path, slot) = autosave::write(&self.prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot, &json)
|
||||
.map_err(|e| EngineError::Other(format!("auto-save failed: {e}")))?;
|
||||
let p = path.to_string_lossy().to_string();
|
||||
self.autosave.last_slot = Some(slot);
|
||||
self.autosave.saved_revision = Some(self.revision);
|
||||
self.autosave.last_path = Some(p.clone());
|
||||
self.update_sentinel();
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// Call periodically with wall-clock seconds: auto-saves a dirty project once the interval
|
||||
/// has passed since the last auto-save (or since the first call). Returns the written path.
|
||||
pub fn autosave_tick(&mut self, now: f64) -> Option<Result<String>> {
|
||||
let a = &self.prefs.auto_save;
|
||||
if !a.enabled {
|
||||
self.autosave.last = Some(now);
|
||||
return None;
|
||||
}
|
||||
let interval = a.interval_minutes.max(1) as f64 * 60.0;
|
||||
let last = *self.autosave.last.get_or_insert(now);
|
||||
if now - last < interval {
|
||||
return None;
|
||||
}
|
||||
self.autosave.last = Some(now);
|
||||
if !self.is_dirty() || self.autosave.saved_revision == Some(self.revision) || self.render_job.is_some() {
|
||||
return None;
|
||||
}
|
||||
if self.path.is_none() && self.prefs.auto_save.location != "custom" && self.default_autosave_root().is_none() {
|
||||
return None;
|
||||
}
|
||||
let r = self.autosave_now();
|
||||
if let Ok(p) = &r {
|
||||
self.toast(format!("Auto-saved {}", Path::new(p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()));
|
||||
}
|
||||
Some(r)
|
||||
}
|
||||
|
||||
/// At launch: start crash recovery. Returns the previous run's state when it didn't exit
|
||||
/// cleanly (and the setting asks for it).
|
||||
pub fn begin_recovery(&mut self) -> Option<autosave::Recovery> {
|
||||
let c = self.config.clone()?;
|
||||
let r = autosave::begin(c.as_ref(), &self.prefs);
|
||||
self.autosave.sentinel = true;
|
||||
self.update_sentinel();
|
||||
r.filter(|_| self.prefs.startup.offer_crash_recovery)
|
||||
}
|
||||
|
||||
/// Clean exit: remove the crash-recovery sentinel.
|
||||
pub fn end_recovery(&mut self) {
|
||||
if let Some(c) = &self.config
|
||||
&& self.autosave.sentinel
|
||||
{
|
||||
autosave::end(c.as_ref());
|
||||
self.autosave.sentinel = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
//! Settings and keyboard shortcut presets.
|
||||
//! Settings, keyboard shortcut presets, auto-save and crash recovery.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::config::{ConfigStore, MemoryConfig};
|
||||
use crate::config::{ConfigStore, DirConfig, MemoryConfig, atomic_write, atomic_write_with, temp_path};
|
||||
use crate::prefs::{PREFS_FILE, Prefs};
|
||||
use crate::shortcuts::{DEFAULT_PRESET, Keymaps, normalize};
|
||||
use crate::{Session, autosave};
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
@@ -326,3 +326,141 @@ fn frontend_commands_are_bindable_with_panel_scope() {
|
||||
let c = s.shortcuts().conflicts(&b, "H");
|
||||
assert!(c.iter().any(|c| c.key == "tool.hand"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- auto-save / recovery
|
||||
|
||||
#[test]
|
||||
fn atomic_write_survives_a_crash_mid_write() {
|
||||
let d = tmp("atomic");
|
||||
let f = d.join("p.ecproj");
|
||||
atomic_write(&f, b"version 1").unwrap();
|
||||
// The writer dies halfway through the temporary file.
|
||||
let r = atomic_write_with(&f, b"version 2 is much longer", |tmp, data| {
|
||||
std::fs::write(tmp, &data[..5])?;
|
||||
Err(std::io::Error::other("power cut"))
|
||||
});
|
||||
assert!(r.is_err());
|
||||
assert_eq!(std::fs::read(&f).unwrap(), b"version 1");
|
||||
assert!(!temp_path(&f).exists(), "the partial temp file is cleaned up");
|
||||
// A temp file left by a hard kill doesn't count as an auto-save.
|
||||
std::fs::write(d.join("p auto-save 1.ecproj.tmp"), b"junk").unwrap();
|
||||
assert!(autosave::existing(&d, "p").is_empty());
|
||||
atomic_write(&f, b"version 3").unwrap();
|
||||
assert_eq!(std::fs::read(&f).unwrap(), b"version 3");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosave_rotates_through_max_versions() {
|
||||
let d = tmp("rotate");
|
||||
let proj = d.join("Intro.ecproj");
|
||||
let mut s = session_with_comp();
|
||||
s.execute("file.saveAs", json!({"path": proj.to_string_lossy()})).unwrap();
|
||||
s.execute("prefs.set", json!({"key": "autoSave.maxVersions", "value": 3})).unwrap();
|
||||
let folder = d.join(autosave::AUTOSAVE_FOLDER);
|
||||
let mut written = vec![];
|
||||
for i in 0..5 {
|
||||
s.execute("layer.newNull", json!({"name": format!("save{i}")})).unwrap();
|
||||
written.push(s.autosave_now().unwrap());
|
||||
}
|
||||
let names: Vec<String> = written.iter().map(|p| PathBuf::from(p).file_name().unwrap().to_string_lossy().to_string()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
["Intro auto-save 1.ecproj", "Intro auto-save 2.ecproj", "Intro auto-save 3.ecproj", "Intro auto-save 1.ecproj", "Intro auto-save 2.ecproj"]
|
||||
);
|
||||
let have: Vec<u32> = autosave::existing(&folder, "Intro").iter().map(|e| e.0).collect();
|
||||
assert_eq!(have, [1, 2, 3]);
|
||||
let slot2 = std::fs::read_to_string(folder.join("Intro auto-save 2.ecproj")).unwrap();
|
||||
assert!(slot2.contains("save4"), "slot 2 holds the newest save");
|
||||
// The project file itself is untouched and still dirty.
|
||||
assert!(s.is_dirty());
|
||||
assert!(!std::fs::read_to_string(&proj).unwrap().contains("save0"));
|
||||
// Lowering the maximum drops the extra slots.
|
||||
s.execute("prefs.set", json!({"key": "autoSave.maxVersions", "value": 2})).unwrap();
|
||||
s.autosave_now().unwrap();
|
||||
let have: Vec<u32> = autosave::existing(&folder, "Intro").iter().map(|e| e.0).collect();
|
||||
assert_eq!(have, [1, 2]);
|
||||
// A fresh session continues after the newest file on disk.
|
||||
let next = autosave::next_slot(&autosave::existing(&folder, "Intro"), None, 2);
|
||||
let newest = autosave::existing(&folder, "Intro").into_iter().max_by_key(|e| e.2).unwrap().0;
|
||||
assert_eq!(next, newest % 2 + 1);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosave_tick_waits_for_the_interval_and_changes() {
|
||||
let d = tmp("tick");
|
||||
let mut s = session_with_comp();
|
||||
s.execute("file.saveAs", json!({"path": d.join("T.ecproj").to_string_lossy()})).unwrap();
|
||||
s.execute(
|
||||
"prefs.set",
|
||||
json!({"values": {"autoSave.intervalMinutes": 1, "autoSave.location": "custom", "autoSave.folder": d.join("custom").to_string_lossy()}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.autosave_tick(1000.0).is_none(), "the first tick starts the clock");
|
||||
s.execute("layer.newNull", json!({})).unwrap();
|
||||
assert!(s.autosave_tick(1030.0).is_none(), "interval not reached");
|
||||
let p = s.autosave_tick(1061.0).unwrap().unwrap();
|
||||
assert!(p.contains("custom") && p.ends_with("T auto-save 1.ecproj"), "{p}");
|
||||
assert!(s.autosave_tick(1200.0).is_none(), "nothing changed since");
|
||||
s.execute("prefs.set", json!({"key": "autoSave.enabled", "value": false})).unwrap();
|
||||
s.execute("layer.newNull", json!({})).unwrap();
|
||||
assert!(s.autosave_tick(5000.0).is_none(), "disabled");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crash_recovery_detects_an_unclean_exit() {
|
||||
let d = tmp("recovery");
|
||||
let store: Arc<dyn ConfigStore> = Arc::new(DirConfig::new(d.join("config")));
|
||||
// First run: open a project, auto-save, then "crash" (no end_recovery).
|
||||
let mut s = session_with_comp();
|
||||
s.config = Some(store.clone());
|
||||
assert!(s.begin_recovery().is_none(), "first launch is clean");
|
||||
let proj = d.join("Crash.ecproj");
|
||||
s.execute("file.saveAs", json!({"path": proj.to_string_lossy()})).unwrap();
|
||||
s.execute("layer.newNull", json!({"name": "unsaved work"})).unwrap();
|
||||
let auto = s.autosave_now().unwrap();
|
||||
drop(s);
|
||||
// Second run finds the sentinel and offers the auto-save.
|
||||
let mut s2 = Session { config: Some(store.clone()), ..Default::default() };
|
||||
let r = s2.begin_recovery().expect("unclean exit detected");
|
||||
assert_eq!(r.project.as_deref(), Some(proj.to_string_lossy().as_ref()));
|
||||
assert_eq!(r.autosave.as_deref(), Some(auto.as_str()));
|
||||
s2.execute("file.open", json!({"path": r.autosave.unwrap()})).unwrap();
|
||||
assert!(s2.active_comp().unwrap().layers.iter().any(|l| l.name == "unsaved work"));
|
||||
// Clean exit: nothing to recover next time.
|
||||
s2.end_recovery();
|
||||
let mut s3 = Session { config: Some(store.clone()), ..Default::default() };
|
||||
assert!(s3.begin_recovery().is_none());
|
||||
// The setting can turn the offer off.
|
||||
let mut s4 = Session { config: Some(store), ..Default::default() };
|
||||
s4.prefs.startup.offer_crash_recovery = false;
|
||||
assert!(s4.begin_recovery().is_none());
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_recent_increment_and_revert() {
|
||||
let d = tmp("recent");
|
||||
let a = d.join("A.ecproj");
|
||||
let mut s = session_with_comp();
|
||||
s.execute("file.saveAs", json!({"path": a.to_string_lossy()})).unwrap();
|
||||
let r = s.execute("file.incrementAndSave", json!({})).unwrap();
|
||||
assert!(r["path"].as_str().unwrap().ends_with("A 2.ecproj"));
|
||||
let r = s.execute("file.incrementAndSave", json!({})).unwrap();
|
||||
assert!(r["path"].as_str().unwrap().ends_with("A 3.ecproj"));
|
||||
assert!(s.prefs.recent_projects[0].ends_with("A 3.ecproj"));
|
||||
assert!(s.prefs.recent_projects[2].ends_with("A.ecproj"));
|
||||
s.execute("layer.newNull", json!({})).unwrap();
|
||||
s.execute("file.revert", json!({})).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 0);
|
||||
s.execute("file.openRecent", json!({"index": 2})).unwrap();
|
||||
assert!(s.path.as_deref().unwrap().ends_with("A.ecproj"));
|
||||
assert!(s.prefs.recent_projects[0].ends_with("A.ecproj"));
|
||||
s.execute("file.clearRecent", json!({})).unwrap();
|
||||
assert!(!s.is_enabled("file.openRecent"));
|
||||
assert_eq!(autosave::increment_path("/x/Intro 9.ecproj", |_| false), "/x/Intro 10.ecproj");
|
||||
assert_eq!(autosave::increment_path("/x/v1.ecproj", |p| p.ends_with("v1 2.ecproj")), "/x/v1 3.ecproj");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
@@ -57,6 +57,8 @@ pub enum Dialog {
|
||||
KeyVelocity,
|
||||
KeyInterpolation,
|
||||
TimeStretch,
|
||||
/// Crash recovery: offer the latest auto-save (`EffectcraftApp::recovery`).
|
||||
Recovery,
|
||||
}
|
||||
|
||||
/// Host hooks provided by the native app (file pickers etc.).
|
||||
@@ -130,6 +132,8 @@ pub struct EffectcraftApp {
|
||||
pub(crate) last_reveal: Option<(String, f64)>,
|
||||
/// Settings revision applied to the theme, tooltips and caches.
|
||||
applied_prefs: Option<u64>,
|
||||
/// A previous run that didn't exit cleanly (shown by `Dialog::Recovery`).
|
||||
pub recovery: Option<effectcraft_engine::autosave::Recovery>,
|
||||
}
|
||||
|
||||
impl EffectcraftApp {
|
||||
@@ -167,6 +171,7 @@ impl EffectcraftApp {
|
||||
waveforms: Default::default(),
|
||||
last_reveal: None,
|
||||
applied_prefs: None,
|
||||
recovery: None,
|
||||
}
|
||||
.with_ui_commands()
|
||||
}
|
||||
@@ -181,6 +186,14 @@ impl EffectcraftApp {
|
||||
self
|
||||
}
|
||||
|
||||
/// Show the crash-recovery dialog for a previous run that didn't exit cleanly.
|
||||
pub fn offer_recovery(&mut self, r: effectcraft_engine::autosave::Recovery) {
|
||||
if r.autosave.is_some() {
|
||||
self.recovery = Some(r);
|
||||
self.dialog = Some(Dialog::Recovery);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply changed settings: theme and brightness, label colours, tool tips, preview caches.
|
||||
pub fn apply_prefs(&mut self, ctx: &egui::Context) {
|
||||
if self.applied_prefs == Some(self.session.prefs_revision) {
|
||||
@@ -590,6 +603,7 @@ impl EffectcraftApp {
|
||||
}
|
||||
self.apply_prefs(&ctx);
|
||||
self.handle_events(&ctx);
|
||||
self.tick_autosave(&ctx);
|
||||
if let Some(rx) = self.command_inbox.take() {
|
||||
while let Ok(id) = rx.try_recv() {
|
||||
if let Err(e) = menus::invoke(self, &ctx, &id, json!({})) {
|
||||
@@ -701,6 +715,17 @@ impl EffectcraftApp {
|
||||
}
|
||||
|
||||
impl EffectcraftApp {
|
||||
/// Auto-save a dirty project when the interval has passed (Settings ▸ Project ▸ Auto-Save).
|
||||
fn tick_autosave(&mut self, ctx: &egui::Context) {
|
||||
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0);
|
||||
if let Some(Err(e)) = self.session.autosave_tick(now) {
|
||||
self.ui.status = e.to_string();
|
||||
}
|
||||
if self.session.prefs.auto_save.enabled {
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs(30));
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the pending synthetic input (from `ui.click`, `ui.key`, …). Hosts that don't call
|
||||
/// [`eframe::App::raw_input_hook`] (the headless test harness) feed these in themselves.
|
||||
pub fn take_synthetic_input(&mut self) -> Vec<egui::Event> {
|
||||
@@ -709,6 +734,11 @@ impl EffectcraftApp {
|
||||
}
|
||||
|
||||
impl eframe::App for EffectcraftApp {
|
||||
fn on_exit(&mut self) {
|
||||
// Clean exit: no crash recovery next launch.
|
||||
self.session.end_recovery();
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
||||
if !self.synthetic.is_empty() {
|
||||
// Pointer events go one per frame so egui sees press → moves → release as a real drag.
|
||||
|
||||
@@ -840,6 +840,27 @@ fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], c
|
||||
MenuNode::Separator => {
|
||||
ui.separator();
|
||||
}
|
||||
MenuNode::Submenu { label, children } if label == "Open Recent" => {
|
||||
// File ▸ Open Recent: the recent projects (Settings), then the static entries.
|
||||
ui.menu_button((gutter(false), label.as_str()), |ui| {
|
||||
ui.set_min_width(320.0);
|
||||
let recent = app.session.prefs.recent_projects.clone();
|
||||
if recent.is_empty() {
|
||||
ui.add_enabled(false, egui::Button::new((gutter(false), "No Recent Projects")));
|
||||
}
|
||||
for (i, path) in recent.iter().enumerate() {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone());
|
||||
let r = ui.add(egui::Button::new((gutter(false), name))).on_hover_text(path);
|
||||
app.auto.add(&format!("menu.openRecent.{i}"), r.rect, path);
|
||||
if r.clicked() {
|
||||
*clicked = Some(("file.openRecent".into(), json!({"index": i})));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
menu_nodes(app, ui, children, clicked);
|
||||
});
|
||||
}
|
||||
MenuNode::Submenu { label, children } => {
|
||||
ui.menu_button((gutter(false), label.as_str()), |ui| {
|
||||
ui.set_min_width(if children.len() > 30 { 200.0 } else { 240.0 });
|
||||
|
||||
@@ -104,6 +104,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
Dialog::CommandPalette => palette(app, ctx, &t),
|
||||
Dialog::Settings => super::settings::show(app, ctx, &t),
|
||||
Dialog::Shortcuts => super::shortcut_editor::show(app, ctx, &t),
|
||||
Dialog::Recovery => super::settings::recovery(app, ctx, &t),
|
||||
Dialog::Form => super::forms::show_form(app, ctx, &t),
|
||||
Dialog::Info => super::forms::show_info(app, ctx, &t),
|
||||
Dialog::ViewOptions => super::forms::show_view_options(app, ctx, &t),
|
||||
|
||||
@@ -344,3 +344,68 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
app.dialog = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Crash recovery: the previous session didn't exit cleanly; offer its latest auto-save.
|
||||
pub fn recovery(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let Some(r) = app.recovery.clone() else {
|
||||
app.dialog = None;
|
||||
return;
|
||||
};
|
||||
let mut choice: Option<&str> = None;
|
||||
super::dialogs::modal(ctx, "Recover Project", vec2(560.0, 230.0), t, |ui| {
|
||||
ui.label("EffectCraft didn't quit normally last time.");
|
||||
ui.add_space(6.0);
|
||||
if let Some(p) = &r.project {
|
||||
ui.label(format!("Project: {p}"));
|
||||
}
|
||||
if let Some(a) = &r.autosave {
|
||||
let when = std::fs::metadata(a).and_then(|m| m.modified()).ok().and_then(|m| m.elapsed().ok()).map(|d| {
|
||||
let m = d.as_secs() / 60;
|
||||
if m < 1 {
|
||||
"less than a minute ago".to_string()
|
||||
} else if m < 120 {
|
||||
format!("{m} minutes ago")
|
||||
} else {
|
||||
format!("{} hours ago", m / 60)
|
||||
}
|
||||
});
|
||||
ui.label(format!("Latest auto-save: {a}"));
|
||||
if let Some(w) = when {
|
||||
ui.label(RichText::new(format!("Saved {w}")).color(t.text_dim));
|
||||
}
|
||||
}
|
||||
ui.add_space(14.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let b = ui.add(egui::Button::new(RichText::new(" Open Auto-Save ").color(Color32::WHITE)).fill(t.accent));
|
||||
app.auto.add("recovery.openAutoSave", b.rect, "Open Auto-Save");
|
||||
if b.clicked() {
|
||||
choice = Some("autosave");
|
||||
}
|
||||
if r.project.is_some() {
|
||||
let b = ui.button("Open Last Saved Project");
|
||||
app.auto.add("recovery.openProject", b.rect, "Open Last Saved Project");
|
||||
if b.clicked() {
|
||||
choice = Some("project");
|
||||
}
|
||||
}
|
||||
let b = ui.button("Don't Recover");
|
||||
app.auto.add("recovery.dismiss", b.rect, "Don't Recover");
|
||||
if b.clicked() {
|
||||
choice = Some("none");
|
||||
}
|
||||
});
|
||||
});
|
||||
let Some(c) = choice else { return };
|
||||
app.dialog = None;
|
||||
app.recovery = None;
|
||||
let path = match c {
|
||||
"autosave" => r.autosave,
|
||||
"project" => r.project,
|
||||
_ => None,
|
||||
};
|
||||
if let Some(p) = path
|
||||
&& let Err(e) = crate::menus::invoke(app, ctx, "file.open", json!({"path": p}))
|
||||
{
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
@@ -106,3 +106,9 @@ the JSON control channel and the MCP server all dispatch by id, so anything a pe
|
||||
menu, an agent can do too. Every interactive widget registers an automation id, so agents can also
|
||||
inspect and click the interface. See [agents.md](agents.md) and
|
||||
[control-protocol.md](control-protocol.md).
|
||||
|
||||
Settings (`engine::prefs`), keyboard shortcut presets (`engine::shortcuts`) and auto-save /
|
||||
crash recovery (`engine::autosave`) live in the engine too; they persist through a
|
||||
`ConfigStore` the frontend provides (a directory on the desktop, `localStorage` on the web).
|
||||
The shortcut dispatcher and the menus read the active preset. See
|
||||
[preferences.md](preferences.md).
|
||||
+18
-1
@@ -1,4 +1,4 @@
|
||||
# Settings and keyboard shortcuts
|
||||
# Settings, keyboard shortcuts, auto-save and crash recovery
|
||||
|
||||
## Settings (Preferences)
|
||||
|
||||
@@ -157,3 +157,20 @@ Commands: `shortcuts.list {query?, keys?, assigned?}`, `shortcuts.set {command,
|
||||
`shortcuts.reset {command?}`, `shortcuts.preset {op: select|new|duplicate|delete|rename, name?,
|
||||
from?, newName?}`, `shortcuts.export {preset?, path?}`, `shortcuts.import {path? | preset?}`,
|
||||
`shortcuts.conflicts`.
|
||||
|
||||
## Auto-save and crash recovery
|
||||
|
||||
- Every *n* minutes (Settings ▸ Project ▸ Auto-Save) a project with unsaved changes is written
|
||||
to an `EffectCraft Auto-Save` folder next to it (or the custom folder) as
|
||||
`<name> auto-save N.ecproj`. Slots rotate through 1…maximum versions, overwriting the oldest.
|
||||
Untitled projects go to the custom folder, or to `Auto-Save` in the config directory.
|
||||
- Every project and auto-save write is atomic: a temporary file is written and flushed, then
|
||||
renamed over the target, so a crash mid-write leaves the previous file intact.
|
||||
- While the app runs, `session.lock` in the config directory records the open project and its
|
||||
latest auto-save. A clean quit removes it. If it is still there at the next launch, the app
|
||||
offers to open the latest auto-save (Settings ▸ Startup & Repair can turn this off).
|
||||
- File ▸ Open Recent lists recent projects (stored in the settings), File ▸ Revert reloads the
|
||||
saved project, File ▸ Increment and Save saves `Intro.ecproj` as `Intro 2.ecproj`.
|
||||
|
||||
Commands: `file.autoSave` (now), `file.recoveryInfo`, `file.openRecent {index? | path?}`,
|
||||
`file.clearRecent`, `file.revert`, `file.incrementAndSave`.
|
||||
Reference in new issue
Block a user