project file: 另存为 — duplicate the project in the library under a new name and switch to the copy

- 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 '<name> Copy'); OK
  duplicates + switches (the save-as semantics: a new, differently-
  named project in the library, editing continues on the copy)
This commit is contained in:
2026-09-02 09:22:29 +08:00
parent 660a32a1b3
commit 56af662b83
3 changed files with 106 additions and 1 deletions
+78 -1
View File
@@ -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<E: AppEngine> {
modal: Entity<Modal>,
content: Entity<crate::dialogs::NewProjectContent>,
},
/// The save-as dialog (文件 → 另存为): the copy's name.
SaveAs {
modal: Entity<Modal>,
content: Entity<crate::dialogs::RenameContent>,
},
}
/// A running export: the session the tick loop drains for progress.
@@ -283,6 +290,7 @@ impl<E: AppEngine> ModalState<E> {
| 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<E: AppEngine> OakApp<E> {
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<E: AppEngine> OakApp<E> {
});
}
/// 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<Self>) {
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<crate::dialogs::RenameContent>,
cx: &mut Context<Self>,
) {
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<Self>) {
@@ -3105,6 +3172,16 @@ impl<E: AppEngine> OakApp<E> {
}
}
}
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();
+8
View File
@@ -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<Self>) -> 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<String, String> {
+20
View File
@@ -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<Self>) -> 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(&current_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<String, String> {
graphops::library_import(&path)
.map_err(|e| format!("failed to import \"{}\": {e}", path.display()))