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
@@ -88,3 +88,7 @@ sync_github_name = false [people.webdevtodayjason] display_name = "Jason Brashear"++[people.dotCooCoo]+real_name = "Robert Lee"+sync_github_name = true@@ -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);@@ -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" => {@@ -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();@@ -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();@@ -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; } }@@ -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")?,@@ -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); d@@ -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).new file mode 100644@@ -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(¤t)["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);+}@@ -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