collective

Browse / github.com/storytold/filmcraft / patch

Fixes: Open Project save prompt, mixer.meterInputOnly params, rayon deadlock test wait, two macOS font tests

Fixes: Open Project save prompt, mixer.meterInputOnly params, rayon deadlock test wait, two macOS font tests This branch brings in the pull requests below so they can land together; each one stays open for its own review. - #1665 Projects: Open Project asks to save unsaved changes first: File › Open Project, File › Open Recent and Home's Recent list show the existing Save / Don't Save / Cancel prompt before they open another project, so unsaved edits are no longer replaced without asking. Close Project and Quit already showed this prompt. Evidence: https://github.com/storytold/filmcraft/pull/1665 (on `234b3454`, after an edit in `current.fcproj`, choosing `other.fcproj` in File › Open Project opens

Status
open
Score
0.00
Adopters
0 other operators
Author
dotCooCoo (imported from pull request #3385, open)
Base
8fda2f232198c4c79ff9cfe5b6a347bb1375fa9e
Size
+626 −15 in 11 files
Review
pending
Shared
2026-10-28
Id
8b6aa39ad7d99ae9109fa5d9

How it went

No other operator has reported using it yet.

Diff

raw · contributors/people.toml crates/codecs/src/gop.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/panels/clip_dialogs.rs crates/ui-egui/src/panels/import_mode.rs crates/ui-egui/src/panels/mixer.rs crates/ui-egui/tests/it/captions_font_ui.rs crates/ui-egui/tests/it/keyboard_ui.rs crates/ui-egui/tests/open_project_prompt_ui.rs docs/project-files.md

diff --git a/contributors/people.toml b/contributors/people.toml--- a/contributors/people.toml+++ b/contributors/people.toml@@ -88,3 +88,7 @@ sync_github_name = false  [people.webdevtodayjason] display_name = "Jason Brashear"++[people.dotCooCoo]+real_name = "Robert Lee"+sync_github_name = truediff --git a/crates/codecs/src/gop.rs b/crates/codecs/src/gop.rs--- a/crates/codecs/src/gop.rs+++ b/crates/codecs/src/gop.rs@@ -1097,21 +1097,52 @@ mod tests {             let _ = tx.send(());         });         // A deadlock stops every request of a round for good. On a loaded machine the 3,840-        // requests can take minutes, so the limit applies to the time between two answered ones.-        let mut answered = 0;+        // requests can take minutes, so the limit applies to the time since the last answered one.+        if let Err(frames) = wait_while_answered(&rx, &done, Duration::from_secs(120)) {+            panic!("frame requests deadlocked after {frames} frames");+        }+    }++    /// Waits for `rx` while `done` keeps growing. Returns `Err` with the count in `done` once+    /// `limit` has passed since the count last grew (or since the call, before it grows).+    fn wait_while_answered(rx: &std::sync::mpsc::Receiver<()>, done: &AtomicUsize, limit: Duration) -> Result<(), usize> {+        let mut answered = done.load(Ordering::Relaxed);+        let mut last_answer = Instant::now();         loop {-            match rx.recv_timeout(Duration::from_secs(120)) {-                Ok(()) => break,+            match rx.recv_timeout(limit / 120) {+                Ok(()) => return Ok(()),                 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {                     let now = done.load(Ordering::Relaxed);-                    assert!(now > answered, "frame requests deadlocked after {now} frames");-                    answered = now;+                    if now > answered {+                        answered = now;+                        last_answer = Instant::now();+                    } else if last_answer.elapsed() >= limit {+                        return Err(now);+                    }                 }                 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!("a frame request failed"),             }         }     } +    #[test]+    fn a_stall_right_after_an_answer_is_reported_once_the_limit_has_passed() {+        let (_tx, rx) = std::sync::mpsc::channel();+        let done = Arc::new(AtomicUsize::new(0));+        let answering = done.clone();+        let (answer_tx, answer_rx) = std::sync::mpsc::channel();+        let limit = Duration::from_secs(2);+        std::thread::spawn(move || {+            std::thread::sleep(Duration::from_millis(50));+            let answer = Instant::now();+            answering.fetch_add(1, Ordering::Relaxed);+            let _ = answer_tx.send(answer);+        });+        assert_eq!(wait_while_answered(&rx, &done, limit), Err(1));+        let waited = answer_rx.recv().expect("answer time").elapsed();+        assert!(waited >= limit && waited < limit * 3 / 2, "the stall was reported {waited:?} after the answer");+    }+     #[test]     fn running_decoder_continues_through_a_nearby_sync_sample() {         let s = samples(200, 30, 0, false);diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -1708,6 +1708,10 @@ impl FilmcraftApp {             }             "file.open" => self.pick(FileDialog::OpenProject, |app, paths| {                 let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) };+                // unsaved changes: Save / Don't Save / Cancel first+                if panels::clip_dialogs::intercept_open(app, &path) {+                    return Ok(json!({"dialog": "openProject"}));+                }                 app.session.execute("file.open", json!({"path": path})).map_err(|e| e.to_string())             }),             "graphics.newFromFile" => {diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs--- a/crates/ui-egui/src/menus.rs+++ b/crates/ui-egui/src/menus.rs@@ -583,6 +583,10 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:     // File ▸ Open Recent     if let Some(i) = id.strip_prefix("file.openRecent.") {         let path = i.parse::<usize>().ok().and_then(|i| app.session.prefs.general.recent_projects.get(i).cloned()).ok_or("no such recent project")?;+        // unsaved changes: Save / Don't Save / Cancel first+        if crate::panels::clip_dialogs::intercept_open(app, &path) {+            return Ok(json!({"dialog": "openProject"}));+        }         return invoke(app, ctx, "file.open", json!({"path": path}));     }     if id == "file.clearRecent" {@@ -1246,6 +1250,8 @@ mod tests {          let app = FilmcraftApp::new(filmcraft_engine::Session::default());         let mut harness = Harness::builder().with_size(egui::vec2(1200.0, 700.0)).build_ui_state(|ui, app| menu_bar(app, ui), app);+        // the app's fonts, which draw the macOS shortcut symbols (⇧⌘S) egui's default fonts lack+        crate::theme::install(&harness.ctx, &harness.state().tokens);         harness.run_steps(3);         // Nothing open: hovering a title does not open it.         harness.get_by_label("Edit").hover();diff --git a/crates/ui-egui/src/panels/clip_dialogs.rs b/crates/ui-egui/src/panels/clip_dialogs.rs--- a/crates/ui-egui/src/panels/clip_dialogs.rs+++ b/crates/ui-egui/src/panels/clip_dialogs.rs@@ -1,7 +1,7 @@ //! Edit / Clip / File menu dialogs (M3.10): Paste Attributes, Remove Attributes, Offline File, //! Make Subclip, Edit Subclip, Modify ▸ Audio Channels, Modify ▸ Timecode, Frame Hold Options,-//! Field Options, Clip Speed / Duration, Nest… (Nested Sequence Name), and the Close Project and-//! Quit "save changes?" prompts.+//! Field Options, Clip Speed / Duration, Nest… (Nested Sequence Name), and the Close Project,+//! Open Project and Quit "save changes?" prompts. //! //! The open dialog lives in `UiState::clip_dialog` (serde): the engine command it runs and the //! parameters being edited — the same JSON the command takes — so agents can open a dialog from@@ -27,6 +27,8 @@ //! - Clip Speed / Duration `speedDuration.*`: `speed`, `reverse`, `ripple`, //!   `interpolation.<frameSampling|frameBlending|opticalFlow>`; //! - Close Project `closeProject.save`, `closeProject.dontSave`, `closeProject.cancel`;+//! - Open Project (asked once the project to open is chosen, see [`intercept_open`])+//!   `openProject.save`, `openProject.dontSave`, `openProject.cancel`; //! - Quit (asked when the window is closed with unsaved changes, see [`intercept_quit`]) //!   `quit.save`, `quit.dontSave`, `quit.cancel`. @@ -61,6 +63,7 @@ fn meta(command: &str) -> Option<(&'static str, &'static str)> {         "clip.fieldOptions" => (tl!("Field Options"), "fieldOptions"),         "clip.speedDuration" => (tl!("Clip Speed / Duration"), "speedDuration"),         "file.closeProject" => (tl!("Save Project"), "closeProject"),+        "file.open" => (tl!("Save Project"), "openProject"),         "app.quit" => (tl!("Save Project"), "quit"),         _ => return None,     })@@ -95,7 +98,8 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option<Result<     if id == "file.saveAll" && app.session.path.is_none() {         return Some(app.file_dialog("file.save", params));     }-    if (id == "file.closeProject" && !app.session.is_dirty()) || id == "app.quit" {+    // Open Project asks once the project to open is chosen (`intercept_open`)+    if (id == "file.closeProject" && !app.session.is_dirty()) || id == "app.quit" || id == "file.open" {         return None;     }     meta(id)?;@@ -116,11 +120,23 @@ pub fn intercept_quit(app: &mut FilmcraftApp) -> bool {         return false;     }     if app.ui.clip_dialog.as_ref().is_none_or(|d| d.command != "app.quit") {-        app.ui.clip_dialog = Some(ClipDialogDraft { command: "app.quit".into(), params: json!({}), info: Value::Null, error: String::new() });+        app.ui.clip_dialog = Some(ClipDialogDraft { command: "app.quit".into(), params: json!({}), info: asked_over(&app.session), error: String::new() });     }     true } +/// File ▸ Open Project once the project at `path` is chosen, File ▸ Open Recent and Home's Recent+/// list: with unsaved changes Save / Don't Save / Cancel is asked first, as Close Project asks, and+/// `path` opens after Save or Don't Save. Returns whether it asked.+pub fn intercept_open(app: &mut FilmcraftApp, path: &str) -> bool {+    if !app.session.is_dirty() {+        return false;+    }+    app.ui.clip_dialog =+        Some(ClipDialogDraft { command: "file.open".into(), params: json!({"path": path}), info: asked_over(&app.session), error: String::new() });+    true+}+ /// Initial parameters (and display info) of a command's dialog. fn defaults(app: &FilmcraftApp, id: &str) -> (Value, Value) {     let s = &app.session;@@ -230,10 +246,17 @@ fn defaults(app: &FilmcraftApp, id: &str) -> (Value, Value) {                 json!({"duration": it.as_ref().map_or(0, |i| i.duration.0), "speed": speed.abs(), "fps": fps}),             )         }+        "file.closeProject" => (json!({}), asked_over(s)),         _ => (json!({}), Value::Null),     } } +/// The info of the Close Project, Open Project and Quit prompts: the project they are asked over+/// (`Session::document_epoch`). When another project replaces it, the prompt closes.+fn asked_over(s: &filmcraft_engine::Session) -> Value {+    json!({"project": s.document_epoch})+}+ fn format_name(f: AudioChannels) -> &'static str {     match f {         AudioChannels::Mono => "mono",@@ -307,11 +330,17 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {         app.ui.clip_dialog = None;         return;     };+    // a save prompt asked over a project that another one has replaced since (a script opened,+    // created or closed to it) closes without acting on the new project+    if d.info["project"].as_u64().is_some_and(|p| p != app.session.document_epoch) {+        app.ui.clip_dialog = None;+        return;+    }     let mut elems: Elems = Vec::new();     let mut action: Option<&'static str> = None;     let project_name = app.session.project.name.clone();     // a never-saved project is saved through the Save dialog-    let can_save = app.session.path.is_some() || app.hooks.pick_save.is_some();+    let can_save = app.session.path.is_some() || crate::native_dialogs::FileDialog::SaveProject { name: String::new() }.available(&app.hooks);     // the title is translated; the window keeps one id whatever the interface language     let id = egui::Id::unique(("clip-dialog", pre));     egui::Window::new(title).id(id).collapsible(false).resizable(false).default_width(360.0).centered(ctx).show(ctx, |ui| {@@ -541,7 +570,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {                     }                 }             }-            "file.closeProject" | "app.quit" => {+            "file.closeProject" | "file.open" | "app.quit" => {                 ui.label(tlf!("Save changes to “{project_name}” before closing?", project_name));                 ui.add_space(8.0);                 ui.horizontal(|ui| {@@ -607,6 +636,16 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {             }         }         Some("dontSave") | Some("save") => {+            // Open Project ▸ Save on a never-saved project: the chosen project opens once the Save+            // dialog's file is saved, which can be a later frame (Linux)+            if action == Some("save") && d.command == "file.open" && app.session.path.is_none() {+                app.ui.clip_dialog = None;+                if let Err(e) = save_then_open(app, d.clone()) {+                    app.ui.status = e;+                    app.ui.clip_dialog = Some(d);+                }+                return;+            }             if action == Some("save") {                 let saved = if app.session.path.is_some() {                     app.session.execute("file.save", json!({})).map(|_| ()).map_err(|e| e.to_string())@@ -624,6 +663,16 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {                     return;                 }             }+            // Open Project opens the chosen project; when that fails, this project stays open with+            // its unsaved changes+            if d.command == "file.open" {+                match app.session.execute("file.open", d.params.clone()) {+                    Ok(_) => edit_after_open(app, &d),+                    Err(e) => app.ui.status = e.to_string(),+                }+                app.ui.clip_dialog = None;+                return;+            }             // Quit ▸ Don't Save discards the changes: closing the project also clears their recovery             // snapshot, so the next launch doesn't offer them back             let close = d.command != "app.quit" || app.session.is_dirty();@@ -642,6 +691,51 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {     app.ui.clip_dialog = Some(d); } +/// Open Project ▸ Save on a never-saved project: the Save dialog, then `prompt`'s project opens+/// once the file is saved. The dialog's answer runs when it arrives (the host may answer in a later+/// frame). A canceled dialog or a failed save shows the prompt again. An answer that arrives after+/// another project was opened, created or closed to does nothing, and one that arrives while another+/// prompt is shown saves but leaves the opening to that prompt. Returns an error when no dialog can+/// be shown.+fn save_then_open(app: &mut FilmcraftApp, prompt: ClipDialogDraft) -> Result<Value, String> {+    let name = format!("{}.fcproj", app.session.project.name);+    // the project the dialog saves (not one opened, created or closed to meanwhile)+    let project = app.session.document_epoch;+    app.pick(crate::native_dialogs::FileDialog::SaveProject { name }, move |app, paths| {+        if app.session.document_epoch != project {+            return Ok(Value::Null);+        }+        let Some(path) = paths.into_iter().next() else {+            if app.ui.clip_dialog.is_none() {+                app.ui.clip_dialog = Some(prompt);+            }+            return Ok(Value::Null);+        };+        if let Err(e) = app.session.execute("file.saveAs", json!({"path": path})) {+            app.ui.status = e.to_string();+            if app.ui.clip_dialog.is_none() {+                app.ui.clip_dialog = Some(prompt);+            }+            return Ok(Value::Null);+        }+        if app.ui.clip_dialog.is_none() {+            match app.session.execute("file.open", prompt.params.clone()) {+                Ok(_) => edit_after_open(app, &prompt),+                Err(e) => app.ui.status = e.to_string(),+            }+        }+        Ok(Value::Null)+    })+}++/// A Recent row on Home asked (`editAfterOpen`): the project it chose is open, so Home switches to+/// Edit, as it does when no prompt was needed.+fn edit_after_open(app: &mut FilmcraftApp, prompt: &ClipDialogDraft) {+    if prompt.info["editAfterOpen"].as_bool() == Some(true) {+        app.ui.mode = crate::state::Mode::Edit;+    }+}+ /// The parameters sent on OK (dialog-only fields dropped or converted). fn finish(d: &ClipDialogDraft) -> Value {     let mut p = d.params.clone();diff --git a/crates/ui-egui/src/panels/import_mode.rs b/crates/ui-egui/src/panels/import_mode.rs--- a/crates/ui-egui/src/panels/import_mode.rs+++ b/crates/ui-egui/src/panels/import_mode.rs@@ -109,7 +109,13 @@ fn recent(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, y: f32) -> f32     }     if let Some(path) = open {         let ctx = ui.ctx().clone();-        if crate::menus::invoke(app, &ctx, "file.open", json!({"path": path})).is_ok() {+        // unsaved changes: Save / Don't Save / Cancel first, asked over the project they were made in;+        // Home switches to Edit once the chosen project opens, and stays after Cancel+        if crate::panels::clip_dialogs::intercept_open(app, &path) {+            if let Some(info) = app.ui.clip_dialog.as_mut().and_then(|d| d.info.as_object_mut()) {+                info.insert("editAfterOpen".into(), json!(true));+            }+        } else if crate::menus::invoke(app, &ctx, "file.open", json!({"path": path})).is_ok() {             app.ui.mode = crate::state::Mode::Edit;         }     }diff --git a/crates/ui-egui/src/panels/mixer.rs b/crates/ui-egui/src/panels/mixer.rs--- a/crates/ui-egui/src/panels/mixer.rs+++ b/crates/ui-egui/src/panels/mixer.rs@@ -232,8 +232,11 @@ pub(crate) fn open_show_hide_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) }  /// Meter Input(s) Only (`mixer.meterInputOnly`, `{"on":bool?}`): sets the option, or toggles it-/// without `on`.+/// without `on`. Parameters that are neither an object nor null are an error. pub(crate) fn set_meter_input_only(app: &mut FilmcraftApp, params: &Value) -> Result<Value, String> {+    if !params.is_object() && !params.is_null() {+        return Err("command parameters must be an object".into());+    }     let on = match params.get("on") {         None | Some(Value::Null) => !app.ui.mixer_meter_input_only,         Some(v) => v.as_bool().ok_or("`on` must be true or false")?,diff --git a/crates/ui-egui/tests/it/captions_font_ui.rs b/crates/ui-egui/tests/it/captions_font_ui.rs--- a/crates/ui-egui/tests/it/captions_font_ui.rs+++ b/crates/ui-egui/tests/it/captions_font_ui.rs@@ -20,7 +20,9 @@ impl Driver {         session.execute("file.openDemoProject", json!({})).expect("demo project");         let (tx, rx) = channel();         let app = FilmcraftApp::new(session).with_control(rx);-        let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app);+        // the font list names every installed family, and some names are in scripts the interface+        // fonts do not cover (macOS lists "इनाईमाथी" for InaiMathi): draw those as boxes+        let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).allow_missing_glyphs().build_eframe(move |_cc| app);         let mut d = Driver { harness, tx };         d.frames(4);         ddiff --git a/crates/ui-egui/tests/it/keyboard_ui.rs b/crates/ui-egui/tests/it/keyboard_ui.rs--- a/crates/ui-egui/tests/it/keyboard_ui.rs+++ b/crates/ui-egui/tests/it/keyboard_ui.rs@@ -306,6 +306,24 @@ fn audio_track_mixer_keys_open_show_hide_and_toggle_meter_input_only() {     assert_eq!(d.app().ui.focused, filmcraft_ui_egui::dock::PanelKind::AudioTrackMixer); } +/// `mixer.meterInputOnly` refuses parameters that are not an object and leaves the option as it+/// was; null parameters toggle it.+#[test]+fn meter_input_only_refuses_parameters_that_are_not_an_object() {+    let mut d = Driver::new();+    assert!(!d.app().ui.mixer_meter_input_only);+    for params in [json!(true), json!(1), json!("on"), json!([true])] {+        let (req, reply) = ControlRequest::new("ui.menu.invoke", json!({"id": "mixer.meterInputOnly", "params": params}));+        d.tx.send(req).unwrap();+        d.frames(3);+        let response = reply.try_recv().expect("the command is answered");+        assert_eq!(response["ok"], false, "{params}: {response}");+        assert!(!d.app().ui.mixer_meter_input_only, "{params} leaves Meter Input(s) Only off");+    }+    let r = d.ok("ui.menu.invoke", json!({"id": "mixer.meterInputOnly", "params": null}));+    assert_eq!(r, json!({"meterInputOnly": true}));+}+ /// Off macOS the menus show `Ctrl+…`, so that is what agents send: a `Ctrl` chord through /// `ui.key` (and `ctrl` in `ui.click` modifiers) must act like the physical key, which egui-winit /// reports as `ctrl` + `command`. Bindings are written `Cmd+…`, so this failed to match (#245).diff --git a/crates/ui-egui/tests/open_project_prompt_ui.rs b/crates/ui-egui/tests/open_project_prompt_ui.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/open_project_prompt_ui.rs@@ -0,0 +1,439 @@+//! Headless UI tests of File ▸ Open Project, File ▸ Open Recent and Home's Recent list with unsaved+//! changes: once the project to open is chosen, the Save Project prompt asks Cancel / Don't Save /+//! Save before it opens.++use std::path::PathBuf;+use std::sync::mpsc::{Sender, channel};+use std::sync::{Arc, Mutex};++use egui_kittest::Harness;+use filmcraft_engine::Session;+use filmcraft_engine::project::{ClipId, Label};+use filmcraft_ui_egui::FilmcraftApp;+use filmcraft_ui_egui::control::ControlRequest;+use filmcraft_ui_egui::native_dialogs::FileDialog;+use filmcraft_ui_egui::state::Mode;+use serde_json::{Value, json};++struct Driver {+    harness: Harness<'static, FilmcraftApp>,+    tx: Sender<ControlRequest>,+}++impl Driver {+    fn new() -> Self {+        let mut s = Session::default();+        s.execute("file.openDemoProject", json!({})).unwrap();+        let (tx, rx) = channel();+        let app = FilmcraftApp::new(s).with_control(rx);+        let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app);+        let mut d = Driver { harness, tx };+        d.frames(4);+        d+    }++    fn frames(&mut self, n: usize) {+        for _ in 0..n {+            // the control channel's clicks and keys enter through the input hook+            let ctx = self.harness.ctx.clone();+            let mut raw = std::mem::take(self.harness.input_mut());+            eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);+            *self.harness.input_mut() = raw;+            self.harness.step();+        }+    }++    fn call(&mut self, method: &str, params: Value) -> Value {+        let (req, reply) = ControlRequest::new(method, params.clone());+        self.tx.send(req).unwrap();+        for _ in 0..600 {+            self.frames(1);+            if let Ok(v) = reply.try_recv() {+                return v;+            }+        }+        panic!("no reply to {method} {params}");+    }++    fn ok(&mut self, method: &str, params: Value) -> Value {+        let v = self.call(method, params.clone());+        assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}");+        v["result"].clone()+    }++    fn exec(&mut self, command: &str, params: Value) -> Value {+        self.ok("engine.execute", json!({"command": command, "params": params}))+    }++    fn menu(&mut self, id: &str) -> Value {+        let r = self.ok("ui.menu.invoke", json!({"id": id}));+        self.frames(3);+        r+    }++    fn click(&mut self, id: &str) {+        self.ok("ui.click", json!({"id": id}));+        self.frames(3);+    }++    fn has(&mut self, id: &str) -> bool {+        let v = self.ok("ui.elements", json!({"prefix": id}));+        v.as_array().unwrap().iter().any(|e| e["id"] == json!(id))+    }++    fn app(&mut self) -> &mut FilmcraftApp {+        self.harness.state_mut()+    }++    fn path(&mut self) -> Option<String> {+        self.app().session.path.clone()+    }++    /// Make an unsaved change: the first clip turns yellow. Returns the clip.+    fn edit(&mut self) -> ClipId {+        let c = self.app().session.active_sequence().unwrap().video_tracks[0].items[0].id;+        self.exec("timeline.select", json!({"clips": [c.0]}));+        self.menu("edit.label.yellow");+        assert!(self.app().session.is_dirty());+        c+    }++    /// The label of `clip` in the open project.+    fn label(&mut self, clip: ClipId) -> Label {+        let p = &self.app().session.project;+        p.sequences().filter_map(|i| p.sequence(i.id)).find_map(|q| q.find_item(clip).map(|(_, it)| it.label)).unwrap()+    }++    /// File ▸ Open Project… with the Open dialog answering `path`.+    fn open_project(&mut self, path: &str) -> Value {+        let path = path.to_string();+        self.app().hooks.pick_open_project = Some(Box::new(move || Some(path.clone())));+        self.menu("file.open")+    }++    /// Where `path` is in the recent projects (File ▸ Open Recent, Home's Recent list).+    fn recent(&mut self, path: &str) -> usize {+        self.app().session.prefs.general.recent_projects.iter().position(|p| p == path).unwrap()+    }+}++/// A folder for one test holding the demo project saved as `current.fcproj` and `other.fcproj`.+/// The session is left on `current.fcproj` with nothing unsaved.+fn projects(d: &mut Driver, test: &str) -> (PathBuf, String, String) {+    let dir = std::env::temp_dir().join(format!("fc-open-project-prompt-{}-{test}", std::process::id()));+    let _ = std::fs::remove_dir_all(&dir);+    std::fs::create_dir_all(&dir).unwrap();+    let current = dir.join("current.fcproj").to_string_lossy().into_owned();+    let other = dir.join("other.fcproj").to_string_lossy().into_owned();+    d.exec("file.saveAs", json!({"path": other}));+    d.exec("file.saveAs", json!({"path": current}));+    assert!(!d.app().session.is_dirty());+    (dir, current, other)+}++#[test]+fn open_project_with_unsaved_changes_asks_first_and_cancel_keeps_the_project() {+    let mut d = Driver::new();+    let (dir, current, other) = projects(&mut d, "cancel");+    let clip = d.edit();+    let r = d.open_project(&other);+    assert_eq!(d.path(), Some(current.clone()), "nothing opens before an answer");+    assert_eq!(r["dialog"], "openProject");+    assert!(d.has("openProject.save") && d.has("openProject.dontSave") && d.has("openProject.cancel"));+    d.click("openProject.cancel");+    assert!(d.app().ui.clip_dialog.is_none());+    assert_eq!(d.path(), Some(current));+    assert!(d.app().session.is_dirty());+    assert_eq!(d.label(clip), Label::Yellow, "the change is kept");+    let _ = std::fs::remove_dir_all(&dir);+}++#[test]+fn open_project_save_saves_the_changes_then_opens_the_chosen_project() {+    let mut d = Driver::new();+    let (dir, current, other) = projects(&mut d, "save");+    let clip = d.edit();+    d.open_project(&other);+    d.click("openProject.save");+    assert!(d.app().ui.clip_dialog.is_none());+    assert_eq!(d.path(), Some(other));+    assert!(!d.app().session.is_dirty());+    // the change was written to the project it was made in+    d.exec("file.open", json!({"path": current}));+    assert_eq!(d.label(clip), Label::Yellow);+    let _ = std::fs::remove_dir_all(&dir);+}++#[test]+fn open_project_dont_save_opens_the_chosen_project_without_saving() {+    let mut d = Driver::new();+    let (dir, current, other) = projects(&mut d, "dontSave");+    let first = d.app().session.active_sequence().unwrap().video_tracks[0].items[0].clone();+    assert_ne!(first.label, Label::Yellow);+    let clip = d.edit();+    // a project that can't be opened leaves the current one open with its changes+    let missing = dir.join("missing.fcproj").to_string_lossy().into_owned();+    d.open_project(&missing);+    d.click("openProject.dontSave");+    assert!(d.app().ui.clip_dialog.is_none());+    assert!(d.app().ui.status.contains("missing.fcproj"), "{}", d.app().ui.status);+    assert_eq!(d.path(), Some(current.clone()));+    assert!(d.app().session.is_dirty());+    assert_eq!(d.label(clip), Label::Yellow);+    d.open_project(&other);+    d.click("openProject.dontSave");+    assert!(d.app().ui.clip_dialog.is_none());+    assert_eq!(d.path(), Some(other));+    assert!(!d.app().session.is_dirty());+    // the project the change was made in is as it was saved+    d.exec("file.open", json!({"path": current}));+    assert_eq!(d.label(clip), first.label);+    let _ = std::fs::remove_dir_all(&dir);+}++#[test]+fn open_project_asks_only_with_unsaved_changes_and_a_scripted_open_does_not_ask() {+    let mut d = Driver::new();+    let (dir, current, other) = projects(&mut d, "clean");+    let r = d.open_project(&other);+    assert!(d.app().ui.clip_dialog.is_none(), "nothing unsaved: no prompt");+    assert_eq!(r["path"], json!(other));+    assert_eq!(d.path(), Some(other.clone()));+    d.edit();+    assert_eq!(d.open_project(&current)["dialog"], "openProject");+    assert_eq!(d.path(), Some(other));+    d.click("openProject.cancel");+    // `file.open` with a path (agents, MCP) opens without asking+    d.exec("file.open", json!({"path": current}));+    assert!(d.app().ui.clip_dialog.is_none());+    assert_eq!(d.path(), Some(current));+    let _ = std::fs::remove_dir_all(&dir);+}++#[test]+fn open_recent_with_unsaved_changes_asks_first() {+    let mut d = Driver::new();+    let (dir, current, other) = projects(&mut d, "openRecent");+    let clip = d.edit();+    let i = d.recent(&other);+    let r = d.menu(&format!("file.openRecent.{i}"));+    assert_eq!(r["dialog"], "openProject");+    assert_eq!(d.path(), Some(current.clone()), "nothing opens before an answer");+    d.click("openProject.cancel");+    assert_eq!(d.path(), Some(current.clone()));+    assert_eq!(d.label(clip), Label::Yellow, "the change is kept");+    let i = d.recent(&other);+    d.menu(&format!("file.openRecent.{i}"));+    d.click("openProject.save");+    assert!(d.app().ui.clip_dialog.is_none());+    assert_eq!(d.path(), Some(other));+    assert!(!d.app().session.is_dirty());+    // the change was written to the project it was made in+    d.exec("file.open", json!({"path": current}));+    assert_eq!(d.label(clip), Label::Yellow);+    let _ = std::fs::remove_dir_all(&dir);+}++#[test]+fn home_recent_with_unsaved_changes_asks_first() {+    let mut d = Driver::new();+    let (dir, current, other) = projects(&mut d, "homeRecent");+    let first = d.app().session.active_sequence().unwrap().video_tracks[0].items[0].clone();+    let clip = d.edit();+    d.click("header.home");+    let i = d.recent(&other);+    d.click(&format!("import.recent.{i}"));+    assert_eq!(d.path(), Some(current.clone()), "nothing opens before an answer");+    assert_eq!(d.app().ui.mode, Mode::Import, "Home stays while it asks");+    // Cancel: Home stays, with this project and its changes+    d.click("openProject.cancel");+    assert!(d.app().ui.clip_dialog.is_none());+    assert_eq!((d.path(), d.app().ui.mode), (Some(current.clone()), Mode::Import));+    assert!(d.app().session.is_dirty());+    d.click(&format!("import.recent.{i}"));+    assert!(d.has("openProject.dontSave"));+    d.click("openProject.dontSave");+    assert!(d.app().ui.clip_dialog.is_none());+    assert_eq!(d.path(), Some(other));+    assert_eq!(d.app().ui.mode, Mode::Edit);+    assert!(!d.app().session.is_dirty());+    // the project the change was made in is as it was saved+    d.exec("file.open", json!({"path": current}));+    assert_eq!(d.label(clip), first.label);+    let _ = std::fs::remove_dir_all(&dir);+}++#[test]+fn home_recent_save_on_a_never_saved_project_switches_to_edit_once_it_opens() {+    let mut d = Driver::new();+    let (dir, _, other) = projects(&mut d, "homeNeverSaved");+    d.app().session.path = None;+    d.edit();+    let saved = dir.join("saved.fcproj").to_string_lossy().into_owned();+    d.app().hooks.pick_save = Some(Box::new(move |_| Some(saved.clone())));+    d.click("header.home");+    let i = d.recent(&other);+    d.click(&format!("import.recent.{i}"));+    assert_eq!(d.app().ui.mode, Mode::Import);+    d.click("openProject.save");+    assert!(d.app().ui.clip_dialog.is_none());+    assert_eq!(d.path(), Some(other));+    assert_eq!(d.app().ui.mode, Mode::Edit);+    let _ = std::fs::remove_dir_all(&dir);+}++#[test]+fn open_project_save_on_a_never_saved_project_uses_the_save_dialog() {+    let mut d = Driver::new();+    let (dir, _, other) = projects(&mut d, "neverSaved");+    d.app().session.path = None;+    let clip = d.edit();+    let saved = dir.join("saved.fcproj").to_string_lossy().into_owned();+    let mut answers = vec![None, Some(saved.clone())];+    d.app().hooks.pick_save = Some(Box::new(move |_| if answers.is_empty() { None } else { answers.remove(0) }));+    d.open_project(&other);+    // the Save dialog is canceled: back to the prompt, nothing opens+    d.click("openProject.save");+    assert!(d.app().ui.clip_dialog.is_some());+    assert_eq!(d.path(), None);+    assert!(d.app().session.is_dirty());+    // saved to the chosen file, then the chosen project opens+    d.click("openProject.save");+    assert!(d.app().ui.clip_dialog.is_none());+    assert_eq!(d.path(), Some(other));+    d.exec("file.open", json!({"path": saved}));+    assert_eq!(d.label(clip), Label::Yellow);+    let _ = std::fs::remove_dir_all(&dir);+}++#[test]+fn open_project_asks_when_the_open_dialog_answers_in_a_later_frame() {+    let mut d = Driver::new();+    let (dir, current, other) = projects(&mut d, "runner");+    d.edit();+    // a host that shows file dialogs without blocking (the desktop app on Linux)+    let answer: Arc<Mutex<Option<Sender<Vec<String>>>>> = Arc::default();+    let a = answer.clone();+    d.app().hooks.run_dialog = Some(Box::new(move |_| {+        let (tx, rx) = channel();+        *a.lock().unwrap() = Some(tx);+        rx+    }));+    assert_eq!(d.menu("file.open"), json!({"dialog": "open"}));+    answer.lock().unwrap().take().unwrap().send(vec![other.clone()]).unwrap();+    d.frames(3);+    assert!(d.has("openProject.dontSave"));+    assert_eq!(d.path(), Some(current));+    d.click("openProject.dontSave");+    assert_eq!(d.path(), Some(other));+    let _ = std::fs::remove_dir_all(&dir);+}++#[test]+fn open_project_save_waits_for_a_save_dialog_answered_later() {+    let mut d = Driver::new();+    let (dir, _, other) = projects(&mut d, "saveLater");+    d.app().session.path = None;+    let clip = d.edit();+    let saved = dir.join("saved.fcproj").to_string_lossy().into_owned();+    // a host that shows file dialogs without blocking (the desktop app on Linux), answered by hand+    let asked: Arc<Mutex<Vec<(FileDialog, Sender<Vec<String>>)>>> = Arc::default();+    let a = asked.clone();+    d.app().hooks.run_dialog = Some(Box::new(move |dialog| {+        let (tx, rx) = channel();+        a.lock().unwrap().push((dialog, tx));+        rx+    }));+    let answer = |d: &mut Driver, paths: Vec<String>| {+        let (dialog, tx) = asked.lock().unwrap().remove(0);+        tx.send(paths).unwrap();+        d.frames(3);+        dialog+    };+    d.menu("file.open");+    assert!(matches!(answer(&mut d, vec![other.clone()]), FileDialog::OpenProject));+    // the Save dialog is canceled: back to the prompt, nothing opens+    d.click("openProject.save");+    assert!(matches!(answer(&mut d, Vec::new()), FileDialog::SaveProject { .. }));+    assert!(d.has("openProject.save"));+    assert_eq!(d.path(), None);+    // the save fails: the prompt stays, with the reason in the status line+    let blocker = dir.join("not-a-folder");+    std::fs::write(&blocker, b"").unwrap();+    d.click("openProject.save");+    answer(&mut d, vec![blocker.join("saved.fcproj").to_string_lossy().into_owned()]);+    assert!(d.has("openProject.save"));+    assert!(!d.app().ui.status.is_empty());+    assert_eq!(d.path(), None);+    assert!(d.app().session.is_dirty());+    // the file is picked a few frames later: saved, then the chosen project opens+    d.click("openProject.save");+    d.frames(3);+    assert_eq!(d.path(), None, "nothing opens before the answer");+    answer(&mut d, vec![saved.clone()]);+    assert!(d.app().ui.clip_dialog.is_none());+    assert_eq!(d.path(), Some(other.clone()));+    assert!(!d.app().session.is_dirty());+    d.exec("file.open", json!({"path": saved}));+    assert_eq!(d.label(clip), Label::Yellow, "the change was saved");+    // another project opened while the Save dialog is open: the late answer saves and opens nothing+    d.app().session.path = None;+    d.exec("file.newBin", json!({"name": "Late"}));+    assert!(d.app().session.is_dirty());+    let late = dir.join("late.fcproj").to_string_lossy().into_owned();+    d.menu("file.open");+    answer(&mut d, vec![other.clone()]);+    d.click("openProject.save");+    d.exec("file.open", json!({"path": saved}));+    answer(&mut d, vec![late.clone()]);+    assert!(!std::path::Path::new(&late).exists());+    assert_eq!(d.path(), Some(saved));+    assert!(d.app().ui.clip_dialog.is_none());+    // the same for a new project changed while the Save dialog is open+    d.app().session.path = None;+    d.exec("file.newBin", json!({"name": "Late"}));+    d.menu("file.open");+    answer(&mut d, vec![other]);+    d.click("openProject.save");+    d.exec("file.newProject", json!({"name": "New"}));+    d.exec("file.newBin", json!({"name": "Changed"}));+    answer(&mut d, vec![late.clone()]);+    assert!(!std::path::Path::new(&late).exists());+    assert_eq!(d.path(), None);+    assert!(d.app().session.is_dirty());+    assert!(d.app().ui.clip_dialog.is_none());+    let _ = std::fs::remove_dir_all(&dir);+}++#[test]+fn a_save_prompt_closes_when_a_script_replaces_its_project() {+    let mut d = Driver::new();+    let (dir, current, other) = projects(&mut d, "replaced");+    // Open Project+    d.edit();+    d.open_project(&other);+    d.exec("file.open", json!({"path": other}));+    d.exec("file.newBin", json!({"name": "Changed"}));+    assert!(d.app().ui.clip_dialog.is_none());+    assert!(!d.has("openProject.dontSave"));+    assert_eq!(d.path(), Some(other.clone()));+    assert!(d.app().session.is_dirty(), "the other project's change is kept");+    // Close Project+    d.menu("file.closeProject");+    assert!(d.has("closeProject.dontSave"));+    d.exec("file.open", json!({"path": current}));+    d.exec("file.newBin", json!({"name": "Changed"}));+    assert!(d.app().ui.clip_dialog.is_none());+    assert_eq!(d.path(), Some(current));+    assert!(d.app().session.is_dirty());+    // Quit+    d.harness.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);+    d.frames(3);+    assert!(d.has("quit.dontSave"));+    d.exec("file.newProject", json!({"name": "New"}));+    d.exec("file.newBin", json!({"name": "Changed"}));+    assert!(d.app().ui.clip_dialog.is_none());+    assert!(!d.app().quit_confirmed);+    assert!(d.app().session.is_dirty());+    let _ = std::fs::remove_dir_all(&dir);+}diff --git a/docs/project-files.md b/docs/project-files.md--- a/docs/project-files.md+++ b/docs/project-files.md@@ -109,6 +109,10 @@ one. If a step fails, the temp file is removed and the old file is untouched. | `file.open {path}` | File ▸ Open Project… (⌘O) | returns `{schemaVersion, migrated}` |  In the UI, Save / Save As / Save a Copy without a `path` show a file dialog.+Open Project… shows one too. In the desktop app, when the current project has unsaved changes, it+then asks *"Save changes to 'X' before closing?"* with **Cancel**, **Don't Save** and **Save**, and+the chosen project opens after Save or Don't Save. File ▸ Open Recent and the Recent list on Home+ask the same way. `file.open` with a `path` opens the project without asking.  ## Auto Save