From 56af662b832f90a26f596d313808d6f084b047b8 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Wed, 2 Sep 2026 09:22:29 +0800 Subject: [PATCH] =?UTF-8?q?project=20file:=20=E5=8F=A6=E5=AD=98=E4=B8=BA?= =?UTF-8?q?=20=E2=80=94=20duplicate=20the=20project=20in=20the=20library?= =?UTF-8?q?=20under=20a=20new=20name=20and=20switch=20to=20the=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - save_project_as(name): library_duplicate the open project's row, rename the copy, open it as the current project - 文件 → 另存为 opens a name dialog (prefilled ' Copy'); OK duplicates + switches (the save-as semantics: a new, differently- named project in the library, editing continues on the copy) --- crates/oak-app/src/app.rs | 79 +++++++++++++++++++++++++++++- crates/oak-app/src/oakui/engine.rs | 8 +++ crates/oak-app/src/oakui/real.rs | 20 ++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) diff --git a/crates/oak-app/src/app.rs b/crates/oak-app/src/app.rs index fdc2096ea..1e0570d9a 100644 --- a/crates/oak-app/src/app.rs +++ b/crates/oak-app/src/app.rs @@ -144,6 +144,8 @@ mod modal_ids { pub const EXPORT_PROJECT_DFD: usize = 19; /// The new-project dialog (文件 → 新建项目). pub const NEW_PROJECT: usize = 20; + /// The save-as dialog (文件 → 另存为). + pub const SAVE_AS: usize = 21; } /// What a picked platform-dialog path should do. @@ -254,6 +256,11 @@ enum ModalState { modal: Entity, content: Entity, }, + /// The save-as dialog (文件 → 另存为): the copy's name. + SaveAs { + modal: Entity, + content: Entity, + }, } /// A running export: the session the tick loop drains for progress. @@ -283,6 +290,7 @@ impl ModalState { | ModalState::EntryRename { modal, .. } => Some(modal.clone()), | ModalState::ExportProjectDfd { modal, .. } => Some(modal.clone()), | ModalState::NewProject { modal, .. } => Some(modal.clone()), + | ModalState::SaveAs { modal, .. } => Some(modal.clone()), } } } @@ -1032,7 +1040,8 @@ impl OakApp { A::OpenFromLibrary | A::ProjectManager => self.show_project_manager(cx), A::Import => self.open_file_dialog(FileAction::ImportFootage, cx), // The 导出工程文件… dialog: choose OTIO / OVE / FCPXML + path. - A::SaveProject | A::SaveProjectAs => self.open_export_project_dfd(cx), + A::SaveProject => self.open_export_project_dfd(cx), + A::SaveProjectAs => self.open_save_as(cx), A::CloseProject => self .engine .update(cx, |engine, cx| engine.close_project(cx)), @@ -2654,6 +2663,64 @@ impl OakApp { }); } + /// Opens the 另存为 dialog: the copy's new name (prefilled with the + /// current project name). OK duplicates the project into the library + /// under that name and switches to the copy. + fn open_save_as(&mut self, cx: &mut Context) { + if !matches!(self.modal, ModalState::None) { + return; + } + let current_name = self + .engine + .read(cx) + .project() + .map(|p| p.name.clone()) + .unwrap_or_else(|| crate::i18n::tr("manager.new.default_name").to_string()); + let mut name = current_name.clone(); + if let Some((stem, _)) = name.rsplit_once('.') { + name = stem.to_string(); + } + name = format!("{name} Copy"); + self.spawn_modal(cx, move |window, app| { + let content = app.new(|cx| { + crate::dialogs::RenameContent::new(name.clone().into(), window, cx) + }); + let modal = app.new(|cx| { + Modal::new( + modal_ids::SAVE_AS, + ModalOptions::new( + crate::i18n::tr("menu.file.save_as"), + px(420.0), + ) + .with_button(DialogButton::primary(crate::i18n::tr("dialog.ok"))) + .with_button(DialogButton::new( + crate::i18n::tr("dialog.cancel"), + gpui_widgets::dialog::DialogButtonRole::Secondary, + )), + window, + cx, + ) + .with_content(content.clone()) + }); + ModalState::SaveAs { modal, content } + }); + } + + /// Confirms the save-as dialog: duplicate + switch to the copy. + fn commit_save_as( + &mut self, + content: &Entity, + cx: &mut Context, + ) { + let name = content.read(cx).value(cx).to_string(); + let engine = self.engine.clone(); + let result = engine.update(cx, |engine, cx| engine.save_project_as(&name, cx)); + if let Err(err) = result { + println!("[file] save as failed: {err}"); + } + self.close_modal(cx); + } + /// Opens the multicam wizard (窗口 → 多机位向导): pick the angle /// footage, choose the sync mode, then create the multicam sequence. fn open_multicam_wizard(&mut self, cx: &mut Context) { @@ -3105,6 +3172,16 @@ impl OakApp { } } } + modal_ids::SAVE_AS => { + if let ModalState::SaveAs { content, .. } = &self.modal { + if *button == 0 { + let content = content.clone(); + self.commit_save_as(&content, cx); + } else { + self.close_modal(cx); + } + } + } modal_ids::SEQUENCE_PROPERTIES => { if let ModalState::SequenceProperties { content, .. } = &self.modal { let content = content.clone(); diff --git a/crates/oak-app/src/oakui/engine.rs b/crates/oak-app/src/oakui/engine.rs index f8e04a560..db4631247 100644 --- a/crates/oak-app/src/oakui/engine.rs +++ b/crates/oak-app/src/oakui/engine.rs @@ -726,6 +726,14 @@ pub trait AppEngine: Err("project library not supported".into()) } + /// 另存为: saves the OPEN project into the library as a differently- + /// named copy and switches the current project to that copy. Default: + /// unsupported. + fn save_project_as(&mut self, name: &str, cx: &mut Context) -> Result<(), String> { + let _ = (name, cx); + Err("save-as not supported".into()) + } + /// Imports a `.ove` / `.otio` / `.fcpxml` project file into the library /// as a new row; returns the new row's uuid. fn library_import_project(&mut self, path: PathBuf) -> Result { diff --git a/crates/oak-app/src/oakui/real.rs b/crates/oak-app/src/oakui/real.rs index eb06e6efa..e7d6c7218 100644 --- a/crates/oak-app/src/oakui/real.rs +++ b/crates/oak-app/src/oakui/real.rs @@ -5387,6 +5387,26 @@ impl AppEngine for RealEngine { .map_err(|e| format!("failed to duplicate the project: {e}")) } + /// 另存为: duplicate the open project into the library under a new + /// (named) row, then open that copy as the current project. The + /// duplicator applies the new name to the copy's row and projectname. + fn save_project_as(&mut self, name: &str, cx: &mut Context) -> Result<(), String> { + let Some(project) = self.project.clone() else { + return Err("no project open".to_string()); + }; + let current_uuid = { + let guard = graphops::lock(&project); + guard.uuid.clone() + }; + let new_uuid = graphops::library_duplicate(¤t_uuid) + .map_err(|e| format!("duplicate failed: {e}"))?; + graphops::library_rename(&new_uuid, name.trim()) + .map_err(|e| format!("rename copy failed: {e}"))?; + // Open the new row (adopt switches the current project). + self.open_library_project(&new_uuid, cx)?; + Ok(()) + } + fn library_import_project(&mut self, path: PathBuf) -> Result { graphops::library_import(&path) .map_err(|e| format!("failed to import \"{}\": {e}", path.display()))