|
|
|
|
@ -681,6 +681,147 @@ enum FileOperation {
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// How often (seconds) the background autosave runs when the document is dirty.
|
|
|
|
|
const AUTOSAVE_INTERVAL_SECS: f64 = 45.0;
|
|
|
|
|
|
|
|
|
|
/// Background crash-recovery autosave state (see the `autosave` field on `EditorApp`).
|
|
|
|
|
struct AutosaveState {
|
|
|
|
|
/// Per-session recovery container path (in the app data dir). `None` disables autosave
|
|
|
|
|
/// (e.g. the data dir couldn't be resolved/created).
|
|
|
|
|
recovery_path: Option<std::path::PathBuf>,
|
|
|
|
|
/// `ActionExecutor::epoch()` captured at the last dispatched autosave (or manual save). The
|
|
|
|
|
/// document is "dirty" when the current epoch differs, or `pending_event` is set.
|
|
|
|
|
baseline_epoch: u64,
|
|
|
|
|
/// Set by non-action changes that still need capturing (import, finished recording).
|
|
|
|
|
pending_event: bool,
|
|
|
|
|
/// True while a recovery write is in flight on the worker (don't dispatch another).
|
|
|
|
|
in_flight: bool,
|
|
|
|
|
/// Wall-clock of the last dispatched autosave, for interval throttling.
|
|
|
|
|
last_time: Option<std::time::Instant>,
|
|
|
|
|
/// Progress channel for the in-flight recovery write.
|
|
|
|
|
progress_rx: Option<std::sync::mpsc::Receiver<FileProgress>>,
|
|
|
|
|
/// Recovery files left over from previous sessions that didn't exit cleanly (newest first),
|
|
|
|
|
/// discovered at startup. Presented to the user as a "recover unsaved work?" prompt.
|
|
|
|
|
leftover_recoveries: Vec<std::path::PathBuf>,
|
|
|
|
|
/// Seconds between autosaves while dirty. Defaults to `AUTOSAVE_INTERVAL_SECS`; override with
|
|
|
|
|
/// `LB_AUTOSAVE_SECS` (e.g. `LB_AUTOSAVE_SECS=5`) to make manual testing practical.
|
|
|
|
|
interval_secs: f64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AutosaveState {
|
|
|
|
|
fn new() -> Self {
|
|
|
|
|
Self::gc_old_recovered();
|
|
|
|
|
let recovery_path = Self::make_session_path();
|
|
|
|
|
eprintln!("💾 [AUTOSAVE] recovery file for this session: {:?}", recovery_path);
|
|
|
|
|
let leftover_recoveries = Self::find_leftovers(recovery_path.as_deref());
|
|
|
|
|
if !leftover_recoveries.is_empty() {
|
|
|
|
|
eprintln!("💾 [AUTOSAVE] found {} leftover recovery file(s) from a previous session",
|
|
|
|
|
leftover_recoveries.len());
|
|
|
|
|
}
|
|
|
|
|
Self {
|
|
|
|
|
recovery_path,
|
|
|
|
|
baseline_epoch: 0,
|
|
|
|
|
pending_event: false,
|
|
|
|
|
in_flight: false,
|
|
|
|
|
last_time: None,
|
|
|
|
|
progress_rx: None,
|
|
|
|
|
leftover_recoveries,
|
|
|
|
|
interval_secs: std::env::var("LB_AUTOSAVE_SECS")
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|s| s.parse::<f64>().ok())
|
|
|
|
|
.filter(|s| *s > 0.0)
|
|
|
|
|
.unwrap_or(AUTOSAVE_INTERVAL_SECS),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Session recovery `.beam` files present at startup (excluding this session's own path).
|
|
|
|
|
/// Their existence means a prior session didn't reach `on_exit` — i.e. it crashed or was
|
|
|
|
|
/// killed — so they hold unsaved work. Sorted newest-first by modification time.
|
|
|
|
|
fn find_leftovers(exclude: Option<&std::path::Path>) -> Vec<std::path::PathBuf> {
|
|
|
|
|
let Some(dir) = Self::recovery_dir() else { return Vec::new() };
|
|
|
|
|
let mut files: Vec<(std::path::PathBuf, std::time::SystemTime)> = std::fs::read_dir(&dir)
|
|
|
|
|
.into_iter()
|
|
|
|
|
.flatten()
|
|
|
|
|
.flatten()
|
|
|
|
|
.map(|e| e.path())
|
|
|
|
|
.filter(|p| {
|
|
|
|
|
p.extension().and_then(|x| x.to_str()) == Some("beam")
|
|
|
|
|
&& p.file_name()
|
|
|
|
|
.and_then(|n| n.to_str())
|
|
|
|
|
.is_some_and(|n| n.starts_with("session-"))
|
|
|
|
|
&& Some(p.as_path()) != exclude
|
|
|
|
|
})
|
|
|
|
|
.filter_map(|p| {
|
|
|
|
|
let mtime = std::fs::metadata(&p).and_then(|m| m.modified()).ok()?;
|
|
|
|
|
Some((p, mtime))
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
files.sort_by(|a, b| b.1.cmp(&a.1)); // newest first
|
|
|
|
|
files.into_iter().map(|(p, _)| p).collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Delete `recovered-*` files (already-recovered work the user relocated via Save As) older than
|
|
|
|
|
/// a week, so the recovery dir doesn't grow without bound. Best-effort.
|
|
|
|
|
fn gc_old_recovered() {
|
|
|
|
|
let Some(dir) = Self::recovery_dir() else { return };
|
|
|
|
|
let Some(cutoff) =
|
|
|
|
|
std::time::SystemTime::now().checked_sub(std::time::Duration::from_secs(7 * 24 * 3600))
|
|
|
|
|
else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
|
|
|
|
let p = entry.path();
|
|
|
|
|
let is_recovered = p.extension().and_then(|x| x.to_str()) == Some("beam")
|
|
|
|
|
&& p.file_name()
|
|
|
|
|
.and_then(|n| n.to_str())
|
|
|
|
|
.is_some_and(|n| n.starts_with("recovered-"));
|
|
|
|
|
if !is_recovered {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if let Ok(mtime) = std::fs::metadata(&p).and_then(|m| m.modified()) {
|
|
|
|
|
if mtime < cutoff {
|
|
|
|
|
let _ = std::fs::remove_file(&p);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The recovery directory (`<data_dir>/recovery`), created if needed.
|
|
|
|
|
fn recovery_dir() -> Option<std::path::PathBuf> {
|
|
|
|
|
let proj = directories::ProjectDirs::from("", "", "lightningbeam")?;
|
|
|
|
|
let dir = proj.data_dir().join("recovery");
|
|
|
|
|
std::fs::create_dir_all(&dir).ok()?;
|
|
|
|
|
Some(dir)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A fresh per-session recovery file path (`recovery/session-<uuid>.beam`).
|
|
|
|
|
fn make_session_path() -> Option<std::path::PathBuf> {
|
|
|
|
|
Some(Self::recovery_dir()?.join(format!("session-{}.beam", uuid::Uuid::new_v4())))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Whether `path` is one of our recovery files (`session-*` / `recovered-*` in the recovery
|
|
|
|
|
/// dir). Saving one of these should behave as Save As so the work gets a real home instead of
|
|
|
|
|
/// being written back into the app data dir.
|
|
|
|
|
fn is_recovery_path(path: &std::path::Path) -> bool {
|
|
|
|
|
path.file_name()
|
|
|
|
|
.and_then(|n| n.to_str())
|
|
|
|
|
.is_some_and(|n| n.starts_with("session-") || n.starts_with("recovered-"))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Something to do after the unsaved-changes prompt resolves: the action deferred behind "Save
|
|
|
|
|
/// changes?" (Save runs it after saving; Don't Save runs it immediately; Cancel drops it). Covers
|
|
|
|
|
/// both file switches and quitting so they share one prompt + one save-then-continue path.
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
enum PendingAction {
|
|
|
|
|
/// New File (return to the start screen).
|
|
|
|
|
NewFile,
|
|
|
|
|
/// Open a specific `.beam` (covers both Open… and Open Recent — the path is already resolved).
|
|
|
|
|
Open(std::path::PathBuf),
|
|
|
|
|
/// Quit (close the window).
|
|
|
|
|
Quit,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Information about an imported asset (for auto-placement)
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
#[allow(dead_code)] // name/duration populated for future import UX features
|
|
|
|
|
@ -1163,6 +1304,26 @@ struct EditorApp {
|
|
|
|
|
/// Current file operation in progress (if any)
|
|
|
|
|
file_operation: Option<FileOperation>,
|
|
|
|
|
|
|
|
|
|
/// Crash-recovery autosave state. The recovery container is a per-session `.beam` in the app's
|
|
|
|
|
/// data dir; it's written in the background (reusing the file worker) and deleted on a clean
|
|
|
|
|
/// exit. A leftover file on next launch signals an unclean shutdown → offer to restore.
|
|
|
|
|
autosave: AutosaveState,
|
|
|
|
|
|
|
|
|
|
/// `ActionExecutor::epoch()` at the last manual save (or new/load) — the document is "modified"
|
|
|
|
|
/// (has unsaved changes vs. the user's file) when the current epoch differs, or `media_modified`
|
|
|
|
|
/// is set. Distinct from the autosave baseline, which also moves on every background autosave.
|
|
|
|
|
saved_epoch: u64,
|
|
|
|
|
/// Non-action changes since the last save (imports, finished recordings) that `epoch` doesn't
|
|
|
|
|
/// capture. Cleared on manual save / new / load.
|
|
|
|
|
media_modified: bool,
|
|
|
|
|
/// The pending action waiting on the "save changes?" prompt — a file switch (New/Open/Open
|
|
|
|
|
/// Recent) or a quit, requested while the document had unsaved work.
|
|
|
|
|
unsaved_prompt: Option<PendingAction>,
|
|
|
|
|
/// The action to run once an in-flight save (triggered from that prompt via "Save") finishes.
|
|
|
|
|
after_save: Option<PendingAction>,
|
|
|
|
|
/// The user agreed to quit — let the next window-close request through instead of intercepting it.
|
|
|
|
|
confirmed_close: bool,
|
|
|
|
|
|
|
|
|
|
/// Audio extraction channel for background thread communication
|
|
|
|
|
audio_extraction_tx: std::sync::mpsc::Sender<AudioExtractionResult>,
|
|
|
|
|
audio_extraction_rx: std::sync::mpsc::Receiver<AudioExtractionResult>,
|
|
|
|
|
@ -1246,7 +1407,17 @@ impl EditorApp {
|
|
|
|
|
cc.egui_ctx.options_mut(|o| o.zoom_with_keyboard = false);
|
|
|
|
|
|
|
|
|
|
// Load application config
|
|
|
|
|
let config = AppConfig::load();
|
|
|
|
|
let mut config = AppConfig::load();
|
|
|
|
|
// One-time cleanup: earlier builds added a Recovered file to Recent on restore. Drop any
|
|
|
|
|
// recovery-dir paths that leaked in (and re-save if we removed any) so they don't show in
|
|
|
|
|
// Recent or get auto-reopened below.
|
|
|
|
|
let recent_before = config.recent_files.len();
|
|
|
|
|
config
|
|
|
|
|
.recent_files
|
|
|
|
|
.retain(|p| !AutosaveState::is_recovery_path(p));
|
|
|
|
|
if config.recent_files.len() != recent_before {
|
|
|
|
|
config.save();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if we should auto-reopen last session
|
|
|
|
|
let pending_auto_reopen = if config.reopen_last_session {
|
|
|
|
|
@ -1450,6 +1621,12 @@ impl EditorApp {
|
|
|
|
|
config,
|
|
|
|
|
file_command_tx,
|
|
|
|
|
file_operation: None, // No file operation in progress initially
|
|
|
|
|
autosave: AutosaveState::new(),
|
|
|
|
|
saved_epoch: 0,
|
|
|
|
|
media_modified: false,
|
|
|
|
|
unsaved_prompt: None,
|
|
|
|
|
after_save: None,
|
|
|
|
|
confirmed_close: false,
|
|
|
|
|
audio_extraction_tx,
|
|
|
|
|
audio_extraction_rx,
|
|
|
|
|
export_dialog: export::dialog::ExportDialog::default(),
|
|
|
|
|
@ -1819,6 +1996,8 @@ impl EditorApp {
|
|
|
|
|
|
|
|
|
|
// Reset action executor with new document
|
|
|
|
|
self.action_executor = lightningbeam_core::action::ActionExecutor::new(document);
|
|
|
|
|
// Fresh document → nothing new to recover; rebase the autosave epoch.
|
|
|
|
|
self.reset_autosave_baseline();
|
|
|
|
|
|
|
|
|
|
// Apply the layout
|
|
|
|
|
if layout_index < self.layouts.len() {
|
|
|
|
|
@ -3212,22 +3391,7 @@ impl EditorApp {
|
|
|
|
|
// File menu
|
|
|
|
|
MenuAction::NewFile => {
|
|
|
|
|
println!("Menu: New File");
|
|
|
|
|
// TODO: Prompt to save current file if modified
|
|
|
|
|
|
|
|
|
|
// Tear down the backend (stops old instruments/voices immediately) and clear the
|
|
|
|
|
// app-side track maps + backend-derived caches.
|
|
|
|
|
self.reset_audio_backend();
|
|
|
|
|
|
|
|
|
|
// Reset UI state and return to start screen
|
|
|
|
|
self.current_file_path = None;
|
|
|
|
|
self.selection.clear();
|
|
|
|
|
self.editing_context = EditingContext::default();
|
|
|
|
|
self.active_layer_id = None;
|
|
|
|
|
self.playback_time = 0.0;
|
|
|
|
|
self.is_playing = false;
|
|
|
|
|
self.pane_instances.clear();
|
|
|
|
|
self.project_generation += 1;
|
|
|
|
|
self.app_mode = AppMode::StartScreen;
|
|
|
|
|
self.request_switch(PendingAction::NewFile);
|
|
|
|
|
}
|
|
|
|
|
MenuAction::NewWindow => {
|
|
|
|
|
println!("Menu: New Window");
|
|
|
|
|
@ -3236,11 +3400,17 @@ impl EditorApp {
|
|
|
|
|
MenuAction::Save => {
|
|
|
|
|
use rfd::FileDialog;
|
|
|
|
|
|
|
|
|
|
if let Some(path) = &self.current_file_path {
|
|
|
|
|
// A recovered file has no real home yet — Save behaves as Save As so the work lands
|
|
|
|
|
// where the user wants, not back in the app data dir.
|
|
|
|
|
let real_path = self
|
|
|
|
|
.current_file_path
|
|
|
|
|
.clone()
|
|
|
|
|
.filter(|p| !AutosaveState::is_recovery_path(p));
|
|
|
|
|
if let Some(path) = real_path {
|
|
|
|
|
// Save to existing path
|
|
|
|
|
self.save_to_file(path.clone());
|
|
|
|
|
self.save_to_file(path);
|
|
|
|
|
} else {
|
|
|
|
|
// No current path, fall through to Save As
|
|
|
|
|
// No real path (untitled or recovered): fall through to Save As
|
|
|
|
|
if let Some(path) = FileDialog::new()
|
|
|
|
|
.add_filter("Lightningbeam Project", &["beam"])
|
|
|
|
|
.set_file_name("Untitled.beam")
|
|
|
|
|
@ -3257,15 +3427,16 @@ impl EditorApp {
|
|
|
|
|
.add_filter("Lightningbeam Project", &["beam"])
|
|
|
|
|
.set_file_name("Untitled.beam");
|
|
|
|
|
|
|
|
|
|
// Set initial directory if we have a current file
|
|
|
|
|
let dialog = if let Some(current_path) = &self.current_file_path {
|
|
|
|
|
if let Some(parent) = current_path.parent() {
|
|
|
|
|
dialog.set_directory(parent)
|
|
|
|
|
} else {
|
|
|
|
|
dialog
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
dialog
|
|
|
|
|
// Default to the current file's directory — but not the recovery dir (a recovered
|
|
|
|
|
// file's parent), which the user never chose and shouldn't be steered back into.
|
|
|
|
|
let dialog = match self
|
|
|
|
|
.current_file_path
|
|
|
|
|
.as_ref()
|
|
|
|
|
.filter(|p| !AutosaveState::is_recovery_path(p))
|
|
|
|
|
.and_then(|p| p.parent())
|
|
|
|
|
{
|
|
|
|
|
Some(parent) => dialog.set_directory(parent),
|
|
|
|
|
None => dialog,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if let Some(path) = dialog.save_file() {
|
|
|
|
|
@ -3275,21 +3446,19 @@ impl EditorApp {
|
|
|
|
|
MenuAction::OpenFile => {
|
|
|
|
|
use rfd::FileDialog;
|
|
|
|
|
|
|
|
|
|
// TODO: Prompt to save current file if modified
|
|
|
|
|
|
|
|
|
|
// Pick the file first, then (if there are unsaved changes) prompt to save.
|
|
|
|
|
if let Some(path) = FileDialog::new()
|
|
|
|
|
.add_filter("Lightningbeam Project", &["beam"])
|
|
|
|
|
.pick_file()
|
|
|
|
|
{
|
|
|
|
|
self.load_from_file(path);
|
|
|
|
|
self.request_switch(PendingAction::Open(path));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
MenuAction::OpenRecent(index) => {
|
|
|
|
|
let recent_files = self.config.get_recent_files();
|
|
|
|
|
|
|
|
|
|
if let Some(path) = recent_files.get(index) {
|
|
|
|
|
// TODO: Prompt to save current file if modified
|
|
|
|
|
self.load_from_file(path.clone());
|
|
|
|
|
self.request_switch(PendingAction::Open(path.clone()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
MenuAction::ClearRecentFiles => {
|
|
|
|
|
@ -4020,19 +4189,45 @@ impl EditorApp {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Prepare document for saving by storing current UI layout
|
|
|
|
|
fn prepare_document_for_save(&mut self) {
|
|
|
|
|
let doc = self.action_executor.document_mut();
|
|
|
|
|
|
|
|
|
|
// Store current layout state
|
|
|
|
|
doc.ui_layout = Some(self.current_layout.clone());
|
|
|
|
|
|
|
|
|
|
// Store base layout name for reference
|
|
|
|
|
if self.current_layout_index < self.layouts.len() {
|
|
|
|
|
doc.ui_layout_base = Some(self.layouts[self.current_layout_index].name.clone());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Save the current document to a .beam file
|
|
|
|
|
/// Assemble the background save command for `path`: prepares the document (layout), clones it,
|
|
|
|
|
/// and snapshots the waveform/thumbnail side data. Shared by manual save and background autosave.
|
|
|
|
|
fn build_save_command(
|
|
|
|
|
&mut self,
|
|
|
|
|
path: std::path::PathBuf,
|
|
|
|
|
progress_tx: std::sync::mpsc::Sender<FileProgress>,
|
|
|
|
|
) -> FileCommand {
|
|
|
|
|
// Snapshot the document and stamp the current UI layout onto the SNAPSHOT — never the live
|
|
|
|
|
// document. Mutating the live doc here (as the old prepare_document_for_save did via
|
|
|
|
|
// Arc::make_mut) would touch UI state and could deep-clone the whole document if a render
|
|
|
|
|
// callback holds a reference — unacceptable for a frequent background autosave. The live
|
|
|
|
|
// doc's `ui_layout` is only read at save/serialize time, so leaving it stale is harmless.
|
|
|
|
|
let mut document = self.action_executor.document().clone();
|
|
|
|
|
document.ui_layout = Some(self.current_layout.clone());
|
|
|
|
|
if self.current_layout_index < self.layouts.len() {
|
|
|
|
|
document.ui_layout_base = Some(self.layouts[self.current_layout_index].name.clone());
|
|
|
|
|
}
|
|
|
|
|
let waveform_blobs: std::collections::HashMap<usize, Vec<u8>> = self
|
|
|
|
|
.waveform_pyramid_blobs
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|(&idx, blob)| (idx, blob.as_ref().clone()))
|
|
|
|
|
.collect();
|
|
|
|
|
let (thumbnail_snapshot, complete_thumbnail_clips) = {
|
|
|
|
|
let vm = self.video_manager.lock().unwrap();
|
|
|
|
|
(vm.snapshot_all_thumbnails(), vm.complete_thumbnail_clips())
|
|
|
|
|
};
|
|
|
|
|
FileCommand::Save {
|
|
|
|
|
path,
|
|
|
|
|
document,
|
|
|
|
|
layer_to_track_map: self.layer_to_track_map.clone(),
|
|
|
|
|
large_media_mode: self.config.large_media_default,
|
|
|
|
|
waveform_blobs,
|
|
|
|
|
thumbnail_snapshot,
|
|
|
|
|
complete_thumbnail_clips,
|
|
|
|
|
progress_tx,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn save_to_file(&mut self, path: std::path::PathBuf) {
|
|
|
|
|
println!("Saving to: {}", path.display());
|
|
|
|
|
|
|
|
|
|
@ -4041,42 +4236,9 @@ impl EditorApp {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Prepare document for save (including layout)
|
|
|
|
|
self.prepare_document_for_save();
|
|
|
|
|
|
|
|
|
|
// Create progress channel
|
|
|
|
|
let (progress_tx, progress_rx) = std::sync::mpsc::channel();
|
|
|
|
|
|
|
|
|
|
// Clone document for background thread
|
|
|
|
|
let document = self.action_executor.document().clone();
|
|
|
|
|
|
|
|
|
|
// Snapshot the generated waveform pyramids (by pool index) so the worker
|
|
|
|
|
// can persist them into the container alongside the audio.
|
|
|
|
|
let waveform_blobs: std::collections::HashMap<usize, Vec<u8>> = self
|
|
|
|
|
.waveform_pyramid_blobs
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|(&idx, blob)| (idx, blob.as_ref().clone()))
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
// Snapshot all video thumbnails (cheap Arc-clone) + which clips finished
|
|
|
|
|
// generating; the worker PNG-encodes them with a complete/partial flag so a
|
|
|
|
|
// save mid-generation persists progress and resumes on load.
|
|
|
|
|
let (thumbnail_snapshot, complete_thumbnail_clips) = {
|
|
|
|
|
let vm = self.video_manager.lock().unwrap();
|
|
|
|
|
(vm.snapshot_all_thumbnails(), vm.complete_thumbnail_clips())
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Send save command to worker thread
|
|
|
|
|
let command = FileCommand::Save {
|
|
|
|
|
path: path.clone(),
|
|
|
|
|
document,
|
|
|
|
|
layer_to_track_map: self.layer_to_track_map.clone(),
|
|
|
|
|
large_media_mode: self.config.large_media_default,
|
|
|
|
|
waveform_blobs,
|
|
|
|
|
thumbnail_snapshot,
|
|
|
|
|
complete_thumbnail_clips,
|
|
|
|
|
progress_tx,
|
|
|
|
|
};
|
|
|
|
|
let command = self.build_save_command(path.clone(), progress_tx);
|
|
|
|
|
|
|
|
|
|
if let Err(e) = self.file_command_tx.send(command) {
|
|
|
|
|
eprintln!("❌ Failed to send save command: {}", e);
|
|
|
|
|
@ -4090,6 +4252,301 @@ impl EditorApp {
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Mark the current document state as the autosave baseline — there is nothing new to recover
|
|
|
|
|
/// (called after a manual save, and after the document is replaced by new/load). Clears the
|
|
|
|
|
/// pending-event flag and resets the throttle so the next real change schedules cleanly.
|
|
|
|
|
fn reset_autosave_baseline(&mut self) {
|
|
|
|
|
self.autosave.baseline_epoch = self.action_executor.epoch();
|
|
|
|
|
self.autosave.pending_event = false;
|
|
|
|
|
self.autosave.last_time = None;
|
|
|
|
|
// A fresh/loaded document also starts unmodified vs. its on-disk form.
|
|
|
|
|
self.saved_epoch = self.action_executor.epoch();
|
|
|
|
|
self.media_modified = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Whether the document has unsaved changes vs. the user's file (edits since the last manual
|
|
|
|
|
/// save, or an import/recording that `epoch` doesn't track). Recovered work counts as unsaved
|
|
|
|
|
/// until the user gives it a real home — its only copy is the transient recovery container.
|
|
|
|
|
fn document_modified(&self) -> bool {
|
|
|
|
|
self.action_executor.epoch() != self.saved_epoch
|
|
|
|
|
|| self.media_modified
|
|
|
|
|
|| self
|
|
|
|
|
.current_file_path
|
|
|
|
|
.as_ref()
|
|
|
|
|
.is_some_and(|p| AutosaveState::is_recovery_path(p))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Background crash-recovery autosave: poll any in-flight recovery write, then (if the document
|
|
|
|
|
/// is dirty, throttled to `AUTOSAVE_INTERVAL_SECS`, and no manual save is running) dispatch a
|
|
|
|
|
/// write of the current state into the per-session recovery container. Fully background — reuses
|
|
|
|
|
/// the file worker; the only UI-thread cost is the same document/side-data snapshot a manual
|
|
|
|
|
/// save does. Never touches `current_file_path` or the raster `dirty` flags (the recovery file
|
|
|
|
|
/// is a separate container from the user's file).
|
|
|
|
|
fn maybe_autosave(&mut self, ctx: &egui::Context) {
|
|
|
|
|
// Drain progress from an in-flight recovery write.
|
|
|
|
|
if let Some(rx) = &self.autosave.progress_rx {
|
|
|
|
|
while let Ok(p) = rx.try_recv() {
|
|
|
|
|
match p {
|
|
|
|
|
FileProgress::Done => self.autosave.in_flight = false,
|
|
|
|
|
FileProgress::Error(e) => {
|
|
|
|
|
eprintln!("⚠️ [AUTOSAVE] recovery write failed: {}", e);
|
|
|
|
|
self.autosave.in_flight = false;
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !self.autosave.in_flight {
|
|
|
|
|
self.autosave.progress_rx = None;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let Some(recovery_path) = self.autosave.recovery_path.clone() else { return };
|
|
|
|
|
if self.autosave.in_flight || self.audio_controller.is_none() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// Don't compete with a manual save on the single worker.
|
|
|
|
|
if matches!(self.file_operation, Some(FileOperation::Saving { .. })) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let epoch = self.action_executor.epoch();
|
|
|
|
|
let dirty = epoch != self.autosave.baseline_epoch || self.autosave.pending_event;
|
|
|
|
|
if !dirty {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if let Some(t) = self.autosave.last_time {
|
|
|
|
|
let elapsed = t.elapsed().as_secs_f64();
|
|
|
|
|
if elapsed < self.autosave.interval_secs {
|
|
|
|
|
// Dirty but throttled — wake up when the interval elapses even if the app goes idle,
|
|
|
|
|
// so an edit-then-idle session still gets its recovery snapshot.
|
|
|
|
|
ctx.request_repaint_after(std::time::Duration::from_secs_f64(
|
|
|
|
|
(self.autosave.interval_secs - elapsed).max(0.1),
|
|
|
|
|
));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
|
|
|
let command = self.build_save_command(recovery_path, tx);
|
|
|
|
|
if self.file_command_tx.send(command).is_err() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
self.autosave.in_flight = true;
|
|
|
|
|
self.autosave.progress_rx = Some(rx);
|
|
|
|
|
self.autosave.baseline_epoch = epoch;
|
|
|
|
|
self.autosave.pending_event = false;
|
|
|
|
|
self.autosave.last_time = Some(std::time::Instant::now());
|
|
|
|
|
eprintln!("💾 [AUTOSAVE] recovery snapshot dispatched");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Show the crash-recovery prompt when a previous session left a recovery file behind. Recover
|
|
|
|
|
/// loads it as an untitled document; Discard deletes it; Later keeps it for the next launch.
|
|
|
|
|
fn render_recovery_prompt(&mut self, ctx: &egui::Context) {
|
|
|
|
|
// Don't prompt over an in-flight file op (including a recovery load we just started).
|
|
|
|
|
if self.autosave.leftover_recoveries.is_empty() || self.file_operation.is_some() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let path = self.autosave.leftover_recoveries[0].clone();
|
|
|
|
|
|
|
|
|
|
#[derive(PartialEq)]
|
|
|
|
|
enum Choice { Recover, Discard, Later }
|
|
|
|
|
let mut choice: Option<Choice> = None;
|
|
|
|
|
|
|
|
|
|
egui::Modal::new(egui::Id::new("crash_recovery_modal")).show(ctx, |ui| {
|
|
|
|
|
ui.set_width(crate::mobile::dialog_width(ctx, 460.0));
|
|
|
|
|
ui.heading("Recover unsaved work?");
|
|
|
|
|
ui.add_space(6.0);
|
|
|
|
|
ui.label(
|
|
|
|
|
"Lightningbeam didn't shut down cleanly last time. You have unsaved work from your \
|
|
|
|
|
previous session — recover it?",
|
|
|
|
|
);
|
|
|
|
|
if self.autosave.leftover_recoveries.len() > 1 {
|
|
|
|
|
ui.add_space(4.0);
|
|
|
|
|
ui.weak(format!(
|
|
|
|
|
"{} snapshots available; this shows the most recent first.",
|
|
|
|
|
self.autosave.leftover_recoveries.len()
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
ui.add_space(14.0);
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
if ui.button("Recover").clicked() {
|
|
|
|
|
choice = Some(Choice::Recover);
|
|
|
|
|
}
|
|
|
|
|
if ui.button("Discard").clicked() {
|
|
|
|
|
choice = Some(Choice::Discard);
|
|
|
|
|
}
|
|
|
|
|
if ui.button("Later").clicked() {
|
|
|
|
|
choice = Some(Choice::Later);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
match choice {
|
|
|
|
|
Some(Choice::Recover) => {
|
|
|
|
|
self.autosave.leftover_recoveries.remove(0);
|
|
|
|
|
// Rename out of the `session-*` namespace before opening it, so it isn't offered
|
|
|
|
|
// again next launch — but keep the file, since recovered raster keyframes page in
|
|
|
|
|
// from it on demand (deleting it would lose paged pixels). It opens as the current
|
|
|
|
|
// file; the user relocates the work with Save As. Old `recovered-*` files are
|
|
|
|
|
// garbage-collected at startup.
|
|
|
|
|
let open_path = match path.file_name().and_then(|n| n.to_str()) {
|
|
|
|
|
Some(name) => {
|
|
|
|
|
let renamed =
|
|
|
|
|
path.with_file_name(name.replacen("session-", "recovered-", 1));
|
|
|
|
|
if std::fs::rename(&path, &renamed).is_ok() { renamed } else { path }
|
|
|
|
|
}
|
|
|
|
|
None => path,
|
|
|
|
|
};
|
|
|
|
|
self.load_from_file(open_path);
|
|
|
|
|
}
|
|
|
|
|
Some(Choice::Discard) => {
|
|
|
|
|
let _ = std::fs::remove_file(&path);
|
|
|
|
|
self.autosave.leftover_recoveries.remove(0);
|
|
|
|
|
}
|
|
|
|
|
Some(Choice::Later) => {
|
|
|
|
|
// Keep the files on disk but stop prompting this session.
|
|
|
|
|
self.autosave.leftover_recoveries.clear();
|
|
|
|
|
}
|
|
|
|
|
None => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The single "save changes?" prompt for any unsaved-work exit point — file switches (New /
|
|
|
|
|
/// Open / Open Recent) and quitting. Also intercepts the window-close request. Save persists
|
|
|
|
|
/// first (Save As for untitled/recovered docs) then runs the action; Don't Save runs it and
|
|
|
|
|
/// discards; Cancel stays put.
|
|
|
|
|
fn render_unsaved_prompt(&mut self, ctx: &egui::Context) {
|
|
|
|
|
// Intercept a window-close request with unsaved work → veto it and queue the Quit prompt.
|
|
|
|
|
if ctx.input(|i| i.viewport().close_requested()) && !self.confirmed_close {
|
|
|
|
|
if self.document_modified() {
|
|
|
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
|
|
|
|
self.unsaved_prompt = Some(PendingAction::Quit);
|
|
|
|
|
}
|
|
|
|
|
// Unmodified → let the close proceed.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let Some(pending) = self.unsaved_prompt.as_ref() else { return };
|
|
|
|
|
// The "…before X?" tail + the affirmative button label, per action.
|
|
|
|
|
let (desc, discard_label) = match pending {
|
|
|
|
|
PendingAction::NewFile => ("starting a new file", "Don't Save"),
|
|
|
|
|
PendingAction::Open(_) => ("opening another file", "Don't Save"),
|
|
|
|
|
PendingAction::Quit => ("quitting", "Discard & Quit"),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
#[derive(PartialEq)]
|
|
|
|
|
enum Answer {
|
|
|
|
|
Save,
|
|
|
|
|
Discard,
|
|
|
|
|
Cancel,
|
|
|
|
|
}
|
|
|
|
|
let mut answer: Option<Answer> = None;
|
|
|
|
|
|
|
|
|
|
egui::Modal::new(egui::Id::new("unsaved_changes_modal")).show(ctx, |ui| {
|
|
|
|
|
ui.set_width(crate::mobile::dialog_width(ctx, 440.0));
|
|
|
|
|
ui.heading("Save changes?");
|
|
|
|
|
ui.add_space(6.0);
|
|
|
|
|
ui.label(format!(
|
|
|
|
|
"This document has unsaved changes. Save them before {desc}?"
|
|
|
|
|
));
|
|
|
|
|
ui.add_space(14.0);
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
if ui.button("Save").clicked() {
|
|
|
|
|
answer = Some(Answer::Save);
|
|
|
|
|
}
|
|
|
|
|
if ui.button(discard_label).clicked() {
|
|
|
|
|
answer = Some(Answer::Discard);
|
|
|
|
|
}
|
|
|
|
|
if ui.button("Cancel").clicked() {
|
|
|
|
|
answer = Some(Answer::Cancel);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
match answer {
|
|
|
|
|
Some(Answer::Cancel) => {
|
|
|
|
|
self.unsaved_prompt = None;
|
|
|
|
|
}
|
|
|
|
|
Some(Answer::Discard) => {
|
|
|
|
|
if let Some(action) = self.unsaved_prompt.take() {
|
|
|
|
|
self.do_action(action, ctx);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Some(Answer::Save) => {
|
|
|
|
|
let action = self.unsaved_prompt.take();
|
|
|
|
|
// Save to the existing file, or Save As for an untitled / recovered document.
|
|
|
|
|
let real_path = self
|
|
|
|
|
.current_file_path
|
|
|
|
|
.clone()
|
|
|
|
|
.filter(|p| !AutosaveState::is_recovery_path(p));
|
|
|
|
|
let target = match real_path {
|
|
|
|
|
Some(p) => Some(p),
|
|
|
|
|
None => rfd::FileDialog::new()
|
|
|
|
|
.add_filter("Lightningbeam Project", &["beam"])
|
|
|
|
|
.set_file_name("Untitled.beam")
|
|
|
|
|
.save_file(),
|
|
|
|
|
};
|
|
|
|
|
match target {
|
|
|
|
|
Some(path) => {
|
|
|
|
|
// Run the action once the save completes.
|
|
|
|
|
self.after_save = action;
|
|
|
|
|
self.save_to_file(path);
|
|
|
|
|
}
|
|
|
|
|
None => {
|
|
|
|
|
// Save As cancelled → abort, keep the document (still unsaved).
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
None => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// New File: tear down the current project and return to the start screen. (The guard for
|
|
|
|
|
/// unsaved changes lives in the menu handler; this is the actual action.)
|
|
|
|
|
fn do_new_file(&mut self) {
|
|
|
|
|
// Tear down the backend (stops old instruments/voices immediately) and clear the app-side
|
|
|
|
|
// track maps + backend-derived caches.
|
|
|
|
|
self.reset_audio_backend();
|
|
|
|
|
|
|
|
|
|
// Reset UI state and return to the start screen.
|
|
|
|
|
self.current_file_path = None;
|
|
|
|
|
self.selection.clear();
|
|
|
|
|
self.editing_context = EditingContext::default();
|
|
|
|
|
self.active_layer_id = None;
|
|
|
|
|
self.playback_time = 0.0;
|
|
|
|
|
self.is_playing = false;
|
|
|
|
|
self.pane_instances.clear();
|
|
|
|
|
self.project_generation += 1;
|
|
|
|
|
self.app_mode = AppMode::StartScreen;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Carry out a deferred action once the unsaved-changes prompt is resolved (or when there was
|
|
|
|
|
/// nothing unsaved to begin with).
|
|
|
|
|
fn do_action(&mut self, action: PendingAction, ctx: &egui::Context) {
|
|
|
|
|
match action {
|
|
|
|
|
PendingAction::NewFile => self.do_new_file(),
|
|
|
|
|
PendingAction::Open(path) => self.load_from_file(path),
|
|
|
|
|
PendingAction::Quit => {
|
|
|
|
|
self.confirmed_close = true;
|
|
|
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Begin a file switch (New / Open / Open Recent), prompting to save first if the document has
|
|
|
|
|
/// unsaved changes. Only ever called with `NewFile`/`Open` from the menus, so the immediate path
|
|
|
|
|
/// needs no `ctx` (only `Quit`, driven by the close interceptor, does).
|
|
|
|
|
fn request_switch(&mut self, action: PendingAction) {
|
|
|
|
|
if self.document_modified() {
|
|
|
|
|
self.unsaved_prompt = Some(action);
|
|
|
|
|
} else {
|
|
|
|
|
match action {
|
|
|
|
|
PendingAction::NewFile => self.do_new_file(),
|
|
|
|
|
PendingAction::Open(path) => self.load_from_file(path),
|
|
|
|
|
PendingAction::Quit => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Load a document from a .beam file
|
|
|
|
|
fn load_from_file(&mut self, path: std::path::PathBuf) {
|
|
|
|
|
println!("Loading from: {}", path.display());
|
|
|
|
|
@ -4184,6 +4641,8 @@ impl EditorApp {
|
|
|
|
|
// Replace document
|
|
|
|
|
let step1_start = std::time::Instant::now();
|
|
|
|
|
self.action_executor = ActionExecutor::new(loaded_project.document);
|
|
|
|
|
// Freshly loaded document is clean → rebase the autosave epoch (no spurious recovery write).
|
|
|
|
|
self.reset_autosave_baseline();
|
|
|
|
|
eprintln!("📊 [APPLY] Step 1: Replace document took {:.2}ms", step1_start.elapsed().as_secs_f64() * 1000.0);
|
|
|
|
|
|
|
|
|
|
// Restore UI layout from loaded document
|
|
|
|
|
@ -4464,9 +4923,12 @@ impl EditorApp {
|
|
|
|
|
// Point the raster paging store at the loaded container so faulting works.
|
|
|
|
|
self.raster_store.set_path(self.current_file_path.clone());
|
|
|
|
|
|
|
|
|
|
// Add to recent files
|
|
|
|
|
// Add to recent files — but never a recovery file (it's an internal, transient container in
|
|
|
|
|
// the app data dir, not a project the user opened).
|
|
|
|
|
if !AutosaveState::is_recovery_path(&path) {
|
|
|
|
|
self.config.add_recent_file(path.clone());
|
|
|
|
|
self.update_recent_files_menu();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set active layer
|
|
|
|
|
if let Some(first) = self.action_executor.document().root.children.first() {
|
|
|
|
|
@ -4722,6 +5184,9 @@ impl EditorApp {
|
|
|
|
|
|
|
|
|
|
fn import_image(&mut self, path: &std::path::Path) -> Option<ImportedAssetInfo> {
|
|
|
|
|
use lightningbeam_core::clip::ImageAsset;
|
|
|
|
|
// Imported media lives outside the action/undo system, so flag it for the next autosave.
|
|
|
|
|
self.autosave.pending_event = true;
|
|
|
|
|
self.media_modified = true;
|
|
|
|
|
self.note_possible_large_media(path);
|
|
|
|
|
|
|
|
|
|
// Get filename for asset name
|
|
|
|
|
@ -4775,6 +5240,8 @@ impl EditorApp {
|
|
|
|
|
/// GPU waveform cache.
|
|
|
|
|
fn import_audio(&mut self, path: &std::path::Path) -> Option<ImportedAssetInfo> {
|
|
|
|
|
use lightningbeam_core::clip::AudioClip;
|
|
|
|
|
self.autosave.pending_event = true;
|
|
|
|
|
self.media_modified = true;
|
|
|
|
|
self.note_possible_large_media(path);
|
|
|
|
|
|
|
|
|
|
let name = path.file_stem()
|
|
|
|
|
@ -4901,6 +5368,8 @@ impl EditorApp {
|
|
|
|
|
fn import_video(&mut self, path: &std::path::Path) -> Option<ImportedAssetInfo> {
|
|
|
|
|
use lightningbeam_core::clip::VideoClip;
|
|
|
|
|
use lightningbeam_core::video::probe_video;
|
|
|
|
|
self.autosave.pending_event = true;
|
|
|
|
|
self.media_modified = true;
|
|
|
|
|
self.note_possible_large_media(path);
|
|
|
|
|
|
|
|
|
|
let name = path.file_stem()
|
|
|
|
|
@ -5443,6 +5912,14 @@ impl EditorApp {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl eframe::App for EditorApp {
|
|
|
|
|
/// Clean shutdown → not a crash → delete this session's recovery file so it isn't offered for
|
|
|
|
|
/// restore on the next launch. (If we crash instead, `on_exit` never runs and the file remains.)
|
|
|
|
|
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
|
|
|
|
if let Some(path) = self.autosave.recovery_path.take() {
|
|
|
|
|
let _ = std::fs::remove_file(&path);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
|
|
|
|
self.tablet.poll(ctx, raw_input, self.selected_tool);
|
|
|
|
|
|
|
|
|
|
@ -5461,6 +5938,13 @@ impl eframe::App for EditorApp {
|
|
|
|
|
mobile::apply_touch_style(ctx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Background crash-recovery autosave (cheap early-out unless dirty + interval elapsed).
|
|
|
|
|
self.maybe_autosave(ctx);
|
|
|
|
|
// Offer to restore a previous session's unsaved work (if a recovery file was left behind).
|
|
|
|
|
self.render_recovery_prompt(ctx);
|
|
|
|
|
// Prompt to save unsaved changes before switching files (New / Open / Open Recent).
|
|
|
|
|
self.render_unsaved_prompt(ctx);
|
|
|
|
|
|
|
|
|
|
// === Raster fault-in (Phase 3 paging) ===
|
|
|
|
|
// The canvas records raster keyframe ids whose `raw_pixels` weren't resident
|
|
|
|
|
// (it can't mutate the document while rendering). Drain that sink here, BEFORE
|
|
|
|
|
@ -5700,6 +6184,9 @@ impl eframe::App for EditorApp {
|
|
|
|
|
let mut operation_complete = false;
|
|
|
|
|
let mut loaded_project_data: Option<(lightningbeam_core::file_io::LoadedProject, std::path::PathBuf)> = None;
|
|
|
|
|
let mut update_recent_menu = false; // Track if we need to update recent files menu
|
|
|
|
|
// An action that was waiting on this save (from the unsaved-changes prompt), to run
|
|
|
|
|
// after the file_operation borrow ends.
|
|
|
|
|
let mut after_save: Option<PendingAction> = None;
|
|
|
|
|
|
|
|
|
|
match operation {
|
|
|
|
|
FileOperation::Saving { ref mut progress_rx, ref path } => {
|
|
|
|
|
@ -5708,6 +6195,18 @@ impl eframe::App for EditorApp {
|
|
|
|
|
FileProgress::Done => {
|
|
|
|
|
println!("✅ Save complete!");
|
|
|
|
|
self.current_file_path = Some(path.clone());
|
|
|
|
|
// Manual save persisted everything to the user's file → no unsaved
|
|
|
|
|
// work to recover; rebase the autosave epoch so it stays quiet until
|
|
|
|
|
// the next real edit. (Inlined rather than reset_autosave_baseline()
|
|
|
|
|
// to avoid a second &mut self borrow inside the file_operation match.)
|
|
|
|
|
self.autosave.baseline_epoch = self.action_executor.epoch();
|
|
|
|
|
self.autosave.pending_event = false;
|
|
|
|
|
self.autosave.last_time = None;
|
|
|
|
|
// The document now matches its file → no unsaved changes.
|
|
|
|
|
self.saved_epoch = self.action_executor.epoch();
|
|
|
|
|
self.media_modified = false;
|
|
|
|
|
// If a file switch was waiting on this save, run it after the borrow.
|
|
|
|
|
after_save = self.after_save.take();
|
|
|
|
|
// Container path may be new (Save As); update the
|
|
|
|
|
// raster paging store so future faults read the right file.
|
|
|
|
|
self.raster_store.set_path(self.current_file_path.clone());
|
|
|
|
|
@ -5825,6 +6324,11 @@ impl eframe::App for EditorApp {
|
|
|
|
|
self.update_recent_files_menu();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// An action that was waiting on this save ("Save" in the prompt → New/Open/Quit) runs now.
|
|
|
|
|
if let Some(action) = after_save {
|
|
|
|
|
self.do_action(action, ctx);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Request repaint to keep updating progress
|
|
|
|
|
ctx.request_repaint();
|
|
|
|
|
}
|
|
|
|
|
@ -6038,6 +6542,9 @@ impl eframe::App for EditorApp {
|
|
|
|
|
|
|
|
|
|
if !clip_id.is_nil() {
|
|
|
|
|
// Finalize the clip (update pool_index and duration)
|
|
|
|
|
// A finished recording (samples in the pool) needs capturing.
|
|
|
|
|
self.autosave.pending_event = true;
|
|
|
|
|
self.media_modified = true;
|
|
|
|
|
if let Some(clip) = self.action_executor.document_mut().audio_clips.get_mut(&clip_id) {
|
|
|
|
|
if clip.finalize_recording(pool_index, duration) {
|
|
|
|
|
clip.name = format!("Recording {}", pool_index);
|
|
|
|
|
|