mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-07 12:12:37 +00:00
Projects: Open Project asks to save unsaved changes first (#1665)
* Projects: Open Project asks to save unsaved changes first File ▸ Open Project opened the chosen project at once, so a project with unsaved changes was replaced without a prompt. Close Project and Quit (#758) already ask first. In the desktop app, once the project to open is chosen, the same Save Project prompt now appears when the open project has unsaved changes, with the automation ids `openProject.save`, `openProject.dontSave` and `openProject.cancel`. Save saves the project and then opens the chosen one; a never-saved project goes through the Save dialog, and canceling it returns to the prompt. On Linux, where the Save dialog does not block, the prompt stays open after a never-saved project is saved, as it does for Close Project and Quit, and a second Save or Don't Save opens the chosen project. Don't Save opens the chosen project without saving, and Cancel keeps the open project. When the chosen project can't be opened, the open project stays open with its changes and the error goes to the status line. `file.open` with a `path` opens without asking, as before. The prompt reuses existing strings. * Contributors: add dotCooCoo (Robert Lee) * Projects: Open Recent and Home's Recent list ask to save unsaved changes first File > Open Recent and a click in Home's Recent list sent `file.open` with a path, so a project with unsaved changes was replaced without a prompt. Both now call `intercept_open` and show the Save Project prompt first, as Open Project does. After Save or Don't Save the chosen project opens; a Recent row on Home then switches to Edit, as it does without the prompt. `file.open` with a path from the control channel or MCP still opens without asking. * Projects: Open Project opens the chosen project after a later Save dialog answer On Linux the Save dialog answers in a later frame. Save in the Open Project prompt on a never-saved project checked whether the project was saved right after showing the dialog, found it unsaved and showed the prompt again; the answer then saved the project but did not open the chosen one. Saving and then opening the chosen project now run when the dialog answers. A canceled dialog or a failed save shows the prompt again, with the reason in the status line. An answer that arrives after another project was opened, created or closed to does nothing: `Session::project_generation` counts the projects a session has held, as in #2437. The prompt offers Save whenever a Save dialog can be shown, also through `HostHooks::run_dialog`. * Projects: a save prompt closes when a script replaces its project The Close Project, Open Project and Quit prompts stayed open when a script opened, created or closed to another project while they were shown, and Save or Don't Save then acted on the new project: Don't Save discarded its changes and Save saved it. Each prompt now records the project it was asked over (`Session::project_generation`) and closes when that project has been replaced. * Projects: a Recent row on Home switches to Edit only once its project opens With unsaved changes, a click in Home's Recent list switched to Edit as soon as the Save Project prompt appeared, so Cancel left Edit showing the current project instead of Home. Home now stays while the prompt is shown and after Cancel, and switches to Edit after Save or Don't Save opens the chosen project, also when Save first asks where to save. * Open Project: a failed save keeps the prompt and the chosen project When the open project already has a file and saving it fails, the Open Project prompt now stays open with the chosen project, as it does for a project that was never saved. The error shows in the status bar. * Open Project: only its prompt offers Save through a Save dialog that answers later Close Project and Quit offer Save for a never-saved project only where the Save dialog answers at once, as on main. After a Save dialog that answers later, only Open Project goes on to its next step. * Projects: a failed save keeps the Close Project and Quit prompts When the open project already has a file and saving it fails, the Close Project and Quit prompts now stay open, as the Open Project prompt does. The error shows in the status bar. * Projects: the Open Project prompt blocks panels in floating windows While the Open Project prompt is shown, panels in floating windows no longer take input, as for the Close Project and Quit prompts.
This commit is contained in:
@@ -111,3 +111,7 @@ real_name = "Alexander Alber"
|
||||
sync_github_name = true
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
@@ -218,12 +218,12 @@ impl FilmcraftApp {
|
||||
}
|
||||
|
||||
/// Whether a modal dialog is open: Settings, Recovery or Revert (egui `Modal`s), any other
|
||||
/// egui `Modal` shown last frame, the Save changes? prompt of Quit or Close Project, or a
|
||||
/// native file dialog. egui's `Modal` blocks input only in the main window, so the dock area
|
||||
/// blocks its native windows meanwhile.
|
||||
/// egui `Modal` shown last frame, the Save Project prompt of Quit, Close Project or Open
|
||||
/// Project, or a native file dialog. egui's `Modal` blocks input only in the main window, so
|
||||
/// the dock area blocks its native windows meanwhile.
|
||||
fn modal_open(&self, ctx: &egui::Context) -> bool {
|
||||
matches!(self.dialog, Some(crate::Dialog::Preferences | crate::Dialog::Recovery | crate::Dialog::RevertConfirm))
|
||||
|| self.ui.clip_dialog.as_ref().is_some_and(|dialog| matches!(dialog.command.as_str(), "app.quit" | "file.closeProject"))
|
||||
|| self.ui.clip_dialog.as_ref().is_some_and(|dialog| matches!(dialog.command.as_str(), "app.quit" | "file.closeProject" | "file.open"))
|
||||
|| self.native_dialog_open()
|
||||
|| ctx.memory(|memory| memory.top_modal_layer().is_some())
|
||||
}
|
||||
|
||||
@@ -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), Rename…, and the Close Project and
|
||||
//! Quit "save changes?" prompts.
|
||||
//! Field Options, Clip Speed / Duration, Nest… (Nested Sequence Name), Rename…, 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
|
||||
@@ -29,6 +29,8 @@
|
||||
//! - Clip Speed / Duration `speedDuration.*`: `speed`, `duration`, `reverse`, `maintainPitch`, `ripple`,
|
||||
//! `interpolation.<frameSampling|frameBlending|opticalFlow>`;
|
||||
//! - Close Project `closeProject.save`, `closeProject.dontSave`, `closeProject.cancel`;
|
||||
//! - Open Project (asked once the project to open is chosen, see [`ask_before_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`.
|
||||
|
||||
@@ -155,7 +157,8 @@ pub fn ask_before_open(app: &mut FilmcraftApp, path: &str) -> Option<Result<Valu
|
||||
if !app.session.is_dirty() {
|
||||
return None;
|
||||
}
|
||||
app.ui.clip_dialog = Some(ClipDialogDraft { command: "file.open".into(), params: json!({"path": path}), info: Value::Null, error: String::new() });
|
||||
app.ui.clip_dialog =
|
||||
Some(ClipDialogDraft { command: "file.open".into(), params: json!({"path": path}), info: asked_over(&app.session), error: String::new() });
|
||||
Some(Ok(json!({"dialog": "openProject"})))
|
||||
}
|
||||
|
||||
@@ -167,7 +170,7 @@ 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
|
||||
}
|
||||
@@ -286,10 +289,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, "maintainPitch": maintain_pitch}),
|
||||
)
|
||||
}
|
||||
"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",
|
||||
@@ -368,6 +378,12 @@ 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();
|
||||
@@ -726,9 +742,10 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
Some("save") => {
|
||||
if app.session.path.is_some() {
|
||||
// the save failed: the prompt stays, with the reason in the status bar
|
||||
if let Err(e) = app.session.execute("file.save", json!({})) {
|
||||
app.ui.status = e.to_string();
|
||||
app.ui.clip_dialog = None;
|
||||
app.ui.clip_dialog = Some(d);
|
||||
return;
|
||||
}
|
||||
leave(app, ctx, &d);
|
||||
@@ -800,9 +817,19 @@ fn leave(app: &mut FilmcraftApp, ctx: &egui::Context, prompt: &ClipDialogDraft)
|
||||
}
|
||||
if command == "file.open"
|
||||
&& let Some(path) = prompt.params["path"].as_str()
|
||||
&& let Err(e) = app.session.execute("file.open", json!({"path": path}))
|
||||
{
|
||||
app.ui.status = e.to_string();
|
||||
match app.session.execute("file.open", json!({"path": path})) {
|
||||
Ok(_) => edit_after_open(app, prompt),
|
||||
Err(e) => app.ui.status = e.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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::ask_before_open(app, &path).is_some() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ mod native_windows_ui;
|
||||
mod nest_editing_ui;
|
||||
mod nest_rendering_ui;
|
||||
mod nested_ui;
|
||||
mod open_project_prompt_ui;
|
||||
mod panels_ui;
|
||||
mod par_ui;
|
||||
mod perf_ui;
|
||||
|
||||
@@ -238,6 +238,24 @@ fn the_quit_prompt_blocks_native_windows() {
|
||||
assert_eq!(d.element("panel.menu.Timeline")["enabled"], json!(false), "the native window is blocked");
|
||||
}
|
||||
|
||||
/// The Save Project prompt of Open Project blocks the panels in native windows, as the Quit
|
||||
/// prompt does.
|
||||
#[test]
|
||||
fn the_open_project_prompt_blocks_native_windows() {
|
||||
let mut d = Driver::new(5);
|
||||
d.float("Timeline", [900.0, 120.0, 680.0, 420.0]);
|
||||
assert_eq!(d.element("panel.menu.Timeline")["enabled"], json!(true));
|
||||
d.ok("engine.execute", json!({"command": "sequence.addTracks", "params": {}}));
|
||||
assert!(d.app().session.is_dirty());
|
||||
let chosen = std::env::temp_dir().join(format!("fc-native-open-project-prompt-{}.fcproj", std::process::id()));
|
||||
let chosen = chosen.to_string_lossy().into_owned();
|
||||
d.harness.state_mut().hooks.pick_open_project = Some(Box::new(move || Some(chosen.clone())));
|
||||
d.ok("ui.menu.invoke", json!({"id": "file.open"}));
|
||||
d.frames(3);
|
||||
assert!(d.elements("openProject.").iter().any(|e| e["id"] == "openProject.cancel"), "Open Project asks first");
|
||||
assert_eq!(d.element("panel.menu.Timeline")["enabled"], json!(false), "the native window is blocked");
|
||||
}
|
||||
|
||||
/// A Source Monitor drag in the main window reaches the Timeline in its own OS window, and drops
|
||||
/// the clip there.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
//! 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_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_failed_save_keeps_the_prompt_and_chosen_project() {
|
||||
let mut d = Driver::new();
|
||||
let (dir, _, other) = projects(&mut d, "failedSave");
|
||||
let folder = dir.join("current-folder");
|
||||
std::fs::create_dir(&folder).unwrap();
|
||||
let current = folder.join("current.fcproj").to_string_lossy().into_owned();
|
||||
d.exec("file.saveAs", json!({"path": current}));
|
||||
let clip = d.edit();
|
||||
std::fs::remove_dir_all(&folder).unwrap();
|
||||
std::fs::write(&folder, b"not a folder").unwrap();
|
||||
|
||||
d.open_project(&other);
|
||||
d.click("openProject.save");
|
||||
assert!(d.has("openProject.save"), "the prompt stays open after a failed save");
|
||||
assert_eq!(d.app().ui.clip_dialog.as_ref().unwrap().params["path"], json!(other));
|
||||
assert_eq!(d.path(), Some(current.clone()));
|
||||
assert!(d.app().session.is_dirty());
|
||||
assert_eq!(d.label(clip), Label::Yellow);
|
||||
assert!(d.app().ui.status.contains(¤t), "the status shows the save error: {}", d.app().ui.status);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_project_and_quit_failed_save_keep_the_prompt() {
|
||||
for (command, prompt) in [("file.closeProject", "closeProject"), ("app.quit", "quit")] {
|
||||
let mut d = Driver::new();
|
||||
let (dir, _, _) = projects(&mut d, prompt);
|
||||
let folder = dir.join("current-folder");
|
||||
std::fs::create_dir(&folder).unwrap();
|
||||
let current = folder.join("current.fcproj").to_string_lossy().into_owned();
|
||||
d.exec("file.saveAs", json!({"path": current}));
|
||||
let clip = d.edit();
|
||||
std::fs::remove_dir_all(&folder).unwrap();
|
||||
std::fs::write(&folder, b"not a folder").unwrap();
|
||||
|
||||
if command == "app.quit" {
|
||||
d.harness.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
|
||||
d.frames(3);
|
||||
} else {
|
||||
d.menu(command);
|
||||
}
|
||||
let save = format!("{prompt}.save");
|
||||
assert!(d.has(&save), "{command} shows its save prompt");
|
||||
d.click(&save);
|
||||
assert!(d.has(&save), "{command} keeps its prompt after a failed save");
|
||||
assert_eq!(d.path(), Some(current.clone()));
|
||||
assert!(d.app().session.is_dirty());
|
||||
assert_eq!(d.label(clip), Label::Yellow, "{command} keeps the change");
|
||||
assert!(d.app().ui.status.contains(¤t), "{command} shows the save error: {}", d.app().ui.status);
|
||||
assert!(!d.app().quit_confirmed, "{command} does not confirm a quit");
|
||||
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_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");
|
||||
// a new project changed while the Save dialog is open: the late answer saves and opens nothing
|
||||
let late = dir.join("late.fcproj").to_string_lossy().into_owned();
|
||||
d.app().session.path = None;
|
||||
d.exec("file.newBin", json!({"name": "Late"}));
|
||||
assert!(d.app().session.is_dirty());
|
||||
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, _, other) = projects(&mut d, "replaced");
|
||||
// Open Project
|
||||
d.edit();
|
||||
d.open_project(&other);
|
||||
assert!(d.has("openProject.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.has("openProject.dontSave"));
|
||||
assert_eq!(d.path(), None);
|
||||
assert!(d.app().session.is_dirty(), "the new project's change is kept");
|
||||
// Close Project
|
||||
d.menu("file.closeProject");
|
||||
assert!(d.has("closeProject.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().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);
|
||||
}
|
||||
@@ -117,6 +117,10 @@ to avoid that ([agents.md](agents.md)); the desktop app and `mcp` sessions don't
|
||||
| `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.
|
||||
|
||||
## Auto Save
|
||||
|
||||
|
||||
Reference in New Issue
Block a user