feat(storage,app): PostgreSQL backend (D3) + project manager window (D4)

D3: oakdb+pg:// fully wired (shared sea-orm entities, BIGSERIAL DDL,
connect-probe instead of pool retry on dead servers); Storage/Backend=pg
+ Storage/PgUrl config; 13 OAK_TEST_PG_URL-gated PG tests (verified
against a Docker postgres:16), always-on clean-error tests otherwise.

D4: DaVinci-style project manager — list with derived stats, create/
rename/duplicate/delete (confirm)/import/export (native dialogs,
ove/otio/fcpxml), shown at startup and from the file menu; facade
oakengine_library_* exports (list/create/delete/rename/duplicate/
import/export + project_load_library that binds write-through);
save/save-as menu becomes 'export project file', open splits into
from-library/from-file; status bar shows library write state; storage
activates on app start and flushes on exit; spawn_modal reentrancy
fixed (window-callback path) with a doc note.

Also: the P1 audio test's environment probe was lost in the ffi purge;
restored on cpal (the output device is cpal now).
This commit is contained in:
2026-08-16 10:39:31 +08:00
parent 5fabad8efd
commit 025dc88c25
32 changed files with 4711 additions and 657 deletions
+641 -41
View File
@@ -31,7 +31,7 @@
//! │ dock: 项目 | 素材查看器 | 序列查看器+节点编辑器 | 检查器+历史记录
//! │ (vertical split) 时间线 (full width, 31px toolbar on top)
//! ├─────────────────────────────────────────────────────
//! └ status bar: 就绪 | 缓存 | 代理 | 自动保存 || 时间码/时长 | 帧率 | 分辨率 | 引擎
//! └ status bar: 就绪 | 缓存 | 代理 | 库写入状态 || 时间码/时长 | 帧率 | 分辨率 | 引擎
//! ```
use std::path::PathBuf;
@@ -69,12 +69,13 @@ use crate::panels::timeline::TimelinePanel;
mod menu_ids {
pub const NEW_PROJECT: usize = 101;
pub const OPEN_PROJECT: usize = 102;
pub const SAVE: usize = 103;
pub const SAVE_AS: usize = 104;
pub const EXPORT_PROJECT: usize = 103;
pub const CLOSE: usize = 105;
pub const EXPORT: usize = 106;
pub const QUIT: usize = 107;
pub const IMPORT_FOOTAGE: usize = 108;
pub const PROJECT_MANAGER: usize = 109;
pub const OPEN_FROM_LIBRARY: usize = 110;
pub const UNDO: usize = 201;
pub const REDO: usize = 202;
@@ -116,6 +117,9 @@ mod modal_ids {
pub const PREFERENCES: usize = 3;
pub const EXPORT: usize = 4;
pub const EXPORT_PROGRESS: usize = 5;
pub const MANAGER: usize = 6;
pub const MANAGER_RENAME: usize = 7;
pub const MANAGER_DELETE: usize = 8;
}
/// What a picked platform-dialog path should do.
@@ -123,11 +127,18 @@ mod modal_ids {
enum FileAction {
ImportFootage,
Open,
/// Export the current project to a file (`.ove` / `.otio` / `.fcpxml`,
/// dispatched by extension).
SaveAs,
/// Import a project file into the library (the manager's 导入).
ImportProject,
/// Export the selected library project (the manager's 导出; the row's
/// uuid is stashed in [`OakApp::pending_export`]).
ExportProject,
}
/// The modal currently layered on top of the shell, if any.
enum ModalState {
enum ModalState<E: AppEngine> {
None,
Preferences {
modal: Entity<Modal>,
@@ -141,6 +152,19 @@ enum ModalState {
modal: Entity<Modal>,
content: Entity<ProgressContent>,
},
/// The project manager (M13 D4).
Manager {
modal: Entity<Modal>,
content: Entity<crate::manager::ProjectManager<E>>,
},
/// The manager's rename prompt.
ManagerRename {
modal: Entity<Modal>,
content: Entity<crate::manager::NamePrompt>,
uuid: String,
},
/// The manager's delete confirmation.
ManagerDelete { modal: Entity<Modal>, uuid: String },
}
/// A running export: the session the tick loop drains for progress.
@@ -148,14 +172,17 @@ struct ExportRun {
session: ExportSession,
}
impl ModalState {
impl<E: AppEngine> ModalState<E> {
/// The modal entity currently shown, if any.
fn modal_entity(&self) -> Option<Entity<Modal>> {
match self {
ModalState::None => None,
ModalState::Preferences { modal, .. }
| ModalState::Export { modal, .. }
| ModalState::Progress { modal, .. } => Some(modal.clone()),
| ModalState::Progress { modal, .. }
| ModalState::Manager { modal, .. }
| ModalState::ManagerRename { modal, .. }
| ModalState::ManagerDelete { modal, .. } => Some(modal.clone()),
}
}
}
@@ -255,9 +282,11 @@ pub struct OakApp<E: AppEngine> {
/// Whether the dark theme is active (toggles via 视图 → 主题).
dark: bool,
/// The modal currently shown on top of the shell, if any.
modal: ModalState,
modal: ModalState<E>,
/// The running export session, if any.
export: Option<ExportRun>,
/// The library row pending an export save dialog (manager 导出).
pending_export: Option<String>,
}
impl<E: AppEngine> OakApp<E> {
@@ -459,6 +488,7 @@ impl<E: AppEngine> OakApp<E> {
dark: true,
modal: ModalState::None,
export: None,
pending_export: None,
};
// Open the CLI-provided project once the shell is up.
@@ -491,11 +521,11 @@ impl<E: AppEngine> OakApp<E> {
use menu_ids::*;
match item {
// --- File ------------------------------------------------------
NEW_PROJECT => self.engine.update(cx, |engine, cx| engine.new_project(cx)),
NEW_PROJECT => self.new_project(cx),
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),
SAVE => self.save_project(None, cx),
SAVE_AS => self.open_file_dialog(FileAction::SaveAs, cx),
EXPORT_PROJECT => self.open_file_dialog(FileAction::SaveAs, cx),
CLOSE => self
.engine
.update(cx, |engine, cx| engine.close_project(cx)),
@@ -580,7 +610,8 @@ impl<E: AppEngine> OakApp<E> {
}
}
/// Saves the project (to its own filename, or the given `path`).
/// 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
@@ -590,6 +621,20 @@ impl<E: AppEngine> OakApp<E> {
}
}
/// 新建项目: 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.
fn new_project(&mut self, cx: &mut Context<Self>) {
let name = crate::i18n::tr("manager.new.default_name").to_string();
let result = self
.engine
.update(cx, |engine, cx| engine.library_create_project(&name, cx));
if let Err(err) = result {
println!("[file] library create failed ({err}); plain new project");
self.engine.update(cx, |engine, cx| engine.new_project(cx));
}
}
/// Deletes the timeline's selected clips (ripple or gap) through the
/// engine's edit commands.
fn delete_timeline_selection(&mut self, ripple: bool, cx: &mut Context<Self>) {
@@ -660,6 +705,228 @@ impl<E: AppEngine> OakApp<E> {
.detach();
}
// -----------------------------------------------------------------------
// Project manager (M13 D4)
// -----------------------------------------------------------------------
/// Opens the project manager (startup without a project argument, and
/// 文件 → 项目管理器 / 从库中打开…). Re-entrant: an already-open
/// manager just reloads its list.
pub fn show_project_manager(&mut self, cx: &mut Context<Self>) {
if let ModalState::Manager { content, .. } = &self.modal {
let content = content.clone();
content.update(cx, |manager, cx| manager.reload(cx));
return;
}
let engine = self.engine.clone();
self.spawn_modal(cx, move |window, app| {
let content = app.new(|cx| crate::manager::ProjectManager::new(engine, window, cx));
let modal = app.new(|cx| {
Modal::new(
modal_ids::MANAGER,
ModalOptions::new(crate::i18n::tr("manager.title"), px(880.0))
.with_button(DialogButton::cancel(crate::i18n::tr("dialog.close"))),
window,
cx,
)
.with_content(content.clone())
});
ModalState::Manager { modal, content }
});
// The content's requests (open / create / rename / ...) route here.
if let ModalState::Manager { content, .. } = &self.modal {
let content = content.clone();
cx.subscribe(
&content,
|this, _content, event: &crate::manager::ManagerEvent, cx| {
this.on_manager_event(event, cx);
},
)
.detach();
}
}
/// Routes a project-manager request through the engine. Open / Create
/// close the dialog on success; the mutating actions reload the list;
/// failures land in the dialog's status line.
fn on_manager_event(&mut self, event: &crate::manager::ManagerEvent, cx: &mut Context<Self>) {
use crate::manager::ManagerEvent as E;
match event {
E::Create => {
let name = crate::i18n::tr("manager.new.default_name").to_string();
let result = self
.engine
.update(cx, |engine, cx| engine.library_create_project(&name, cx));
match result {
Ok(()) => self.close_modal(cx),
Err(err) => self.manager_status(err, cx),
}
}
E::Open(uuid) => {
let uuid = uuid.clone();
let result = self
.engine
.update(cx, |engine, cx| engine.library_open_project(&uuid, cx));
match result {
Ok(()) => self.close_modal(cx),
Err(err) => self.manager_status(err, cx),
}
}
E::Rename(uuid) => self.open_manager_rename(uuid.clone(), cx),
E::Duplicate(uuid) => {
let result = self
.engine
.update(cx, |engine, _cx| engine.library_duplicate_project(uuid));
match result {
Ok(()) => self.reload_manager(cx),
Err(err) => self.manager_status(err, cx),
}
}
E::Delete(uuid) => self.open_manager_delete(uuid.clone(), cx),
E::Import => self.open_file_dialog(FileAction::ImportProject, cx),
E::Export(uuid) => self.open_manager_export(uuid.clone(), cx),
}
}
/// Reloads the open manager's list (after a library mutation).
fn reload_manager(&mut self, cx: &mut Context<Self>) {
if let ModalState::Manager { content, .. } = &self.modal {
let content = content.clone();
content.update(cx, |manager, cx| manager.reload(cx));
}
}
/// Shows an engine error in the open manager's status line (or logs it
/// when the manager is gone).
fn manager_status(&mut self, message: String, cx: &mut Context<Self>) {
if let ModalState::Manager { content, .. } = &self.modal {
let content = content.clone();
content.update(cx, |manager, cx| manager.set_status(Some(message), cx));
} else {
println!("[manager] {message}");
}
}
/// The selected row's display name in the open manager (used to seed
/// the rename prompt / the delete confirmation / the export filename).
fn manager_selected_name(&self, cx: &App) -> Option<String> {
match &self.modal {
ModalState::Manager { content, .. } => content.read(cx).selected_name(),
_ => None,
}
}
/// Swaps the manager for the rename prompt of row `uuid`.
fn open_manager_rename(&mut self, uuid: String, cx: &mut Context<Self>) {
let initial = self.manager_selected_name(cx).unwrap_or_default();
self.spawn_modal(cx, move |window, app| {
let content = app.new(|cx| crate::manager::NamePrompt::new(&initial, cx));
let modal = app.new(|cx| {
Modal::new(
modal_ids::MANAGER_RENAME,
ModalOptions::new(crate::i18n::tr("manager.rename.title"), px(380.0))
.with_button(DialogButton::primary(crate::i18n::tr(
"manager.rename.title",
)))
.with_button(DialogButton::cancel(crate::i18n::tr("dialog.cancel"))),
window,
cx,
)
.with_content(content.clone())
});
ModalState::ManagerRename { modal, content, uuid }
});
}
/// Swaps the manager for the delete confirmation of row `uuid`.
fn open_manager_delete(&mut self, uuid: String, cx: &mut Context<Self>) {
let name = self.manager_selected_name(cx).unwrap_or_default();
let text = crate::i18n::tr("manager.delete.confirm").replace("{name}", &name);
self.spawn_modal(cx, move |window, app| {
let content = app.new(|_cx| crate::manager::ConfirmContent::new(text));
let modal = app.new(|cx| {
Modal::new(
modal_ids::MANAGER_DELETE,
ModalOptions::new(crate::i18n::tr("manager.delete.title"), px(420.0))
.with_button(DialogButton::primary(crate::i18n::tr("manager.delete")))
.with_button(DialogButton::cancel(crate::i18n::tr("dialog.cancel"))),
window,
cx,
)
.with_content(content.clone())
});
ModalState::ManagerDelete { modal, uuid }
});
}
/// Confirms the rename prompt (button 0).
fn confirm_manager_rename(&mut self, cx: &mut Context<Self>) {
let ModalState::ManagerRename { content, uuid, .. } = &self.modal else {
return;
};
let name = content.read(cx).value(cx);
let uuid = uuid.clone();
if name.is_empty() {
self.back_to_manager(cx);
return;
}
let result = self
.engine
.update(cx, |engine, _cx| engine.library_rename_project(&uuid, &name));
match result {
Ok(()) => self.back_to_manager(cx),
Err(err) => {
self.back_to_manager(cx);
self.manager_status(err, cx);
}
}
}
/// Confirms the delete confirmation (button 0).
fn confirm_manager_delete(&mut self, cx: &mut Context<Self>) {
let ModalState::ManagerDelete { uuid, .. } = &self.modal else {
return;
};
let uuid = uuid.clone();
let result = self
.engine
.update(cx, |engine, _cx| engine.library_delete_project(&uuid));
match result {
Ok(()) => self.back_to_manager(cx),
Err(err) => {
self.back_to_manager(cx);
self.manager_status(err, cx);
}
}
}
/// Returns from a manager sub-dialog (rename / delete) to the manager.
fn back_to_manager(&mut self, cx: &mut Context<Self>) {
self.modal = ModalState::None;
self.show_project_manager(cx);
}
/// Opens the platform save dialog for exporting the library row `uuid`
/// (the suggested name is `<project>.ove`; the format follows the
/// extension the user picks).
fn open_manager_export(&mut self, uuid: String, cx: &mut Context<Self>) {
let name = self
.manager_selected_name(cx)
.filter(|n| !n.is_empty())
.unwrap_or_else(|| "project".to_string());
self.pending_export = Some(uuid);
let receiver =
cx.prompt_for_new_path(&PathBuf::from("."), Some(&format!("{name}.ove")));
cx.spawn(async move |this, cx| {
if let Ok(Ok(Some(path))) = receiver.await {
this.update(cx, |this, cx| {
this.on_file_paths(FileAction::ExportProject, vec![path], cx);
});
}
})
.detach();
}
// -----------------------------------------------------------------------
// Modal dialogs
// -----------------------------------------------------------------------
@@ -679,10 +946,15 @@ impl<E: AppEngine> OakApp<E> {
/// through a weak handle *inside* the window callback would re-enter this
/// entity while it is already being updated (the crash seen when opening
/// Preferences from a menu action).
///
/// The caller must NOT be inside a window update itself (e.g.
/// `WindowHandle::update`): the nested `update_window` below would fail
/// and the modal would silently not open. Drive the root entity instead
/// (menu actions and entity updates are fine).
fn spawn_modal(
&mut self,
cx: &mut Context<Self>,
build: impl FnOnce(&mut Window, &mut App) -> ModalState,
build: impl FnOnce(&mut Window, &mut App) -> ModalState<E>,
) {
let windows = cx.windows();
let Some(handle) = windows.first() else {
@@ -704,14 +976,15 @@ 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
/// allows multiple files); Save As asks for a new path next to the current
/// project. The picker resolves asynchronously, so the chosen path is
/// applied in a spawned task via [`Self::on_file_paths`].
/// 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`].
fn open_file_dialog(&mut self, action: FileAction, cx: &mut Context<Self>) {
match action {
FileAction::Open | FileAction::ImportFootage => {
FileAction::Open | FileAction::ImportFootage | FileAction::ImportProject => {
let prompt = match action {
FileAction::Open => crate::i18n::tr("file.open.title"),
FileAction::ImportProject => crate::i18n::tr("manager.import.title"),
_ => crate::i18n::tr("file.import_footage.title"),
};
let receiver = cx.prompt_for_paths(PathPromptOptions {
@@ -757,12 +1030,49 @@ impl<E: AppEngine> OakApp<E> {
})
.detach();
}
// The manager's export prompts in `open_manager_export` (it needs
// the selection's suggested filename).
FileAction::ExportProject => {}
}
}
/// Applies paths picked in the platform dialog through the engine, using
/// the action's routing (open / import / save-as).
/// the action's routing (open / import / export).
fn on_file_paths(&mut self, action: FileAction, paths: Vec<PathBuf>, cx: &mut Context<Self>) {
// The manager's library import/export refresh the open manager and
// report failures in its status line.
match action {
FileAction::ImportProject => {
let Some(path) = paths.first() else {
return;
};
let result = self
.engine
.update(cx, |engine, _cx| engine.library_import_project(path.clone()));
match result {
Ok(uuid) => {
println!("[manager] imported \"{}\" as {uuid}", path.display());
self.reload_manager(cx);
}
Err(err) => self.manager_status(err, cx),
}
return;
}
FileAction::ExportProject => {
let (Some(uuid), Some(path)) = (self.pending_export.take(), paths.first()) else {
return;
};
let result = self
.engine
.update(cx, |engine, _cx| engine.library_export_project(&uuid, path.clone()));
match result {
Ok(()) => println!("[manager] exported to \"{}\"", path.display()),
Err(err) => self.manager_status(err, cx),
}
return;
}
_ => {}
}
let result = self.engine.update(cx, |engine, cx| match action {
FileAction::Open => match paths.first() {
Some(path) => engine.open_project_path(path.clone(), cx),
@@ -786,6 +1096,8 @@ impl<E: AppEngine> OakApp<E> {
None => Ok(()),
}
}
// Handled above (the manager's library import/export).
FileAction::ImportProject | FileAction::ExportProject => Ok(()),
});
if let Err(err) = result {
println!("[file] {action:?} failed: {err}");
@@ -963,6 +1275,21 @@ impl<E: AppEngine> OakApp<E> {
}
}
modal_ids::PREFERENCES => self.close_modal(cx),
modal_ids::MANAGER => self.close_modal(cx),
modal_ids::MANAGER_RENAME => {
if *button == 0 {
self.confirm_manager_rename(cx);
} else {
self.back_to_manager(cx);
}
}
modal_ids::MANAGER_DELETE => {
if *button == 0 {
self.confirm_manager_delete(cx);
} else {
self.back_to_manager(cx);
}
}
_ => {}
},
ModalEvent::Dismissed { control } => match *control {
@@ -971,6 +1298,10 @@ impl<E: AppEngine> OakApp<E> {
self.cancel_export(cx);
self.close_modal(cx);
}
// Escape from a manager sub-dialog returns to the manager.
modal_ids::MANAGER_RENAME | modal_ids::MANAGER_DELETE => {
self.back_to_manager(cx);
}
_ => self.close_modal(cx),
},
}
@@ -1020,11 +1351,12 @@ fn make_menus(dark: bool) -> Vec<MenuBarEntry> {
tr("menu.file"),
Menu::new(vec![
MenuItem::new(NEW_PROJECT, tr("menu.file.new_project")).with_shortcut("⌘N"),
MenuItem::new(OPEN_FROM_LIBRARY, tr("menu.file.open_library")),
MenuItem::new(OPEN_PROJECT, tr("menu.file.open_project")).with_shortcut("⌘O"),
MenuItem::new(PROJECT_MANAGER, tr("menu.file.project_manager")).separated(),
MenuItem::new(IMPORT_FOOTAGE, tr("menu.file.import_footage")),
MenuItem::new(SAVE, tr("menu.file.save")).with_shortcut("⌘S"),
MenuItem::new(SAVE_AS, tr("menu.file.save_as"))
.with_shortcut("⇧⌘S")
MenuItem::new(EXPORT_PROJECT, tr("menu.file.export_project"))
.with_shortcut("⌘S")
.separated(),
MenuItem::new(CLOSE, tr("menu.file.close")),
MenuItem::new(EXPORT, tr("menu.file.export"))
@@ -1174,28 +1506,53 @@ fn run_with<E: AppEngine>(args: AppArgs) {
// Restore the persisted UI language (config `Language` key) before the
// first window renders.
crate::i18n::init();
// M13 D4: enable the write-through project library (SQLite at the
// default location) unless the user configured the backend
// explicitly.
crate::oakui::real::configure_storage();
cx.init_colors();
let bounds = Bounds::centered(None, size(px(1600.0), px(900.0)), cx);
let initial = initial.clone();
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|window, cx| {
// Compact pro-app text metrics: gpui's default rem is
// 16px (desktop-app large); 14px matches the design's
// density. All rem-based text scales; px spacing is
// unaffected.
window.set_rem_size(px(14.0));
build_root::<E>(window, initial, cx)
},
)
.expect("failed to open the main window");
let show_manager = initial.is_none();
let mut root_slot = None;
let window = cx
.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|window, cx| {
// Compact pro-app text metrics: gpui's default rem is
// 16px (desktop-app large); 14px matches the design's
// density. All rem-based text scales; px spacing is
// unaffected.
window.set_rem_size(px(14.0));
let root = build_root::<E>(window, initial, cx);
root_slot = Some(root.clone());
root
},
)
.expect("failed to open the main window");
let _ = window;
// No project on the command line: the DaVinci-style project manager
// greets instead of an empty shell. Drives the ROOT ENTITY (not the
// window handle): a window update borrows the window, and building a
// modal inside it would re-enter it (spawn_modal needs a free
// `update_window`).
if show_manager {
if let Some(root) = &root_slot {
root.update(cx, |app, cx| app.show_project_manager(cx));
}
}
cx.activate(true);
cx.on_window_closed(|cx, _| {
if cx.windows().is_empty() {
// Exit path (plan M13 §2): drain the write-through backlog
// (save + snapshot of every still-bound project) and stop
// the facade's snapshot thread before quitting.
crate::oakui::real::storage_flush();
cx.quit();
}
})
@@ -1206,6 +1563,7 @@ fn run_with<E: AppEngine>(args: AppArgs) {
#[cfg(test)]
mod tests {
use super::*;
use crate::oakui::EngineGateway as _;
use gpui::{px, size, TestAppContext};
/// The 视图/View menu carries a 语言/Language submenu whose items are
@@ -1299,9 +1657,10 @@ mod tests {
assert_eq!(dark_item(false).checked, Some(false));
}
/// The File menu exposes the full project lifecycle actions (open /
/// save / save-as / close / export) and the Edit menu the undo stack
/// plus the delete variants, across both languages.
/// The File menu exposes the full project lifecycle actions (new /
/// open-from-library / open-file / manager / export-project / close /
/// export) and the Edit menu the undo stack plus the delete variants,
/// across both languages.
#[test]
fn file_and_edit_menus_cover_the_project_lifecycle() {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
@@ -1317,9 +1676,10 @@ mod tests {
let file = entry("File(F)");
for id in [
menu_ids::NEW_PROJECT,
menu_ids::OPEN_FROM_LIBRARY,
menu_ids::OPEN_PROJECT,
menu_ids::SAVE,
menu_ids::SAVE_AS,
menu_ids::PROJECT_MANAGER,
menu_ids::EXPORT_PROJECT,
menu_ids::CLOSE,
menu_ids::EXPORT,
menu_ids::QUIT,
@@ -1349,7 +1709,7 @@ mod tests {
.menu
.items
.iter()
.any(|item| item.id == menu_ids::SAVE_AS));
.any(|item| item.id == menu_ids::EXPORT_PROJECT));
}
/// Opening 视图 → Preferences… must not crash: the dialog content and the
@@ -1463,4 +1823,244 @@ mod tests {
assert!(d.project.is_none());
assert!(!d.mock);
}
// -------------------------------------------------------------------
// Project manager (M13 D4)
// -------------------------------------------------------------------
/// A running app shell on the mock engine (en-US), plus its root. The
/// caller holds the language lock (the tests flip the process-global
/// language).
fn mock_shell(
cx: &mut TestAppContext,
) -> (
gpui::WindowHandle<OakApp<MockEngine>>,
Entity<OakApp<MockEngine>>,
) {
crate::i18n::set_language(crate::i18n::Language::EnUs);
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(1600.0), px(900.0)), |window, cx| {
OakApp::<MockEngine>::new(window, None, cx)
});
cx.run_until_parked();
let root = window.root(cx).expect("app root");
(window, root)
}
/// Opens the manager and returns its content entity.
fn open_manager(
cx: &mut TestAppContext,
root: &Entity<OakApp<MockEngine>>,
) -> Entity<crate::manager::ProjectManager<MockEngine>> {
cx.update(|app| root.update(app, |app, cx| app.show_project_manager(cx)));
cx.run_until_parked();
let content = cx.read(|app| match &root.read(app).modal {
ModalState::Manager { content, .. } => content.clone(),
_ => panic!("the manager modal should be open"),
});
content
}
/// The manager's listed rows.
fn manager_rows(
cx: &mut TestAppContext,
content: &Entity<crate::manager::ProjectManager<MockEngine>>,
) -> Vec<crate::oakui::LibraryProject> {
cx.read(|app| content.read(app).rows().to_vec())
}
/// The manager lists the mock library; opening a row (the double-click
/// / 打开 route) drives the engine's library open and closes the dialog.
#[gpui::test]
async fn manager_lists_and_opens(cx: &mut TestAppContext) {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
let (_window, root) = mock_shell(cx);
let content = open_manager(cx, &root);
let rows = manager_rows(cx, &content);
assert_eq!(rows.len(), 3, "the mock library seeds three rows");
assert_eq!(rows[0].name, "第一稿", "most recently modified first");
assert!(rows[0].track_count > 0 && rows[0].footage_count > 0);
// Select the second row and open it.
let uuid = rows[1].uuid.clone();
cx.update(|app| content.update(app, |m, cx| m.select(&uuid, cx)));
cx.update(|app| {
root.update(app, |app, cx| {
app.on_manager_event(&crate::manager::ManagerEvent::Open(uuid.clone()), cx)
})
});
cx.run_until_parked();
let opened = cx.read(|app| root.read(app).engine.read(app).library_opened().to_vec());
assert_eq!(opened, vec![uuid], "the engine opened the selected row");
let name = cx.read(|app| root.read(app).engine.read(app).project().unwrap().name.clone());
assert_eq!(name, "宣传片 v3");
let modal_none = cx.read(|app| matches!(root.read(app).modal, ModalState::None));
assert!(modal_none, "a successful open closes the manager");
}
/// Create / rename / duplicate / delete round-trip through the manager
/// and its sub-dialogs.
#[gpui::test]
async fn manager_create_rename_duplicate_delete(cx: &mut TestAppContext) {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
let (_window, root) = mock_shell(cx);
let content = open_manager(cx, &root);
// Create: a new row appears and the project opens (dialog closes).
cx.update(|app| {
root.update(app, |app, cx| {
app.on_manager_event(&crate::manager::ManagerEvent::Create, cx)
})
});
cx.run_until_parked();
let rows = cx.read(|app| root.read(app).engine.read(app).library_projects().unwrap());
assert_eq!(rows.len(), 4);
let created = rows
.iter()
.find(|row| row.name == "Untitled Project")
.expect("the created row")
.clone();
let project_name = cx.read(|app| root.read(app).engine.read(app).project().unwrap().name.clone());
assert_eq!(project_name, "Untitled Project", "create opens the new project");
// Reopen the manager, select the created row, rename it.
let content = open_manager(cx, &root);
cx.update(|app| content.update(app, |m, cx| m.select(&created.uuid, cx)));
cx.update(|app| {
root.update(app, |app, cx| {
app.on_manager_event(&crate::manager::ManagerEvent::Rename(created.uuid.clone()), cx)
})
});
cx.run_until_parked();
let prompt = cx.read(|app| match &root.read(app).modal {
ModalState::ManagerRename { content, uuid, .. } => {
assert_eq!(uuid, &created.uuid);
content.clone()
}
_ => panic!("the rename prompt should be open"),
});
cx.update(|app| prompt.update(app, |p, cx| p.set_value("改名为正稿", cx)));
cx.update(|app| {
root.update(app, |app, cx| {
app.on_modal(
&ModalEvent::ButtonClicked {
control: modal_ids::MANAGER_RENAME,
button: 0,
},
cx,
)
})
});
cx.run_until_parked();
// The confirmation swaps in a FRESH manager (the captured content is
// stale from here on); assert against the engine's library instead.
let rows = cx.read(|app| root.read(app).engine.read(app).library_projects().unwrap());
let renamed = rows.iter().find(|row| row.uuid == created.uuid).unwrap();
assert_eq!(renamed.name, "改名为正稿");
let back = cx.read(|app| matches!(root.read(app).modal, ModalState::Manager { .. }));
assert!(back, "a confirmed rename returns to the manager");
// Duplicate the renamed row.
cx.update(|app| {
root.update(app, |app, cx| {
app.on_manager_event(
&crate::manager::ManagerEvent::Duplicate(created.uuid.clone()),
cx,
)
})
});
cx.run_until_parked();
let rows = cx.read(|app| root.read(app).engine.read(app).library_projects().unwrap());
assert_eq!(rows.len(), 5);
assert!(
rows.iter().any(|row| row.name == "改名为正稿 (copy)"),
"the copy is named '<name> (copy)': {rows:?}"
);
// Delete the original through the confirmation dialog.
cx.update(|app| {
root.update(app, |app, cx| {
app.on_manager_event(&crate::manager::ManagerEvent::Delete(created.uuid.clone()), cx)
})
});
cx.run_until_parked();
let confirming = cx.read(|app| matches!(root.read(app).modal, ModalState::ManagerDelete { .. }));
assert!(confirming, "the delete confirmation should be open");
cx.update(|app| {
root.update(app, |app, cx| {
app.on_modal(
&ModalEvent::ButtonClicked {
control: modal_ids::MANAGER_DELETE,
button: 0,
},
cx,
)
})
});
cx.run_until_parked();
let rows = cx.read(|app| root.read(app).engine.read(app).library_projects().unwrap());
assert_eq!(rows.len(), 4);
assert!(!rows.iter().any(|row| row.uuid == created.uuid));
let back = cx.read(|app| matches!(root.read(app).modal, ModalState::Manager { .. }));
assert!(back, "a confirmed delete returns to the manager");
}
/// The manager's 导入 opens the platform path picker and lands the file
/// as a new row; 导出 asks for a new path and routes it to the engine.
#[gpui::test]
async fn manager_import_and_export_route_through_the_platform_dialogs(
cx: &mut TestAppContext,
) {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
let (_window, root) = mock_shell(cx);
let content = open_manager(cx, &root);
// Import.
cx.update(|app| {
root.update(app, |app, cx| {
app.on_manager_event(&crate::manager::ManagerEvent::Import, cx)
})
});
cx.run_until_parked();
assert!(cx.did_prompt_for_paths(), "import shows the path picker");
cx.simulate_path_prompt_response(|options| {
assert!(!options.multiple, "project import is single-file");
Some(vec![PathBuf::from("/library/先导片.ove")])
});
cx.run_until_parked();
let rows = manager_rows(cx, &content);
assert_eq!(rows.len(), 4);
assert!(
rows.iter().any(|row| row.name == "先导片"),
"the imported file becomes a row named by its stem: {rows:?}"
);
// Export the imported row.
let uuid = rows
.iter()
.find(|row| row.name == "先导片")
.unwrap()
.uuid
.clone();
cx.update(|app| content.update(app, |m, cx| m.select(&uuid, cx)));
cx.update(|app| {
root.update(app, |app, cx| {
app.on_manager_event(&crate::manager::ManagerEvent::Export(uuid.clone()), cx)
})
});
cx.run_until_parked();
assert!(
cx.did_prompt_for_new_path(),
"export shows the save dialog"
);
cx.simulate_new_path_selection(|_dir| Some(PathBuf::from("/library/先导片.otio")));
cx.run_until_parked();
let exported = cx.read(|app| root.read(app).engine.read(app).library_exported().to_vec());
assert_eq!(
exported,
vec![(uuid, PathBuf::from("/library/先导片.otio"))],
"the picked path routes to the engine's library export"
);
}
}
+66 -11
View File
@@ -180,10 +180,11 @@ const EN: &[(&str, &str)] = &[
("menu.help", "Help(H)"),
// --- File ---
("menu.file.new_project", "New Project…"),
("menu.file.open_project", "Open Project…"),
("menu.file.open_project", "Open Project File"),
("menu.file.open_library", "Open from Library…"),
("menu.file.project_manager", "Project Manager…"),
("menu.file.import_footage", "Import Footage…"),
("menu.file.save", "Save"),
("menu.file.save_as", "Save As…"),
("menu.file.export_project", "Export Project File…"),
("menu.file.close", "Close Project"),
("menu.file.export", "Export…"),
("menu.file.quit", "Quit"),
@@ -240,9 +241,34 @@ const EN: &[(&str, &str)] = &[
("status.ready", "Ready"),
("status.cache", "Cache: Enabled"),
("status.proxy", "Proxy: Off"),
("status.autosave", "Autosave: 3 min ago"),
("status.storage.written", "Library: written"),
("status.storage.unbound", "Library: off"),
("status.storage.error", "Library: write failed"),
("status.untitled", "Untitled Project"),
("status.backend", "Engine:"),
// --- project manager ---
("manager.title", "Project Manager"),
("manager.new", "New Project"),
("manager.new.default_name", "Untitled Project"),
("manager.open", "Open"),
("manager.rename", "Rename…"),
("manager.rename.title", "Rename Project"),
("manager.rename.label", "New name"),
("manager.duplicate", "Duplicate"),
("manager.delete", "Delete"),
("manager.delete.title", "Delete Project"),
("manager.delete.confirm", "Delete project \"{name}\" from the library? This cannot be undone."),
("manager.import", "Import…"),
("manager.import.title", "Import Project"),
("manager.export", "Export…"),
("manager.export.title", "Export Project"),
("manager.col.name", "Name"),
("manager.col.modified", "Modified"),
("manager.col.duration", "Duration"),
("manager.col.tracks", "Tracks"),
("manager.col.clips", "Clips"),
("manager.col.footage", "Footage"),
("manager.empty", "No projects in the library yet."),
// --- timeline toolbar ---
("timeline.tool.select", "Select"),
("timeline.tool.razor", "Razor"),
@@ -327,10 +353,11 @@ const ZH: &[(&str, &str)] = &[
("menu.help", "帮助(H)"),
// --- File ---
("menu.file.new_project", "新建项目…"),
("menu.file.open_project", "打开项目"),
("menu.file.open_project", "打开工程文件"),
("menu.file.open_library", "从库中打开…"),
("menu.file.project_manager", "项目管理器…"),
("menu.file.import_footage", "导入素材…"),
("menu.file.save", "保存"),
("menu.file.save_as", "另存为…"),
("menu.file.export_project", "导出工程文件…"),
("menu.file.close", "关闭项目"),
("menu.file.export", "导出…"),
("menu.file.quit", "退出"),
@@ -387,9 +414,37 @@ const ZH: &[(&str, &str)] = &[
("status.ready", "就绪"),
("status.cache", "缓存:已启用"),
("status.proxy", "代理:关"),
("status.autosave", "自动保存:3分钟前"),
("status.storage.written", "库:已写入"),
("status.storage.unbound", "库:未启用"),
("status.storage.error", "库:写入失败"),
("status.untitled", "未命名项目"),
("status.backend", "引擎:"),
// --- project manager ---
("manager.title", "项目管理器"),
("manager.new", "新建项目"),
("manager.new.default_name", "未命名项目"),
("manager.open", "打开"),
("manager.rename", "重命名…"),
("manager.rename.title", "重命名工程"),
("manager.rename.label", "新名称"),
("manager.duplicate", "复制"),
("manager.delete", "删除"),
("manager.delete.title", "删除工程"),
(
"manager.delete.confirm",
"从库中删除工程“{name}”?此操作不可撤销。",
),
("manager.import", "导入…"),
("manager.import.title", "导入工程"),
("manager.export", "导出…"),
("manager.export.title", "导出工程"),
("manager.col.name", "名称"),
("manager.col.modified", "修改时间"),
("manager.col.duration", "时长"),
("manager.col.tracks", "轨道"),
("manager.col.clips", "片段"),
("manager.col.footage", "素材"),
("manager.empty", "库中还没有工程。"),
// --- timeline toolbar ---
("timeline.tool.select", "选择"),
("timeline.tool.razor", "剃刀"),
@@ -594,11 +649,11 @@ mod tests {
fn switching_flips_a_sample_string() {
let _guard = lang_lock().lock().unwrap();
set_language(Language::EnUs);
assert_eq!(tr("menu.file.save"), "Save");
assert_eq!(tr("menu.file.export_project"), "Export Project File…");
set_language(Language::ZhCN);
assert_eq!(tr("menu.file.save"), "保存");
assert_eq!(tr("menu.file.export_project"), "导出工程文件…");
set_language(Language::EnUs);
assert_eq!(tr("menu.file.save"), "Save");
assert_eq!(tr("menu.file.export_project"), "Export Project File…");
}
/// `sync_widgets` installs the active language's strings into the widget
+3
View File
@@ -31,6 +31,8 @@
//! * [`app`] — the window shell: menu bar, dock layout, status bar, modal
//! dialogs (file open/save-as, preferences, export), tick loop.
//! * [`dialogs`] — the preferences and export dialog content views.
//! * [`manager`] — the project manager window (M13 D4): the library browser
//! with new / open / rename / duplicate / delete / import / export.
//! * [`panels`] — the dockable panels (viewers, timeline, inspector, ...).
//! * [`oakui`] — the engine gateway trait, the mock + real implementations,
//! and the pure view-state logic (timecode, transport).
@@ -51,6 +53,7 @@
pub mod app;
pub mod dialogs;
pub mod i18n;
pub mod manager;
pub mod oakui;
pub mod panels;
+538
View File
@@ -0,0 +1,538 @@
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The project manager (M13 D4): the DaVinci-style library browser the app
//! shows at startup (no `--project` argument) and from 文件 → 项目管理器.
//!
//! The view is a modal content view ([`ProjectManager`]) hosted by
//! `crate::app::OakApp` inside the standard `Modal` card: a toolbar
//! (新建 / 导入), a column list of the library rows (name, modified time,
//! duration, tracks, clips, footage — the backend-derived stats), and an
//! action row (打开 / 重命名 / 复制 / 删除 / 导出). The view emits
//! [`ManagerEvent`] requests; the app routes them through the engine
//! ([`AppEngine`]'s library surface) and swaps in the rename / delete
//! confirmation modals.
//!
//! Pure formatting helpers ([`format_modified`], [`format_duration_ms`])
//! are unit tested here; the app-level flows are covered by the
//! `OakApp<MockEngine>` tests in `crate::app`.
use gpui::colors::DefaultColors;
use gpui::prelude::*;
use gpui::{
div, App, ClickEvent, Context, ElementId, Entity, EventEmitter, Hsla, Render, SharedString,
Window,
};
use gpui_elements::editable_text::{text_input, EditableTextState, StringStorage};
use crate::i18n;
use crate::oakui::{AppEngine, LibraryProject};
/// A request the manager view emits for the host (the app shell) to route
/// through the engine.
#[derive(Debug, Clone, PartialEq)]
pub enum ManagerEvent {
/// Open the project (double-click / the 打开 button).
Open(String),
/// Create a new blank project and open it.
Create,
/// Rename the project (the host prompts for the new name).
Rename(String),
/// Duplicate the project (history included).
Duplicate(String),
/// Delete the project (the host confirms first).
Delete(String),
/// Import a `.ove` / `.otio` / `.fcpxml` file as a new library row.
Import,
/// Export the project to a file.
Export(String),
}
/// The project manager content view: the library list plus its toolbars.
pub struct ProjectManager<E: AppEngine> {
engine: Entity<E>,
/// The listed rows (most recently modified first, as the engine
/// reports them).
rows: Vec<LibraryProject>,
/// The selected row index.
selected: Option<usize>,
/// The last operation error (shown under the list).
status: Option<String>,
}
impl<E: AppEngine> ProjectManager<E> {
/// Builds the view and loads the library.
pub fn new(engine: Entity<E>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
let mut this = Self {
engine,
rows: Vec::new(),
selected: None,
status: None,
};
this.reload(cx);
this
}
/// Reloads the library from the engine, keeping the selection on the
/// same row (by uuid) when it still exists.
pub fn reload(&mut self, cx: &mut Context<Self>) {
let selected_uuid = self.selected_uuid();
match self.engine.read(cx).library_projects() {
Ok(rows) => {
self.rows = rows;
self.selected = selected_uuid
.and_then(|uuid| self.rows.iter().position(|row| row.uuid == uuid));
self.status = None;
}
Err(err) => {
self.rows = Vec::new();
self.selected = None;
self.status = Some(err);
}
}
cx.notify();
}
/// The selected row's uuid, if any.
pub fn selected_uuid(&self) -> Option<String> {
self.selected
.and_then(|index| self.rows.get(index))
.map(|row| row.uuid.clone())
}
/// The selected row's display name, if any.
pub fn selected_name(&self) -> Option<String> {
self.selected
.and_then(|index| self.rows.get(index))
.map(|row| row.name.clone())
}
/// Shows an operation error under the list (the host reports engine
/// failures here so a failed action is visible in the dialog).
pub fn set_status(&mut self, status: Option<String>, cx: &mut Context<Self>) {
self.status = status;
cx.notify();
}
/// The listed rows (tests).
#[cfg(test)]
pub fn rows(&self) -> &[LibraryProject] {
&self.rows
}
/// Selects the row with `uuid` (tests and the host's post-action
/// reselection).
pub fn select(&mut self, uuid: &str, cx: &mut Context<Self>) {
self.selected = self.rows.iter().position(|row| row.uuid == uuid);
cx.notify();
}
/// Emits the event through the view's subscribers.
fn emit(&mut self, event: ManagerEvent, cx: &mut Context<Self>) {
cx.emit(event);
cx.notify();
}
/// A click on row `index`: select, or open on a double click.
fn row_clicked(&mut self, index: usize, clicks: usize, cx: &mut Context<Self>) {
if index >= self.rows.len() {
return;
}
self.selected = Some(index);
if clicks >= 2 {
let uuid = self.rows[index].uuid.clone();
self.emit(ManagerEvent::Open(uuid), cx);
} else {
cx.notify();
}
}
/// An action-row button targeting the selection.
fn selected_action(&mut self, action: impl FnOnce(String) -> ManagerEvent, cx: &mut Context<Self>) {
if let Some(uuid) = self.selected_uuid() {
self.emit(action(uuid), cx);
}
}
}
impl<E: AppEngine> EventEmitter<ManagerEvent> for ProjectManager<E> {}
impl<E: AppEngine> Render for ProjectManager<E> {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
// Toolbar buttons (新建 / 导入) and the selection-targeting action
// row (打开 / 重命名 / 复制 / 删除 / 导出).
let tool = |id: &'static str, label: &'static str| -> gpui::Stateful<gpui::Div> {
div()
.id(id)
.px_3()
.py_1()
.rounded_md()
.bg(colors.background)
.border_1()
.border_color(colors.border)
.text_color(colors.text)
.cursor_pointer()
.child(label)
};
let has_selection = self.selected.is_some();
let action = |id: &'static str, label: &'static str| -> gpui::Stateful<gpui::Div> {
let mut b = div().id(id).px_3().py_1().rounded_md();
if has_selection {
b = b
.bg(colors.background)
.border_1()
.border_color(colors.border)
.text_color(colors.text)
.cursor_pointer();
} else {
b = b.text_color(colors.disabled);
}
b.child(label)
};
// The column header.
let header_cell = |label: &'static str, width: f32| -> gpui::Div {
let mut cell = div()
.px_2()
.text_xs()
.text_color(colors.disabled)
.whitespace_nowrap()
.child(label);
if width <= 0.0 {
cell = cell.flex_1();
} else {
cell = cell.w(gpui::px(width)).text_right();
}
cell
};
let list = div()
.h(gpui::px(340.0))
.flex()
.flex_col()
.border_1()
.border_color(colors.border)
.rounded_md()
.bg(colors.background)
.child(
div()
.flex()
.items_center()
.gap_2()
.py_1()
.border_b_1()
.border_color(colors.border)
.child(header_cell(i18n::tr("manager.col.name"), 0.0))
.child(header_cell(i18n::tr("manager.col.modified"), 128.0))
.child(header_cell(i18n::tr("manager.col.duration"), 72.0))
.child(header_cell(i18n::tr("manager.col.tracks"), 56.0))
.child(header_cell(i18n::tr("manager.col.clips"), 56.0))
.child(header_cell(i18n::tr("manager.col.footage"), 56.0)),
)
.child(
div()
.id("manager-list")
.flex_1()
.min_h_0()
.overflow_y_scroll()
.children(if self.rows.is_empty() {
vec![
div()
.p_4()
.text_color(colors.disabled)
.child(i18n::tr("manager.empty"))
.into_any_element(),
]
} else {
self.rows
.iter()
.enumerate()
.map(|(index, row)| {
let selected = self.selected == Some(index);
let mut line = div()
.id(ElementId::Name(format!("manager-row-{index}").into()))
.flex()
.items_center()
.gap_2()
.py_1()
.cursor_pointer()
.on_click(cx.listener(move |this, event: &ClickEvent, _w, cx| {
this.row_clicked(index, event.click_count(), cx);
}));
if selected {
line = line.bg(colors.selected).text_color(colors.selected_text);
} else {
line = line.text_color(colors.text);
}
let cell = |text: String, width: f32| -> gpui::Div {
let mut c = div().px_2().whitespace_nowrap().child(text);
if width <= 0.0 {
c = c.flex_1();
} else {
c = c.w(gpui::px(width)).text_right();
}
c
};
line.child(cell(row.name.clone(), 0.0))
.child(cell(format_modified(row.modified_at), 128.0))
.child(cell(format_duration_ms(row.duration_ms), 72.0))
.child(cell(row.track_count.to_string(), 56.0))
.child(cell(row.clip_count.to_string(), 56.0))
.child(cell(row.footage_count.to_string(), 56.0))
.into_any_element()
})
.collect()
}),
);
let mut root = div()
.flex()
.flex_col()
.gap_3()
.w_full()
.child(
div()
.flex()
.gap_2()
.child(
tool("manager-new", i18n::tr("manager.new")).on_click(
cx.listener(|this, _e: &ClickEvent, _w, cx| {
this.emit(ManagerEvent::Create, cx);
}),
),
)
.child(
tool("manager-import", i18n::tr("manager.import")).on_click(
cx.listener(|this, _e: &ClickEvent, _w, cx| {
this.emit(ManagerEvent::Import, cx);
}),
),
),
)
.child(list)
.child(
div()
.flex()
.justify_end()
.gap_2()
.child(
action("manager-open", i18n::tr("manager.open")).on_click(
cx.listener(|this, _e: &ClickEvent, _w, cx| {
this.selected_action(ManagerEvent::Open, cx);
}),
),
)
.child(
action("manager-rename", i18n::tr("manager.rename")).on_click(
cx.listener(|this, _e: &ClickEvent, _w, cx| {
this.selected_action(ManagerEvent::Rename, cx);
}),
),
)
.child(
action("manager-duplicate", i18n::tr("manager.duplicate")).on_click(
cx.listener(|this, _e: &ClickEvent, _w, cx| {
this.selected_action(ManagerEvent::Duplicate, cx);
}),
),
)
.child(
action("manager-delete", i18n::tr("manager.delete")).on_click(
cx.listener(|this, _e: &ClickEvent, _w, cx| {
this.selected_action(ManagerEvent::Delete, cx);
}),
),
)
.child(
action("manager-export", i18n::tr("manager.export")).on_click(
cx.listener(|this, _e: &ClickEvent, _w, cx| {
this.selected_action(ManagerEvent::Export, cx);
}),
),
),
);
if let Some(status) = &self.status {
root = root.child(
div()
.text_xs()
.text_color(Hsla {
h: 0.0,
s: 0.6,
l: 0.55,
a: 1.0,
})
.child(status.clone()),
);
}
root
}
}
// ---------------------------------------------------------------------------
// Rename prompt / delete confirmation contents
// ---------------------------------------------------------------------------
/// The rename prompt's content: one text field with the new name.
pub struct NamePrompt {
editor: Entity<EditableTextState>,
}
impl NamePrompt {
/// Builds the prompt seeded with `initial`.
pub fn new(initial: &str, cx: &mut Context<Self>) -> Self {
let editor = cx.new(|cx| {
let editor = EditableTextState::new(StringStorage::default(), cx);
editor
});
editor.update(cx, |editor, cx| editor.emplace(initial, cx));
Self { editor }
}
/// The name currently entered (trimmed).
pub fn value(&self, app: &App) -> String {
self.editor.read(app).as_str().trim().to_string()
}
/// Replaces the entered name (tests / prefill).
pub fn set_value(&mut self, value: &str, cx: &mut Context<Self>) {
self.editor.update(cx, |editor, cx| editor.emplace(value, cx));
cx.notify();
}
}
impl Render for NamePrompt {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let weak = self.editor.downgrade();
div()
.flex()
.flex_col()
.gap_1()
.w_full()
.child(
div()
.text_color(colors.text)
.child(i18n::tr("manager.rename.label")),
)
.child(
div()
.rounded_md()
.border_1()
.border_color(colors.border)
.bg(colors.background)
.px_2()
.py_1()
.child(
text_input("gpui-widgets-rename-field")
.state(weak)
.accepts_input(true),
),
)
}
}
/// The delete confirmation's content: the warning text.
pub struct ConfirmContent {
text: SharedString,
}
impl ConfirmContent {
/// Builds the confirmation with `text`.
pub fn new(text: impl Into<SharedString>) -> Self {
Self { text: text.into() }
}
}
impl Render for ConfirmContent {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
div().text_color(colors.text).child(self.text.clone())
}
}
// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------
/// Formats a unix timestamp (UTC) as `YYYY-MM-DD HH:MM` for the modified
/// column.
pub fn format_modified(unix_secs: i64) -> String {
if unix_secs <= 0 {
return "".to_string();
}
let days = unix_secs.div_euclid(86_400);
let secs = unix_secs.rem_euclid(86_400);
let (year, month, day) = civil_from_days(days);
format!(
"{year:04}-{month:02}-{day:02} {:02}:{:02}",
secs / 3600,
(secs % 3600) / 60
)
}
/// Days since the unix epoch → (year, month, day), UTC (Howard Hinnant's
/// civil-from-days algorithm).
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let year = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
let year = if month <= 2 { year + 1 } else { year };
(year, month, day)
}
/// Formats a duration in milliseconds as `H:MM:SS` for the duration
/// column.
pub fn format_duration_ms(ms: i64) -> String {
if ms <= 0 {
return "".to_string();
}
let secs = ms / 1000;
format!("{}:{:02}:{:02}", secs / 3600, (secs % 3600) / 60, secs % 60)
}
#[cfg(test)]
mod tests {
use super::*;
/// The civil-date conversion matches known dates.
#[test]
fn format_modified_known_dates() {
assert_eq!(format_modified(0), "");
assert_eq!(format_modified(-5), "");
// 2026-08-16 00:54:01 UTC.
assert_eq!(format_modified(1_786_841_641), "2026-08-16 00:54");
// 1970-01-01 00:00 UTC.
assert_eq!(format_modified(1), "1970-01-01 00:00");
// 2000-02-29 (a leap day) 12:34 UTC.
assert_eq!(format_modified(951_827_640), "2000-02-29 12:34");
}
/// Durations format as H:MM:SS with a dash for empty projects.
#[test]
fn format_duration_ms_shapes() {
assert_eq!(format_duration_ms(0), "");
assert_eq!(format_duration_ms(61_500), "0:01:01");
assert_eq!(format_duration_ms(3_725_000), "1:02:05");
}
}
+91
View File
@@ -93,6 +93,30 @@ pub struct Project {
pub path: PathBuf,
}
/// A project-library row, as the project manager lists it (M13 D4). The
/// stats are derived from the row's head state by the backend (they are
/// never stored in the library).
#[derive(Debug, Clone, PartialEq)]
pub struct LibraryProject {
/// The library row uuid (the open / rename / duplicate / delete /
/// export selector).
pub uuid: String,
/// The row's display name.
pub name: String,
/// Row creation time (unix seconds, UTC).
pub created_at: i64,
/// Last-write time (unix seconds, UTC; the manager sort key).
pub modified_at: i64,
/// Longest sequence duration in milliseconds.
pub duration_ms: i64,
/// Total tracks across all sequences.
pub track_count: i32,
/// Total clip blocks.
pub clip_count: i32,
/// Total footage nodes.
pub footage_count: i32,
}
/// The sequence currently open in the project.
#[derive(Debug, Clone, PartialEq)]
pub struct Sequence {
@@ -293,6 +317,73 @@ pub trait AppEngine:
/// Closes the current project, leaving the app with no sequence.
fn close_project(&mut self, cx: &mut Context<Self>);
// -------------------------------------------------------------------
// Project library (M13 D4: the write-through database the manager
// window browses). Default: unsupported (empty list / error strings).
// -------------------------------------------------------------------
/// Whether the open project is bound to the library write-through
/// session (the status bar's write state).
fn storage_bound(&self) -> bool {
false
}
/// The last write-through / snapshot error of the open project, if any.
fn storage_last_error(&self) -> Option<String> {
None
}
/// Lists the project library, most recently modified first (the
/// project manager's data source).
fn library_projects(&self) -> Result<Vec<LibraryProject>, String> {
Err("project library not supported".into())
}
/// Creates a blank project named `name` in the library and opens it.
fn library_create_project(&mut self, name: &str, cx: &mut Context<Self>) -> Result<(), String> {
let _ = (name, cx);
Err("project library not supported".into())
}
/// Opens the library project `uuid` (closing the current project).
fn library_open_project(&mut self, uuid: &str, cx: &mut Context<Self>) -> Result<(), String> {
let _ = (uuid, cx);
Err("project library not supported".into())
}
/// Deletes the library project `uuid` (the manager confirms first).
fn library_delete_project(&mut self, uuid: &str) -> Result<(), String> {
let _ = uuid;
Err("project library not supported".into())
}
/// Renames the library project `uuid` (the manager's list name).
fn library_rename_project(&mut self, uuid: &str, name: &str) -> Result<(), String> {
let _ = (uuid, name);
Err("project library not supported".into())
}
/// Duplicates the library project `uuid` (history included) under a
/// fresh uuid.
fn library_duplicate_project(&mut self, uuid: &str) -> Result<(), String> {
let _ = uuid;
Err("project library 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> {
let _ = path;
Err("project library not supported".into())
}
/// Exports the library project `uuid` to `path`; the format is
/// dispatched by extension (`.ove` / `.otio` / `.fcpxml`).
fn library_export_project(&mut self, uuid: &str, path: PathBuf) -> Result<(), String> {
let _ = (uuid, path);
Err("project library not supported".into())
}
/// The timeline waveform cache (M12 P4); `None` when the backend
/// does not provide waveforms.
fn waveform_cache(&self) -> Option<std::sync::Arc<crate::oakui::waveform::WaveformCache>> {
+61
View File
@@ -281,6 +281,67 @@ unsafe extern "C" {
/// `oakengine_config_set_string` — write a string value.
pub fn oakengine_config_set_string(key: *const c_char, value: *const c_char) -> c_int;
// -- oakengine::storage (write-through session state) --
/// `oakengine_storage_flush` — flush every bound project and stop the
/// snapshot thread (the app calls it on exit).
pub fn oakengine_storage_flush() -> c_int;
/// `oakengine_storage_is_bound` — 1 when the project is bound to a
/// library session.
pub fn oakengine_storage_is_bound(project: *mut OakEngineProject) -> c_int;
/// `oakengine_storage_last_error` — the last write-through / snapshot
/// error (buf/size; empty when none or not bound).
pub fn oakengine_storage_last_error(
project: *mut OakEngineProject,
buf: *mut c_char,
buf_size: c_int,
) -> c_int;
// -- oakengine::library (project manager, M13 D4) --
/// `oakengine_library_list` — the library rows as a JSON array
/// (buf/size), most recently modified first; `"[]"` with storage off.
pub fn oakengine_library_list(buf: *mut c_char, buf_size: c_int) -> c_int;
/// `oakengine_library_create` — create a blank project row; reports its
/// uuid (buf/size on `out_uuid`; the return value is the uuid length,
/// negative on error).
pub fn oakengine_library_create(
name: *const c_char,
out_uuid: *mut c_char,
out_size: c_int,
) -> c_int;
/// `oakengine_library_delete` — delete a row by uuid.
pub fn oakengine_library_delete(uuid: *const c_char) -> c_int;
/// `oakengine_library_rename` — rename a row.
pub fn oakengine_library_rename(uuid: *const c_char, name: *const c_char) -> c_int;
/// `oakengine_library_duplicate` — copy a row (history included);
/// reports the new uuid like `oakengine_library_create`.
pub fn oakengine_library_duplicate(
uuid: *const c_char,
name: *const c_char,
out_uuid: *mut c_char,
out_size: c_int,
) -> c_int;
/// `oakengine_library_import` — import a `.ove`/`.otio`/`.fcpxml` file
/// as a new row; reports the new uuid like `oakengine_library_create`.
pub fn oakengine_library_import(
path: *const c_char,
out_uuid: *mut c_char,
out_size: c_int,
) -> c_int;
/// `oakengine_library_export` — export a row to `path` (format by
/// extension).
pub fn oakengine_library_export(uuid: *const c_char, path: *const c_char) -> c_int;
/// `oakengine_project_load_library` — load a library row into a fresh
/// project shell (same contract as `oakengine_project_load`); binds the
/// project to the library session.
pub fn oakengine_project_load_library(
self_: *mut OakEngineProject,
uuid: *const c_char,
err: *mut c_char,
err_size: c_int,
) -> c_int;
// -- oakengine::node (project) --
/// `oakengine_project_create` — owned project box (no content yet).
+185 -2
View File
@@ -62,14 +62,60 @@ use gpui_widgets::project_explorer::{ProjectDataSource, ProjectEntry};
use gpui_widgets::viewer::PlaybackClock;
use super::engine::{
AppEngine, EngineGateway, ExportEvent, ExportSession, Monitor, Project, ScopeData, Sequence,
VideoFormat,
AppEngine, EngineGateway, ExportEvent, ExportSession, LibraryProject, Monitor, Project,
ScopeData, Sequence, VideoFormat,
};
use super::transport::TransportState;
/// The demo sequence length: 00:04:18:18 at 25 fps.
const SEQUENCE_LENGTH: i64 = 6468;
/// The current unix time in seconds (0 on clock failure).
fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// The demo library rows the project manager opens with (M13 D4): three
/// projects with plausible stats, most recently modified first.
fn demo_library() -> Vec<LibraryProject> {
let now = now_unix();
vec![
LibraryProject {
uuid: "mock-1".into(),
name: "第一稿".into(),
created_at: now - 86400,
modified_at: now - 300,
duration_ms: SEQUENCE_LENGTH * 1000 / 25,
track_count: 4,
clip_count: 5,
footage_count: 3,
},
LibraryProject {
uuid: "mock-2".into(),
name: "宣传片 v3".into(),
created_at: now - 3 * 86400,
modified_at: now - 86400,
duration_ms: 95_000,
track_count: 6,
clip_count: 14,
footage_count: 8,
},
LibraryProject {
uuid: "mock-3".into(),
name: "采访粗剪".into(),
created_at: now - 9 * 86400,
modified_at: now - 7 * 86400,
duration_ms: 612_000,
track_count: 3,
clip_count: 22,
footage_count: 5,
},
]
}
// ---------------------------------------------------------------------------
// Clocks
// ---------------------------------------------------------------------------
@@ -431,6 +477,19 @@ pub struct MockEngine {
/// has no media pipeline, so it just records them (drives app-level tests
/// of the import flow).
imported_footage: Vec<PathBuf>,
/// The fake project library the project manager browses (M13 D4): an
/// in-memory row set the library trait methods operate on, so the app
/// flow (list / open / create / rename / duplicate / delete / import /
/// export) is testable without a database.
library: Vec<LibraryProject>,
/// Id allocator for library rows created at runtime.
next_library_id: u64,
/// The uuids handed to [`AppEngine::library_open_project`] (test
/// observability).
library_opened: Vec<String>,
/// (uuid, path) pairs handed to [`AppEngine::library_export_project`]
/// (test observability).
library_exported: Vec<(String, PathBuf)>,
}
impl MockEngine {
@@ -674,6 +733,10 @@ impl MockEngine {
node_selection: BTreeSet::new(),
cpu_frame_cache: Mutex::new(HashMap::new()),
imported_footage: Vec::new(),
library: demo_library(),
next_library_id: 100,
library_opened: Vec::new(),
library_exported: Vec::new(),
};
// The demo graph is born connected: derive every port's `connected`
// flag from the edge list.
@@ -1274,6 +1337,114 @@ impl AppEngine for MockEngine {
cx.notify();
}
// --- project library (M13 D4, in-memory fake) -------------------------
fn storage_bound(&self) -> bool {
// The mock pretends the demo project lives in the library.
true
}
fn library_projects(&self) -> Result<Vec<LibraryProject>, String> {
let mut rows = self.library.clone();
rows.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
Ok(rows)
}
fn library_create_project(&mut self, name: &str, cx: &mut Context<Self>) -> Result<(), String> {
let uuid = format!("mock-{}", self.next_library_id);
self.next_library_id += 1;
let now = now_unix();
self.library.push(LibraryProject {
uuid,
name: name.to_string(),
created_at: now,
modified_at: now,
duration_ms: 0,
track_count: 0,
clip_count: 0,
footage_count: 0,
});
self.project.name = name.to_string();
self.project.path = PathBuf::new();
cx.notify();
Ok(())
}
fn library_open_project(&mut self, uuid: &str, cx: &mut Context<Self>) -> Result<(), String> {
let Some(row) = self.library.iter().find(|row| row.uuid == uuid) else {
return Err(format!("library project {uuid} not found"));
};
self.project.name = row.name.clone();
self.project.path = PathBuf::new();
self.library_opened.push(uuid.to_string());
cx.notify();
Ok(())
}
fn library_delete_project(&mut self, uuid: &str) -> Result<(), String> {
let before = self.library.len();
self.library.retain(|row| row.uuid != uuid);
if self.library.len() == before {
return Err(format!("library project {uuid} not found"));
}
Ok(())
}
fn library_rename_project(&mut self, uuid: &str, name: &str) -> Result<(), String> {
let Some(row) = self.library.iter_mut().find(|row| row.uuid == uuid) else {
return Err(format!("library project {uuid} not found"));
};
row.name = name.to_string();
row.modified_at = now_unix();
Ok(())
}
fn library_duplicate_project(&mut self, uuid: &str) -> Result<(), String> {
let Some(row) = self.library.iter().find(|row| row.uuid == uuid).cloned() else {
return Err(format!("library project {uuid} not found"));
};
let now = now_unix();
self.library.push(LibraryProject {
uuid: format!("mock-{}", self.next_library_id),
name: format!("{} (copy)", row.name),
created_at: now,
modified_at: now,
..row
});
self.next_library_id += 1;
Ok(())
}
fn library_import_project(&mut self, path: PathBuf) -> Result<String, String> {
let name = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.filter(|s| !s.is_empty())
.ok_or_else(|| format!("invalid project file \"{}\"", path.display()))?;
let uuid = format!("mock-{}", self.next_library_id);
self.next_library_id += 1;
let now = now_unix();
self.library.push(LibraryProject {
uuid: uuid.clone(),
name,
created_at: now,
modified_at: now,
duration_ms: 0,
track_count: 0,
clip_count: 0,
footage_count: 0,
});
Ok(uuid)
}
fn library_export_project(&mut self, uuid: &str, path: PathBuf) -> Result<(), String> {
if !self.library.iter().any(|row| row.uuid == uuid) {
return Err(format!("library project {uuid} not found"));
}
self.library_exported.push((uuid.to_string(), path));
Ok(())
}
fn start_export(&mut self, _format: i32, _path: PathBuf) -> Result<ExportSession, String> {
// Mock export: fake progress on a background thread, no file.
let (tx, rx) = mpsc::channel::<ExportEvent>();
@@ -1421,6 +1592,18 @@ impl MockEngine {
&self.imported_footage
}
/// The uuids opened via [`AppEngine::library_open_project`] so far
/// (mock state; drives app-level tests of the manager's open flow).
pub fn library_opened(&self) -> &[String] {
&self.library_opened
}
/// The (uuid, path) pairs exported via
/// [`AppEngine::library_export_project`] so far (mock state).
pub fn library_exported(&self) -> &[(String, PathBuf)] {
&self.library_exported
}
/// The selected material-bin entry id (demo state).
pub fn selected_item(&self) -> Option<u64> {
self.selected_item
+2 -2
View File
@@ -53,8 +53,8 @@ pub mod transport;
pub mod waveform;
pub use engine::{
AppEngine, EngineClock, EngineGateway, ExportEvent, ExportSession, Monitor, Project, ScopeData,
Sequence, VideoFormat,
AppEngine, EngineClock, EngineGateway, ExportEvent, ExportSession, LibraryProject, Monitor,
Project, ScopeData, Sequence, VideoFormat,
};
pub use mock::{MockClock, MockEngine};
pub use real::{RealClock, RealEngine};
+223 -2
View File
@@ -101,8 +101,8 @@ use gpui_widgets::project_explorer::{ProjectDataSource, ProjectEntry};
use gpui_widgets::viewer::PlaybackClock;
use super::engine::{
AppEngine, EngineGateway, ExportEvent, ExportSession, Monitor, Project, ScopeData, Sequence,
VideoFormat,
AppEngine, EngineGateway, ExportEvent, ExportSession, LibraryProject, Monitor, Project,
ScopeData, Sequence, VideoFormat,
};
use super::ffi::*;
use super::frames::{f32_rgba_to_bgra_image, synthetic_frame_samples};
@@ -2230,6 +2230,112 @@ impl AppEngine for RealEngine {
cx.notify();
}
// --- project library (M13 D4) --------------------------------------
fn storage_bound(&self) -> bool {
self.project_ptr()
.map(|p| unsafe { oakengine_storage_is_bound(p) } != 0)
.unwrap_or(false)
}
fn storage_last_error(&self) -> Option<String> {
let project = self.project_ptr()?;
let message = read_string(|buf, size| unsafe {
oakengine_storage_last_error(project, buf, size)
});
if message.is_empty() {
None
} else {
Some(message)
}
}
fn library_projects(&self) -> Result<Vec<LibraryProject>, String> {
library_list()
}
fn library_create_project(&mut self, name: &str, cx: &mut Context<Self>) -> Result<(), String> {
let uuid = library_create(name)?;
self.open_library_project(&uuid, cx)
}
fn library_open_project(&mut self, uuid: &str, cx: &mut Context<Self>) -> Result<(), String> {
self.open_library_project(uuid, cx)
}
fn library_delete_project(&mut self, uuid: &str) -> Result<(), String> {
let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?;
let rc = unsafe { oakengine_library_delete(uuid_c.as_ptr()) };
if rc != 0 {
return Err(format!("failed to delete the project (error {rc})"));
}
Ok(())
}
fn library_rename_project(&mut self, uuid: &str, name: &str) -> Result<(), String> {
let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?;
let name_c = CString::new(name).map_err(|_| "invalid name".to_string())?;
let rc = unsafe { oakengine_library_rename(uuid_c.as_ptr(), name_c.as_ptr()) };
if rc != 0 {
return Err(format!("failed to rename the project (error {rc})"));
}
Ok(())
}
fn library_duplicate_project(&mut self, uuid: &str) -> Result<(), String> {
let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?;
let mut buf = [0 as c_char; 256];
// Single call with a stack buffer: the duplicate has a side effect,
// so the two-stage (measure-then-read) pattern must not be used.
let rc = unsafe {
oakengine_library_duplicate(
uuid_c.as_ptr(),
std::ptr::null(),
buf.as_mut_ptr(),
buf.len() as c_int,
)
};
if rc < 0 {
return Err(format!("failed to duplicate the project (error {rc})"));
}
Ok(())
}
fn library_import_project(&mut self, path: PathBuf) -> Result<String, String> {
let path_c = cstr_path(&path).ok_or("invalid import path")?;
let mut buf = [0 as c_char; 256];
// Single call with a stack buffer (side effect; see duplicate).
let rc = unsafe {
oakengine_library_import(path_c.as_ptr(), buf.as_mut_ptr(), buf.len() as c_int)
};
if rc < 0 {
return Err(format!(
"failed to import \"{}\" (error {rc})",
path.display()
));
}
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
Ok(
String::from_utf8_lossy(unsafe {
std::slice::from_raw_parts(buf.as_ptr() as *const u8, len)
})
.into_owned(),
)
}
fn library_export_project(&mut self, uuid: &str, path: PathBuf) -> Result<(), String> {
let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?;
let path_c = cstr_path(&path).ok_or("invalid export path")?;
let rc = unsafe { oakengine_library_export(uuid_c.as_ptr(), path_c.as_ptr()) };
if rc != 0 {
return Err(format!(
"failed to export the project to \"{}\" (error {rc})",
path.display()
));
}
Ok(())
}
fn start_export(&mut self, format: i32, path: PathBuf) -> Result<ExportSession, String> {
let Some(seq) = self.seq_ptr() else {
return Err("no sequence open".into());
@@ -2589,6 +2695,121 @@ pub fn renderer_backends() -> Vec<&'static str> {
vec!["opengl", "metal", "vulkan", "none"]
}
// ---------------------------------------------------------------------------
// Project library (M13 D4: the write-through database the manager browses)
// ---------------------------------------------------------------------------
/// The config key selecting the storage backend (see
/// `crates/oakengine/src/storage.rs`).
pub const CONFIG_KEY_STORAGE_BACKEND: &str = "Storage/Backend";
/// Enables the SQLite write-through library unless the user configured the
/// backend explicitly (any existing value — including "off" — wins over the
/// app's default). The library path defaults facade-side to
/// `<system data directory>/library.db`.
pub fn configure_storage() {
if config_get_string(CONFIG_KEY_STORAGE_BACKEND).is_empty() {
config_set_string(CONFIG_KEY_STORAGE_BACKEND, "sqlite");
}
}
/// Flushes every bound project (write-through + snapshot) and stops the
/// facade's snapshot thread. The app calls this on exit.
pub fn storage_flush() {
unsafe {
oakengine_storage_flush();
}
}
/// The library rows, most recently modified first (the project manager's
/// data source; JSON over the facade's `oakengine_library_list`).
pub fn library_list() -> Result<Vec<LibraryProject>, String> {
let needed = unsafe { oakengine_library_list(std::ptr::null_mut(), 0) };
if needed < 0 {
return Err(format!("failed to list the library (error {needed})"));
}
let mut buf = vec![0 as c_char; needed as usize + 1];
unsafe { oakengine_library_list(buf.as_mut_ptr(), needed + 1) };
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
let json =
String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) })
.into_owned();
let rows: serde_json::Value =
serde_json::from_str(&json).map_err(|e| format!("malformed library list: {e}"))?;
let Some(rows) = rows.as_array() else {
return Err("malformed library list (not an array)".into());
};
Ok(rows
.iter()
.map(|row| {
let s = |key: &str| row.get(key).and_then(|v| v.as_str()).unwrap_or_default().to_string();
let n = |key: &str| row.get(key).and_then(|v| v.as_i64()).unwrap_or(0);
LibraryProject {
uuid: s("uuid"),
name: s("name"),
created_at: n("created_at"),
modified_at: n("modified_at"),
duration_ms: n("duration_ms"),
track_count: n("track_count") as i32,
clip_count: n("clip_count") as i32,
footage_count: n("footage_count") as i32,
}
})
.collect())
}
/// Creates a blank project row in the library; returns its uuid. Single
/// call with a stack buffer: the create has a side effect, so the
/// two-stage (measure-then-read) pattern must not be used.
fn library_create(name: &str) -> Result<String, String> {
let name_c = CString::new(name).map_err(|_| "invalid name".to_string())?;
let mut buf = [0 as c_char; 256];
let rc = unsafe { oakengine_library_create(name_c.as_ptr(), buf.as_mut_ptr(), buf.len() as c_int) };
if rc < 0 {
return Err(format!("failed to create the project (error {rc})"));
}
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
Ok(
String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) })
.into_owned(),
)
}
impl RealEngine {
/// Opens the library row `uuid` through the facade's library-load path
/// (which binds the project to the write-through session) and adopts it.
fn open_library_project(&mut self, uuid: &str, cx: &mut Context<Self>) -> Result<(), String> {
let project = unsafe { oakengine_project_create() };
if project.is_null() {
return Err("failed to create a project".into());
}
let uuid_c = CString::new(uuid).map_err(|_| "invalid uuid".to_string())?;
let mut err = [0 as c_char; 4096];
let rc = unsafe {
oakengine_project_load_library(
project,
uuid_c.as_ptr(),
err.as_mut_ptr(),
err.len() as c_int,
)
};
if rc != 0 {
let message = load_error(&mut err);
unsafe { oakengine_project_free(project) };
return Err(format!("failed to open the library project: {message}"));
}
self.adopt_project(project, cx);
// The facade's project name is filename-derived ("(untitled)" for a
// library row); display the library row name instead.
if let Ok(rows) = library_list() {
if let Some(row) = rows.iter().find(|row| row.uuid == uuid) {
self.project_info.name = row.name.clone();
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
+31 -4
View File
@@ -14,9 +14,11 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The global status bar (状态栏): ready state, cache, proxy and autosave
//! info on the left; current timecode / duration, frame rate and resolution
//! on the right.
//! The global status bar (状态栏): ready state, cache, proxy and the
//! library write state (M13 D4: the write-through replaces the manual save,
//! so the old autosave hint becomes "written to the library / library off /
//! write failed") on the left; current timecode / duration, frame rate and
//! resolution on the right.
use gpui::colors::DefaultColors;
use gpui::timeline::Frame;
@@ -66,6 +68,25 @@ impl<E: AppEngine> Render for StatusBar<E> {
div().px_2().py_1().text_color(colors.text).child(text)
};
// The write-through state (M13 D4): a bound project with no recorded
// error is written through; an error turns the segment red.
let (storage_text, storage_color) = if engine.storage_last_error().is_some() {
(
crate::i18n::tr("status.storage.error"),
gpui::rgba(0xcc6666ff),
)
} else if engine.storage_bound() {
(
crate::i18n::tr("status.storage.written"),
colors.disabled,
)
} else {
(
crate::i18n::tr("status.storage.unbound"),
colors.disabled,
)
};
div()
.h_6()
.flex()
@@ -77,7 +98,13 @@ impl<E: AppEngine> Render for StatusBar<E> {
.child(segment(&colors, crate::i18n::tr("status.ready").into()))
.child(segment(&colors, crate::i18n::tr("status.cache").into()))
.child(segment(&colors, crate::i18n::tr("status.proxy").into()))
.child(segment(&colors, crate::i18n::tr("status.autosave").into()))
.child(
div()
.px_2()
.py_1()
.text_color(storage_color)
.child(storage_text),
)
.child(div().flex_1())
.child(segment(
&colors,