Prompt to save on quit too; unify with the switch prompt

Closing the window with unsaved changes was a silent data-loss path — and worse,
a clean exit deletes the recovery file, removing the safety net too.

Fold quitting into the same unsaved-changes flow as file switches: one
PendingAction enum (NewFile / Open / Quit), one modal, one do_action, one
after_save handler. render_unsaved_prompt now also intercepts the window-close
request (CancelClose + queue a Quit prompt); Save & Quit saves then closes,
Discard & Quit closes now, Cancel keeps the window open. confirmed_close lets the
final programmatic close through instead of re-intercepting it.

Replaces the near-duplicate close-prompt/switch-prompt code with a single path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Skyler Lehmkuhl 2026-07-09 18:17:37 -04:00
parent 6aece26a8b
commit 8fbb6d65c0
1 changed files with 72 additions and 43 deletions

View File

@ -809,14 +809,17 @@ impl AutosaveState {
} }
} }
/// A file switch deferred behind the unsaved-changes prompt: the thing to do once the user answers /// Something to do after the unsaved-changes prompt resolves: the action deferred behind "Save
/// Save / Don't Save (Cancel just drops it). /// 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)] #[derive(Debug, Clone)]
enum PendingSwitch { enum PendingAction {
/// New File (return to the start screen). /// New File (return to the start screen).
NewFile, NewFile,
/// Open a specific `.beam` (covers both Open… and Open Recent — the path is already resolved). /// Open a specific `.beam` (covers both Open… and Open Recent — the path is already resolved).
Open(std::path::PathBuf), Open(std::path::PathBuf),
/// Quit (close the window).
Quit,
} }
/// Information about an imported asset (for auto-placement) /// Information about an imported asset (for auto-placement)
@ -1313,10 +1316,13 @@ struct EditorApp {
/// Non-action changes since the last save (imports, finished recordings) that `epoch` doesn't /// Non-action changes since the last save (imports, finished recordings) that `epoch` doesn't
/// capture. Cleared on manual save / new / load. /// capture. Cleared on manual save / new / load.
media_modified: bool, media_modified: bool,
/// A file switch waiting on the "save changes?" prompt (New/Open/Open Recent with unsaved work). /// The pending action waiting on the "save changes?" prompt — a file switch (New/Open/Open
unsaved_prompt: Option<PendingSwitch>, /// Recent) or a quit, requested while the document had unsaved work.
/// A switch to perform once an in-flight save (triggered from that prompt) finishes. unsaved_prompt: Option<PendingAction>,
switch_after_save: Option<PendingSwitch>, /// 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 channel for background thread communication
audio_extraction_tx: std::sync::mpsc::Sender<AudioExtractionResult>, audio_extraction_tx: std::sync::mpsc::Sender<AudioExtractionResult>,
@ -1619,7 +1625,8 @@ impl EditorApp {
saved_epoch: 0, saved_epoch: 0,
media_modified: false, media_modified: false,
unsaved_prompt: None, unsaved_prompt: None,
switch_after_save: None, after_save: None,
confirmed_close: false,
audio_extraction_tx, audio_extraction_tx,
audio_extraction_rx, audio_extraction_rx,
export_dialog: export::dialog::ExportDialog::default(), export_dialog: export::dialog::ExportDialog::default(),
@ -3384,7 +3391,7 @@ impl EditorApp {
// File menu // File menu
MenuAction::NewFile => { MenuAction::NewFile => {
println!("Menu: New File"); println!("Menu: New File");
self.request_switch(PendingSwitch::NewFile); self.request_switch(PendingAction::NewFile);
} }
MenuAction::NewWindow => { MenuAction::NewWindow => {
println!("Menu: New Window"); println!("Menu: New Window");
@ -3444,14 +3451,14 @@ impl EditorApp {
.add_filter("Lightningbeam Project", &["beam"]) .add_filter("Lightningbeam Project", &["beam"])
.pick_file() .pick_file()
{ {
self.request_switch(PendingSwitch::Open(path)); self.request_switch(PendingAction::Open(path));
} }
} }
MenuAction::OpenRecent(index) => { MenuAction::OpenRecent(index) => {
let recent_files = self.config.get_recent_files(); let recent_files = self.config.get_recent_files();
if let Some(path) = recent_files.get(index) { if let Some(path) = recent_files.get(index) {
self.request_switch(PendingSwitch::Open(path.clone())); self.request_switch(PendingAction::Open(path.clone()));
} }
} }
MenuAction::ClearRecentFiles => { MenuAction::ClearRecentFiles => {
@ -4404,20 +4411,32 @@ impl EditorApp {
} }
} }
/// Show the "save changes?" prompt when a file switch is requested with unsaved work. Save /// The single "save changes?" prompt for any unsaved-work exit point — file switches (New /
/// persists first (Save As for untitled/recovered files) then switches; Don't Save switches and /// 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. /// discards; Cancel stays put.
fn render_unsaved_prompt(&mut self, ctx: &egui::Context) { 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 }; let Some(pending) = self.unsaved_prompt.as_ref() else { return };
let action_desc = match pending { // The "…before X?" tail + the affirmative button label, per action.
PendingSwitch::NewFile => "starting a new file", let (desc, discard_label) = match pending {
PendingSwitch::Open(_) => "opening another file", PendingAction::NewFile => ("starting a new file", "Don't Save"),
PendingAction::Open(_) => ("opening another file", "Don't Save"),
PendingAction::Quit => ("quitting", "Discard & Quit"),
}; };
#[derive(PartialEq)] #[derive(PartialEq)]
enum Answer { enum Answer {
Save, Save,
DontSave, Discard,
Cancel, Cancel,
} }
let mut answer: Option<Answer> = None; let mut answer: Option<Answer> = None;
@ -4427,15 +4446,15 @@ impl EditorApp {
ui.heading("Save changes?"); ui.heading("Save changes?");
ui.add_space(6.0); ui.add_space(6.0);
ui.label(format!( ui.label(format!(
"This document has unsaved changes. Save them before {action_desc}?" "This document has unsaved changes. Save them before {desc}?"
)); ));
ui.add_space(14.0); ui.add_space(14.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
if ui.button("Save").clicked() { if ui.button("Save").clicked() {
answer = Some(Answer::Save); answer = Some(Answer::Save);
} }
if ui.button("Don't Save").clicked() { if ui.button(discard_label).clicked() {
answer = Some(Answer::DontSave); answer = Some(Answer::Discard);
} }
if ui.button("Cancel").clicked() { if ui.button("Cancel").clicked() {
answer = Some(Answer::Cancel); answer = Some(Answer::Cancel);
@ -4447,13 +4466,13 @@ impl EditorApp {
Some(Answer::Cancel) => { Some(Answer::Cancel) => {
self.unsaved_prompt = None; self.unsaved_prompt = None;
} }
Some(Answer::DontSave) => { Some(Answer::Discard) => {
if let Some(sw) = self.unsaved_prompt.take() { if let Some(action) = self.unsaved_prompt.take() {
self.do_switch(sw); self.do_action(action, ctx);
} }
} }
Some(Answer::Save) => { Some(Answer::Save) => {
let sw = self.unsaved_prompt.take(); let action = self.unsaved_prompt.take();
// Save to the existing file, or Save As for an untitled / recovered document. // Save to the existing file, or Save As for an untitled / recovered document.
let real_path = self let real_path = self
.current_file_path .current_file_path
@ -4468,12 +4487,12 @@ impl EditorApp {
}; };
match target { match target {
Some(path) => { Some(path) => {
// Run the switch once the save completes. // Run the action once the save completes.
self.switch_after_save = sw; self.after_save = action;
self.save_to_file(path); self.save_to_file(path);
} }
None => { None => {
// Save As cancelled → abort the switch, keep the document (still unsaved). // Save As cancelled → abort, keep the document (still unsaved).
} }
} }
} }
@ -4500,21 +4519,31 @@ impl EditorApp {
self.app_mode = AppMode::StartScreen; self.app_mode = AppMode::StartScreen;
} }
/// Carry out a file switch once the unsaved-changes prompt is resolved (or when there were no /// Carry out a deferred action once the unsaved-changes prompt is resolved (or when there was
/// unsaved changes to begin with). /// nothing unsaved to begin with).
fn do_switch(&mut self, switch: PendingSwitch) { fn do_action(&mut self, action: PendingAction, ctx: &egui::Context) {
match switch { match action {
PendingSwitch::NewFile => self.do_new_file(), PendingAction::NewFile => self.do_new_file(),
PendingSwitch::Open(path) => self.load_from_file(path), 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, prompting to save first if the document has unsaved changes. /// Begin a file switch (New / Open / Open Recent), prompting to save first if the document has
fn request_switch(&mut self, switch: PendingSwitch) { /// 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() { if self.document_modified() {
self.unsaved_prompt = Some(switch); self.unsaved_prompt = Some(action);
} else { } else {
self.do_switch(switch); match action {
PendingAction::NewFile => self.do_new_file(),
PendingAction::Open(path) => self.load_from_file(path),
PendingAction::Quit => {}
}
} }
} }
@ -6155,9 +6184,9 @@ impl eframe::App for EditorApp {
let mut operation_complete = false; let mut operation_complete = false;
let mut loaded_project_data: Option<(lightningbeam_core::file_io::LoadedProject, std::path::PathBuf)> = None; 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 let mut update_recent_menu = false; // Track if we need to update recent files menu
// A file switch that was waiting on this save (from the unsaved-changes prompt), to run // An action that was waiting on this save (from the unsaved-changes prompt), to run
// after the file_operation borrow ends. // after the file_operation borrow ends.
let mut switch_after_save: Option<PendingSwitch> = None; let mut after_save: Option<PendingAction> = None;
match operation { match operation {
FileOperation::Saving { ref mut progress_rx, ref path } => { FileOperation::Saving { ref mut progress_rx, ref path } => {
@ -6177,7 +6206,7 @@ impl eframe::App for EditorApp {
self.saved_epoch = self.action_executor.epoch(); self.saved_epoch = self.action_executor.epoch();
self.media_modified = false; self.media_modified = false;
// If a file switch was waiting on this save, run it after the borrow. // If a file switch was waiting on this save, run it after the borrow.
switch_after_save = self.switch_after_save.take(); after_save = self.after_save.take();
// Container path may be new (Save As); update the // Container path may be new (Save As); update the
// raster paging store so future faults read the right file. // raster paging store so future faults read the right file.
self.raster_store.set_path(self.current_file_path.clone()); self.raster_store.set_path(self.current_file_path.clone());
@ -6295,9 +6324,9 @@ impl eframe::App for EditorApp {
self.update_recent_files_menu(); self.update_recent_files_menu();
} }
// A file switch that was waiting on this save (New/Open after choosing "Save") now runs. // An action that was waiting on this save ("Save" in the prompt → New/Open/Quit) runs now.
if let Some(switch) = switch_after_save { if let Some(action) = after_save {
self.do_switch(switch); self.do_action(action, ctx);
} }
// Request repaint to keep updating progress // Request repaint to keep updating progress