refactor(app): retire manual-save semantics (M13 D5)

- AppEngine: save_project -> export_project_path(explicit path);
  project_modified removed (write-through has no dirty flag)
- File menu's save/save-as are the export path (extension-dispatched
  ove/otio/fcpxml); the library row export path is unchanged
- facade project_save/is_modified stay frozen, documented as the
  legacy export surface
- docs: M13 D5 checked off; M12 inventory updated
This commit is contained in:
2026-08-16 14:17:45 +08:00
parent 025dc88c25
commit 0f82092e95
11 changed files with 72 additions and 88 deletions
+11 -22
View File
@@ -127,9 +127,9 @@ mod modal_ids {
enum FileAction {
ImportFootage,
Open,
/// Export the current project to a file (`.ove` / `.otio` / `.fcpxml`,
/// dispatched by extension).
SaveAs,
/// Export the current project to a file (the 导出工程文件… action's
/// target; `.ove` / `.otio` / `.fcpxml`, dispatched by extension).
ExportProjectFile,
/// Import a project file into the library (the manager's 导入).
ImportProject,
/// Export the selected library project (the manager's 导出; the row's
@@ -525,7 +525,7 @@ impl<E: AppEngine> OakApp<E> {
OPEN_PROJECT => self.open_file_dialog(FileAction::Open, cx),
OPEN_FROM_LIBRARY | PROJECT_MANAGER => self.show_project_manager(cx),
IMPORT_FOOTAGE => self.open_file_dialog(FileAction::ImportFootage, cx),
EXPORT_PROJECT => self.open_file_dialog(FileAction::SaveAs, cx),
EXPORT_PROJECT => self.open_file_dialog(FileAction::ExportProjectFile, cx),
CLOSE => self
.engine
.update(cx, |engine, cx| engine.close_project(cx)),
@@ -610,17 +610,6 @@ impl<E: AppEngine> OakApp<E> {
}
}
/// Exports the project to a file (the 导出工程文件… action's target; the
/// format is dispatched by the picked path's extension).
fn save_project(&mut self, path: Option<PathBuf>, cx: &mut Context<Self>) {
let result = self
.engine
.update(cx, |engine, cx| engine.save_project(path, cx));
if let Err(err) = result {
println!("[file] save failed: {err}");
}
}
/// 新建项目: creates a blank project in the library and opens it (the
/// write-through persists it from the first edit). Falls back to the
/// engine's plain new-project path when the library is unavailable.
@@ -976,9 +965,9 @@ impl<E: AppEngine> OakApp<E> {
/// Opens the platform file dialog for `action` and routes the picked
/// path(s) through the engine. Open / Import use the path picker (import
/// footage allows multiple files); Save As and the manager's export ask
/// for a new path. The picker resolves asynchronously, so the chosen
/// path is applied in a spawned task via [`Self::on_file_paths`].
/// footage allows multiple files); the 导出工程文件… and the manager's
/// export ask for a new path. The picker resolves asynchronously, so the
/// chosen path is applied in a spawned task via [`Self::on_file_paths`].
fn open_file_dialog(&mut self, action: FileAction, cx: &mut Context<Self>) {
match action {
FileAction::Open | FileAction::ImportFootage | FileAction::ImportProject => {
@@ -1002,7 +991,7 @@ impl<E: AppEngine> OakApp<E> {
})
.detach();
}
FileAction::SaveAs => {
FileAction::ExportProjectFile => {
let current = self
.engine
.read(cx)
@@ -1024,7 +1013,7 @@ impl<E: AppEngine> OakApp<E> {
cx.spawn(async move |this, cx| {
if let Ok(Ok(Some(path))) = receiver.await {
this.update(cx, |this, cx| {
this.on_file_paths(FileAction::SaveAs, vec![path], cx);
this.on_file_paths(FileAction::ExportProjectFile, vec![path], cx);
});
}
})
@@ -1078,8 +1067,8 @@ impl<E: AppEngine> OakApp<E> {
Some(path) => engine.open_project_path(path.clone(), cx),
None => Ok(()),
},
FileAction::SaveAs => match paths.first() {
Some(path) => engine.save_project(Some(path.clone()), cx),
FileAction::ExportProjectFile => match paths.first() {
Some(path) => engine.export_project_path(path.clone(), cx),
None => Ok(()),
},
FileAction::ImportFootage => {
-2
View File
@@ -323,7 +323,6 @@ const EN: &[(&str, &str)] = &[
("dialog.close", "Close"),
("file.open.title", "Open Project"),
("file.import_footage.title", "Import Footage"),
("file.save_as.title", "Save Project As"),
("preferences.title", "Preferences"),
("preferences.backend", "Renderer backend"),
("preferences.backend.placeholder", "Select a backend…"),
@@ -499,7 +498,6 @@ const ZH: &[(&str, &str)] = &[
("dialog.close", "关闭"),
("file.open.title", "打开项目"),
("file.import_footage.title", "导入素材"),
("file.save_as.title", "项目另存为"),
("preferences.title", "偏好设置"),
("preferences.backend", "渲染后端"),
("preferences.backend.placeholder", "选择一个后端…"),
+6 -7
View File
@@ -298,9 +298,6 @@ pub trait AppEngine:
/// Steps the undo stack forward one entry.
fn redo(&mut self, cx: &mut Context<Self>);
/// Whether the project has unsaved changes.
fn project_modified(&self) -> bool;
/// Starts a new blank project with a single default sequence.
fn new_project(&mut self, cx: &mut Context<Self>);
@@ -309,10 +306,12 @@ pub trait AppEngine:
/// interchange loader.
fn open_project_path(&mut self, path: PathBuf, cx: &mut Context<Self>) -> Result<(), String>;
/// Saves the project to `path` (or its own filename when `None`). The
/// format is dispatched by extension like [`open_project_path`]
/// (AppEngine::open_project_path).
fn save_project(&mut self, path: Option<PathBuf>, cx: &mut Context<Self>)
/// Exports the current project to the file `path` (the 导出工程文件…
/// action's target). The format is dispatched by extension like
/// [`open_project_path`] (AppEngine::open_project_path). Exporting is a
/// pure file write — the write-through library already persists every
/// edit, so there is no "save" anymore.
fn export_project_path(&mut self, path: PathBuf, cx: &mut Context<Self>)
-> Result<(), String>;
/// Closes the current project, leaving the app with no sequence.
+4 -4
View File
@@ -358,11 +358,11 @@ unsafe extern "C" {
err: *mut c_char,
err_size: c_int,
) -> c_int;
/// `oakengine_project_save` — save to `path` (or the recorded
/// filename when NULL).
/// `oakengine_project_save` — write the project to `path` (or the
/// recorded filename when NULL). Legacy manual-save ABI, now used only
/// as the .ove export path (导出工程文件…); the write-through library is
/// the primary persistence.
pub fn oakengine_project_save(self_: *mut OakEngineProject, path: *const c_char) -> c_int;
/// `oakengine_project_is_modified`.
pub fn oakengine_project_is_modified(self_: *const OakEngineProject) -> c_int;
/// `oakengine_project_name` (buf/size).
pub fn oakengine_project_name(
self_: *const OakEngineProject,
+2 -10
View File
@@ -1302,10 +1302,6 @@ impl AppEngine for MockEngine {
cx.notify();
}
fn project_modified(&self) -> bool {
false
}
fn new_project(&mut self, cx: &mut Context<Self>) {
println!("[mock engine] new project: demo data stays (mock mode)");
cx.notify();
@@ -1322,12 +1318,8 @@ impl AppEngine for MockEngine {
Ok(())
}
fn save_project(
&mut self,
_path: Option<PathBuf>,
cx: &mut Context<Self>,
) -> Result<(), String> {
println!("[mock engine] save: no persistence in mock mode");
fn export_project_path(&mut self, _path: PathBuf, cx: &mut Context<Self>) -> Result<(), String> {
println!("[mock engine] export: no persistence in mock mode");
cx.notify();
Ok(())
}
+20 -38
View File
@@ -580,8 +580,6 @@ pub struct RealEngine {
/// the selection changes or the project is dropped — the renderer binds
/// the footage node, so a new selection must bind the new node.
source_renderer: Mutex<RendererSlot>,
/// Whether the project has unsaved changes (mirrors the facade flag).
modified: bool,
}
impl RealEngine {
@@ -656,7 +654,6 @@ impl RealEngine {
cpu_frame_cache: Mutex::new(HashMap::new()),
renderer: Mutex::new(RendererSlot::Untried),
source_renderer: Mutex::new(RendererSlot::Untried),
modified: false,
}
}
@@ -987,7 +984,6 @@ impl RealEngine {
},
path,
};
self.modified = unsafe { oakengine_project_is_modified(project) != 0 };
// The sequence: the project's first, or a blank default.
let count = unsafe { oakengine_project_sequence_count(project) };
@@ -1019,7 +1015,6 @@ impl RealEngine {
self.tracks.clear();
self.sequence_info = None;
self.modified = false;
self.project_info = Project {
name: UNTITLED.into(),
path: PathBuf::new(),
@@ -1398,7 +1393,6 @@ impl RealEngine {
self.rebuild_timeline();
// The sequence content changed: cached rendered frames are stale.
self.cpu_frame_cache.lock().unwrap().clear();
self.modified = true;
cx.notify();
}
@@ -2154,7 +2148,6 @@ impl AppEngine for RealEngine {
self.refresh_sequence_info();
self.rebuild_timeline();
self.cpu_frame_cache.lock().unwrap().clear();
self.modified = true;
cx.notify();
}
}
@@ -2167,15 +2160,10 @@ impl AppEngine for RealEngine {
self.refresh_sequence_info();
self.rebuild_timeline();
self.cpu_frame_cache.lock().unwrap().clear();
self.modified = true;
cx.notify();
}
}
fn project_modified(&self) -> bool {
self.modified
}
fn new_project(&mut self, cx: &mut Context<Self>) {
let project = unsafe { oakengine_project_create() };
if project.is_null() {
@@ -2201,28 +2189,22 @@ impl AppEngine for RealEngine {
}
}
fn save_project(
fn export_project_path(
&mut self,
path: Option<PathBuf>,
path: PathBuf,
cx: &mut Context<Self>,
) -> Result<(), String> {
if self.project_ptr().is_none() {
return Err("no project open".into());
}
let ext = path
.as_ref()
.and_then(|p| p.extension())
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase());
let result = match ext.as_deref() {
Some("otio") | Some("fcpxml") => self.save_interchange(&path.unwrap(), cx),
_ => self.save_ove(path.as_deref(), cx),
};
if result.is_ok() {
self.modified = false;
cx.notify();
match ext.as_deref() {
Some("otio") | Some("fcpxml") => self.export_interchange(&path, cx),
_ => self.export_ove(&path, cx),
}
result
}
fn close_project(&mut self, cx: &mut Context<Self>) {
@@ -2586,22 +2568,22 @@ impl RealEngine {
}
}
/// Saves to the project's own filename (or `path`) through the OVE
/// serializer.
fn save_ove(&mut self, path: Option<&Path>, _cx: &mut Context<Self>) -> Result<(), String> {
/// Exports the project to `path` through the OVE serializer (the .ove
/// branch of 导出工程文件…; the write-through library stays the primary
/// persistence, this only writes a file).
fn export_ove(&mut self, path: &Path, _cx: &mut Context<Self>) -> Result<(), String> {
let Some(project) = self.project_ptr() else {
return Err("no project open".into());
};
let cpath = path.and_then(cstr_path);
let ptr = cpath
.as_ref()
.map(|c| c.as_ptr())
.unwrap_or(std::ptr::null());
let rc = unsafe { oakengine_project_save(project, ptr) };
let Some(cpath) = cstr_path(path) else {
return Err("invalid project path".into());
};
let rc = unsafe { oakengine_project_save(project, cpath.as_ptr()) };
if rc != 0 {
return Err(format!("failed to save the project (error {rc})"));
return Err(format!("failed to export the project (error {rc})"));
}
// The facade recorded the target filename; refresh the display name.
// The facade records the target filename (legacy save side effect);
// refresh the display name to match.
let name = read_string(|buf, size| unsafe { oakengine_project_name(project, buf, size) });
if !name.is_empty() {
self.project_info.name = name;
@@ -2614,9 +2596,9 @@ impl RealEngine {
Ok(())
}
/// Saves as `.otio` / `.fcpxml` through the oaktask save task (the
/// Exports as `.otio` / `.fcpxml` through the oaktask save task (the
/// facade derives the output filename from the project's own filename).
fn save_interchange(&mut self, path: &PathBuf, _cx: &mut Context<Self>) -> Result<(), String> {
fn export_interchange(&mut self, path: &PathBuf, _cx: &mut Context<Self>) -> Result<(), String> {
let Some(project) = self.project_ptr() else {
return Err("no project open".into());
};
@@ -2635,7 +2617,7 @@ impl RealEngine {
let error = Self::task_error(task);
unsafe { oakengine_task_free(task) };
if rc == 0 {
return Err(format!("failed to save \"{}\": {error}", path.display()));
return Err(format!("failed to export \"{}\": {error}", path.display()));
}
self.project_info.path = path.clone();
Ok(())