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
Generated
+1
View File
@@ -4744,6 +4744,7 @@ dependencies = [
"gpui_widgets",
"image",
"oakengine",
"serde_json",
"smallvec",
]
+3
View File
@@ -82,6 +82,9 @@ smallvec = "1"
# Editable-text widget (used by the file / export dialogs' path fields, the
# same gpui-elements crate gpui_widgets builds on).
gpui_elements = { path = "gpui/crates/gpui_elements" }
# The library list crosses the facade C ABI as JSON
# (`oakengine_library_list`, M13 D4); Value-only parsing, no derive.
serde_json = "1"
[build-dependencies]
# The real engine is NOT linked as an rlib: the app binds only the frozen
+35 -2
View File
@@ -454,10 +454,43 @@ mod tests {
fn output_callback_consumes_pushed_samples() {
// Skip when the audio system cannot actually run a stream: open a
// silent stream and require at least one callback within 2 s. A
// device existing is not enough — headless sessions report
// is_active=true while delivering zero callbacks.
// device existing is not enough — headless sessions report the
// stream running while delivering zero callbacks.
use std::sync::atomic::AtomicI64 as A;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
static PROBE: A = A::new(0);
PROBE.store(0, Ordering::Relaxed);
let can_play = (|| {
let host = cpal::default_host();
let device = host.default_output_device()?;
let config = device.default_output_config().ok()?;
let cb = move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
PROBE.fetch_add(data.len() as i64, Ordering::Relaxed);
for s in data.iter_mut() {
*s = 0.0;
}
};
let stream = device
.build_output_stream(
config.config(),
cb,
|_| {},
None,
)
.ok()?;
stream.play().ok()?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while std::time::Instant::now() < deadline && PROBE.load(Ordering::Relaxed) == 0 {
std::thread::sleep(std::time::Duration::from_millis(50));
}
let got = PROBE.load(Ordering::Relaxed) > 0;
drop(stream);
Some(got)
})();
if can_play != Some(true) {
eprintln!("audio session cannot deliver callbacks; skipping");
return;
}
DESTROYED.store(false, Ordering::SeqCst);
let manager = MANAGER.get_or_init(|| Mutex::new(ManagerInner::default()));
+1
View File
@@ -74,6 +74,7 @@ pub mod handle;
pub mod ipc;
#[cfg(not(test))]
pub mod linkage;
pub mod library;
pub mod node;
pub mod plugin;
pub mod pods;
+368
View File
@@ -0,0 +1,368 @@
// 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-library manager C ABI (plan M13 §4): list / create / open /
//! rename / duplicate / delete / import / export over the oakstorage
//! database backend the write-through binds to ([`crate::storage`]).
//!
//! These exports are additive (D4): the app talks to the engine dylib only
//! through the frozen `oakengine_*` surface, so the manager's data source
//! crosses the boundary here instead of linking oakstorage directly (which
//! would give the app a second copy of the handle/serializer types).
//!
//! All operations address the configured default library (the same
//! `Storage/Backend` + `Storage/SqlitePath` configuration the write-through
//! uses); with storage disabled every call fails with `OAKENGINE_E_STATE`
//! except [`oakengine_library_list`], which reports an empty library
//! (`"[]"`) so the manager window can still open.
use std::ffi::{c_char, c_int};
use oakstorage::backend::StorageBackend;
use oakstorage::uri::StorageUri;
use crate::error::{Error, Result};
use crate::handle::{guard, guard_int, read_cstr, write_string, OakEngineProject};
use crate::stubs::node as n;
/// One library row as the project manager shows it: the project metadata
/// plus the stats derived from the head state (plan §4).
#[derive(serde::Serialize)]
struct LibraryRow {
/// Library row uuid (the open/duplicate/export selector).
uuid: String,
/// Display name.
name: String,
/// Row creation time (unix seconds, UTC).
created_at: i64,
/// Last-write time (unix seconds, UTC; the manager sort key).
modified_at: i64,
/// Longest sequence duration, milliseconds.
duration_ms: i64,
/// Total tracks across all sequences.
track_count: i32,
/// Total clip blocks.
clip_count: i32,
/// Total footage nodes.
footage_count: i32,
}
/// Map an oakstorage error onto the facade error space (the context string
/// is log-only per the error contract).
fn map_err(e: oakstorage::error::Error) -> Error {
use oakstorage::error::Error as E;
match e {
E::Invalid => Error::Invalid,
E::State => Error::State,
E::NotFound => Error::NotFound,
E::NoMem => Error::NoMem,
other => Error::Failed(other.to_string()),
}
}
/// The configured default library as a parsed URI; [`Error::State`] when
/// the write-through backend is disabled or the path does not resolve.
fn library() -> Result<StorageUri> {
if !crate::storage::storage_enabled() {
return Err(Error::State);
}
let uri = crate::storage::library_uri().ok_or(Error::State)?;
StorageUri::parse(&uri).map_err(map_err)
}
/// The library URI selecting one row (`…?project=<uuid>`).
fn project_uri(uuid: &str) -> Result<StorageUri> {
let uri = library()?;
StorageUri::parse(&format!("{}?project={uuid}", uri.to_uri_string())).map_err(map_err)
}
/// Load one library row as an owned project handle (refcount 1).
fn load_handle(uuid: &str) -> Result<crate::handle::CHandle> {
let uri = project_uri(uuid)?;
let result = crate::storage::backend().load(&uri).map_err(map_err)?;
if result.project.is_null() {
return Err(Error::Failed(format!(
"library load of {uuid} returned no project (info code {})",
result.version_info
)));
}
Ok(result.project)
}
/// Release an owned handle (refcount 1).
fn release(h: crate::handle::CHandle) {
if let Some(release) = h.release {
unsafe { release(h.ctx) };
}
}
/// `oakengine_library_list` — the library rows as a JSON array (buf/size
/// convention), most recently modified first. Each row carries the manager
/// stats derived from the head state; a row whose stats fail to replay
/// degrades to zeros instead of failing the whole list. With storage
/// disabled the result is the empty array (`"[]"`), not an error.
#[no_mangle]
pub unsafe extern "C" fn oakengine_library_list(buf: *mut c_char, buf_size: c_int) -> c_int {
guard_int(|| unsafe {
if !crate::storage::storage_enabled() {
return Ok(write_string("[]", buf, buf_size));
}
let uri = library()?;
let infos = crate::storage::backend()
.list_projects(&uri)
.map_err(map_err)?;
let mut rows = Vec::with_capacity(infos.len());
for info in infos {
let stats = crate::storage::backend()
.project_stats(&uri, &info.uuid)
.unwrap_or_default();
rows.push(LibraryRow {
uuid: info.uuid,
name: info.name,
created_at: info.created_at.and_utc().timestamp(),
modified_at: info.modified_at.and_utc().timestamp(),
duration_ms: stats.duration_ms,
track_count: stats.track_count,
clip_count: stats.clip_count,
footage_count: stats.footage_count,
});
}
let json = serde_json::to_string(&rows)
.map_err(|e| Error::Failed(format!("library list encode: {e}")))?;
Ok(write_string(&json, buf, buf_size))
})
}
/// `oakengine_library_create` — create a blank project named `name` as a
/// new library row and report its uuid (buf/size convention on
/// `out_uuid`; the return value is the uuid length, negative on error).
/// The row lands immediately (one `kind='import'` command), so the
/// manager list shows it before the first edit.
#[no_mangle]
pub unsafe extern "C" fn oakengine_library_create(
name: *const c_char,
out_uuid: *mut c_char,
out_size: c_int,
) -> c_int {
guard_int(|| unsafe {
if name.is_null() {
return Err(Error::Invalid);
}
let name = read_cstr(name);
if name.trim().is_empty() {
return Err(Error::Invalid);
}
let uri = library()?;
let mut h = n::oaknode_project_init();
if h.is_null() {
return Err(Error::NoMem);
}
let outcome = (|| -> Result<String> {
Error::from_module(n::oaknode_project_initialize(h))?;
let uuid = {
let arc = crate::handle::domain::project_of(&h).ok_or(Error::Invalid)?;
let mut guard = arc.lock().unwrap_or_else(|e| e.into_inner());
guard.settings.insert("projectname".to_string(), name);
guard.uuid.clone()
};
crate::storage::backend().save(h, &uri, 0).map_err(map_err)?;
Ok(uuid)
})();
n::oaknode_project_free(&mut h);
let uuid = outcome?;
Ok(write_string(&uuid, out_uuid, out_size))
})
}
/// `oakengine_library_delete` — delete the library row `uuid` (cascades
/// settings / snapshots / journal; `OAKENGINE_E_NOT_FOUND` when absent).
/// The manager confirms with the user before calling.
#[no_mangle]
pub unsafe extern "C" fn oakengine_library_delete(uuid: *const c_char) -> c_int {
guard(|| unsafe {
if uuid.is_null() {
return Err(Error::Invalid);
}
let uuid = read_cstr(uuid);
if uuid.is_empty() {
return Err(Error::Invalid);
}
crate::storage::backend()
.delete_project(&library()?, &uuid)
.map_err(map_err)
})
}
/// `oakengine_library_rename` — rename the library row `uuid` (the
/// manager's list name; the in-project `projectname` setting is
/// untouched).
#[no_mangle]
pub unsafe extern "C" fn oakengine_library_rename(
uuid: *const c_char,
name: *const c_char,
) -> c_int {
guard(|| unsafe {
if uuid.is_null() || name.is_null() {
return Err(Error::Invalid);
}
let (uuid, name) = (read_cstr(uuid), read_cstr(name));
if uuid.is_empty() || name.trim().is_empty() {
return Err(Error::Invalid);
}
crate::storage::backend()
.rename_project(&library()?, &uuid, name.trim())
.map_err(map_err)
})
}
/// `oakengine_library_duplicate` — copy the library row `uuid` (settings,
/// snapshots and the full journal history included) under a fresh uuid,
/// reporting the new row's uuid (buf/size convention on `out_uuid`; the
/// return value is the uuid length, negative on error).
/// `name` is the copy's display name; NULL/empty defaults to
/// `<name> (copy)`.
#[no_mangle]
pub unsafe extern "C" fn oakengine_library_duplicate(
uuid: *const c_char,
name: *const c_char,
out_uuid: *mut c_char,
out_size: c_int,
) -> c_int {
guard_int(|| unsafe {
if uuid.is_null() {
return Err(Error::Invalid);
}
let uuid = read_cstr(uuid);
if uuid.is_empty() {
return Err(Error::Invalid);
}
let name = read_cstr(name);
let name = match name.trim() {
"" => None,
trimmed => Some(trimmed),
};
let info = crate::storage::backend()
.duplicate_project(&library()?, &uuid, name)
.map_err(map_err)?;
Ok(write_string(&info.uuid, out_uuid, out_size))
})
}
/// `oakengine_library_import` — import a `.ove` / `.otio` / `.fcpxml`
/// project file as a new library row (the file backend parses it, a fresh
/// uuid is assigned, and the first save journals the whole project as one
/// `kind='import'` command). Reports the new row's uuid (buf/size
/// convention on `out_uuid`; the return value is the uuid length,
/// negative on error).
#[no_mangle]
pub unsafe extern "C" fn oakengine_library_import(
path: *const c_char,
out_uuid: *mut c_char,
out_size: c_int,
) -> c_int {
guard_int(|| unsafe {
if path.is_null() {
return Err(Error::Invalid);
}
let path = read_cstr(path);
if path.is_empty() {
return Err(Error::Invalid);
}
let file_uri = StorageUri::parse(&path).map_err(map_err)?;
let uuid = crate::storage::backend()
.import_from_file(&library()?, &file_uri)
.map_err(map_err)?;
Ok(write_string(&uuid, out_uuid, out_size))
})
}
/// `oakengine_library_export` — export the library row `uuid` to the file
/// `path`; the format is dispatched by extension through the oakstorage
/// registry (`.ove` / `.ovexml` → ove-xml, `.otio` / `.fcpxml` → the
/// interchange backend). Nothing is written back to the library.
#[no_mangle]
pub unsafe extern "C" fn oakengine_library_export(
uuid: *const c_char,
path: *const c_char,
) -> c_int {
guard(|| unsafe {
if uuid.is_null() || path.is_null() {
return Err(Error::Invalid);
}
let (uuid, path) = (read_cstr(uuid), read_cstr(path));
if uuid.is_empty() || path.is_empty() {
return Err(Error::Invalid);
}
let file_uri = StorageUri::parse(&path).map_err(map_err)?;
if file_uri.scheme != "file" {
return Err(Error::Invalid);
}
let handle = load_handle(&uuid)?;
let backend = oakstorage::registry::Registry::global()
.resolve(&file_uri)
.map_err(map_err)?;
let result = backend.save(handle, &file_uri, 0).map_err(map_err);
release(handle);
result
})
}
/// `oakengine_project_load_library` — load the library row `uuid` into a
/// fresh project shell (same contract as `oakengine_project_load`: the
/// shell must carry no content). On success the undo stack is cleared, the
/// modified flag is reset, and the project is bound to the library session
/// (the write-through continues the row's journal from its head seq).
#[no_mangle]
pub unsafe extern "C" fn oakengine_project_load_library(
self_: *mut OakEngineProject,
uuid: *const c_char,
err: *mut c_char,
err_size: c_int,
) -> c_int {
guard(|| unsafe {
if self_.is_null() || uuid.is_null() {
return Err(Error::Invalid);
}
let h = crate::handle::unbox(self_)?;
if !n::oaknode_project_root(h).is_null() {
return Err(Error::State);
}
let uuid = read_cstr(uuid);
if uuid.is_empty() {
return Err(Error::Invalid);
}
let loaded = match load_handle(&uuid) {
Ok(handle) => handle,
Err(e) => {
write_string(&e.to_string(), err, err_size);
return Err(e);
}
};
// Swap the loaded content into the caller's shell box, releasing
// the empty shell handle the box was created with.
let mut old = (*self_).handle;
(*self_).handle = loaded;
n::oaknode_project_free(&mut old);
crate::undo::oakengine_undo_clear();
Error::from_module(n::oaknode_project_set_modified(loaded, 0))?;
crate::storage::bind_project(loaded);
if !err.is_null() && err_size > 0 {
*err = 0;
}
Ok(())
})
}
+94 -17
View File
@@ -41,9 +41,9 @@
//! The library is selected from the `Storage` config group (all defaults
//! are config-driven, plan §5):
//!
//! - `Storage/Backend` — `"sqlite"` (the documented default value) or
//! `"database"` enable the write-through; any other value (e.g.
//! `"off"`) disables it. When the key is absent, no library is
//! - `Storage/Backend` — `"sqlite"` (the documented default value),
//! `"database"` or `"pg"` enable the write-through; any other value
//! (e.g. `"off"`) disables it. When the key is absent, no library is
//! configured: projects stay unbound and the undo path runs without
//! touching a database (graceful degradation — this is what keeps
//! headless consumers and the test suite from writing to the user's
@@ -52,6 +52,12 @@
//! storage is enabled) `<system data directory>/library.db` (the same
//! location `FileFunctions::get_configuration_location` derives,
//! honoring `OAK_CONFIG_DIR` and portable mode).
//! - `Storage/PgUrl` — the PostgreSQL connection string (plan D3), used
//! when `Storage/Backend` is `"pg"`: `user:pass@host:5432/dbname`
//! (libpq URL form; an optional `postgres://`/`postgresql://` scheme is
//! accepted and stripped). The resolved library URI is
//! `oakdb+pg://<PgUrl>`. When `Backend = "pg"` but `PgUrl` is absent
//! or empty, no library is configured (same graceful degradation).
//!
//! ## Snapshot thread and exit flush
//!
@@ -101,9 +107,9 @@ struct Binding {
}
/// The process-wide oakstorage backend (shared by the UI thread's
/// write-throughs and the snapshot thread; the backend serializes its own
/// operations).
fn backend() -> &'static DatabaseBackend {
/// write-throughs, the snapshot thread and the library manager exports in
/// [`crate::library`]; the backend serializes its own operations).
pub(crate) fn backend() -> &'static DatabaseBackend {
static BACKEND: OnceLock<DatabaseBackend> = OnceLock::new();
BACKEND.get_or_init(DatabaseBackend::new)
}
@@ -291,18 +297,18 @@ fn record_error(key: usize, message: &str) {
// ---------------------------------------------------------------------------
/// Whether the write-through backend is enabled (config-driven, plan §5):
/// `Storage/Backend` set to `"sqlite"` or `"database"` enables it; any
/// other explicit value (e.g. `"off"`) disables it. When NO `Storage`
/// configuration is present the backend is NOT enabled — "no library
/// configured" degrades gracefully to plain unbound projects, which keeps
/// headless consumers (the CLI) and the test suite from ever writing to
/// the user's default library. The documented default *values* are
/// `Backend = "sqlite"` and `SqlitePath = <system data dir>/library.db`
/// `Storage/Backend` set to `"sqlite"`, `"database"` or `"pg"` enables
/// it; any other explicit value (e.g. `"off"`) disables it. When NO
/// `Storage` configuration is present the backend is NOT enabled — "no
/// library configured" degrades gracefully to plain unbound projects,
/// which keeps headless consumers (the CLI) and the test suite from ever
/// writing to the user's default library. The documented default *values*
/// are `Backend = "sqlite"` and `SqlitePath = <system data dir>/library.db`
/// (used once storage is enabled without an explicit path).
pub(crate) fn storage_enabled() -> bool {
let store = oakcommon::configstore::ConfigStore::instance();
match store.get(Some("Storage"), "Backend") {
Ok(b) => b == "sqlite" || b == "database",
Ok(b) => b == "sqlite" || b == "database" || b == "pg",
Err(_) => false,
}
}
@@ -316,6 +322,29 @@ fn configured_sqlite_path() -> Option<String> {
}
}
/// The configured PostgreSQL connection string (`Storage/PgUrl`; empty
/// value = not configured).
fn configured_pg_url() -> Option<String> {
let store = oakcommon::configstore::ConfigStore::instance();
match store.get(Some("Storage"), "PgUrl") {
Ok(u) if !u.trim().is_empty() => Some(u.trim().to_string()),
_ => None,
}
}
/// The `oakdb+pg://…` uri of the configured PostgreSQL library (None
/// when `Storage/PgUrl` is absent). A `postgres://`/`postgresql://`
/// scheme on the config value is stripped — the oakdb uri body is the
/// bare connection string (`user:pass@host:5432/dbname`).
fn pg_library_uri() -> Option<String> {
let url = configured_pg_url()?;
let body = url
.strip_prefix("postgres://")
.or_else(|| url.strip_prefix("postgresql://"))
.unwrap_or(&url);
Some(format!("oakdb+pg://{body}"))
}
/// The default library file: `<system data directory>/library.db`, where
/// the data directory is the standard per-user location
/// (`FileFunctions::get_configuration_location`: macOS Application
@@ -327,9 +356,14 @@ pub(crate) fn default_library_path() -> String {
format!("{}/library.db", dir)
}
/// The `oakdb+sqlite://…` uri of the configured library (None when the
/// path cannot be made absolute).
fn library_uri() -> Option<String> {
/// The `oakdb+…` uri of the configured library (None when the path
/// cannot be made absolute, or the PG url is missing). Shared with the
/// library manager exports in [`crate::library`].
pub(crate) fn library_uri() -> Option<String> {
let store = oakcommon::configstore::ConfigStore::instance();
if store.get(Some("Storage"), "Backend").ok().as_deref() == Some("pg") {
return pg_library_uri();
}
let path = match configured_sqlite_path() {
Some(p) => p,
None => default_library_path(),
@@ -580,4 +614,47 @@ mod tests {
assert!(!storage_enabled());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn library_uri_resolves_pg_config() {
use oakcommon::configstore::ConfigStore;
let store = ConfigStore::instance();
let _g = crate::tests::common::STORAGE_CONFIG_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
// Backend = "pg" yields an oakdb+pg uri from Storage/PgUrl; a
// postgres:// scheme on the config value is stripped.
store.set(Some("Storage"), "Backend", "pg");
store.set(
Some("Storage"),
"PgUrl",
"postgres://user:pass@host:5432/oak",
);
assert!(storage_enabled());
assert_eq!(
library_uri().as_deref(),
Some("oakdb+pg://user:pass@host:5432/oak")
);
// postgresql:// is accepted too.
store.set(
Some("Storage"),
"PgUrl",
"postgresql://u@h/db?sslmode=disable",
);
assert_eq!(
library_uri().as_deref(),
Some("oakdb+pg://u@h/db?sslmode=disable")
);
// Backend = "pg" with no PgUrl = no library (graceful
// degradation, same as an absent sqlite path).
store.set(Some("Storage"), "PgUrl", "");
assert_eq!(library_uri(), None);
// Leave the store in a safe state.
store.set(Some("Storage"), "Backend", "off");
assert!(!storage_enabled());
}
}
@@ -0,0 +1,426 @@
// 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/>.
//! D4 integration tests: the project-library manager C ABI
//! (`src/library.rs`, plan M13 §4).
//!
//! End-to-end against real SQLite library files in temp directories,
//! driving the facade exactly like the app's project manager: create lands
//! a row immediately, list reports it with the derived stats, open
//! (`oakengine_project_load_library`) binds the loaded project to the
//! library session (the next undoable edit write-throughs onto the row's
//! journal), rename / duplicate / delete / import / export round-trip, and
//! the disabled-backend configuration degrades to an empty list + error
//! codes.
//!
//! Every test holds the shared undo-stack lock (the facade's stack is
//! process-wide, same as the it_undo / it_storage families) and the
//! storage-config lock, so the suite never races on either singleton.
use std::ffi::CString;
use std::path::{Path, PathBuf};
use super::common;
use super::it_undo::GLOBAL_STACK_LOCK;
use crate::error::{OAKENGINE_OK, OAKENGINE_E_INVALID, OAKENGINE_E_NOT_FOUND, OAKENGINE_E_STATE};
use crate::handle::OakEngineProject;
/// The math node type id (a factory type the tests add as an undoable
/// edit; footage is not factory-creatable, so the write-through is
/// verified on the journal rows directly).
const MATH: &str = "org.olivevideoeditor.Olive.math";
/// The journal row count of a library row (direct sea-orm read, the same
/// pattern as the it_storage tests).
fn journal_rows(db: &Path, uuid: &str) -> usize {
use sea_orm::entity::prelude::*;
use oakstorage::backends::database::entities::{journal, project};
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
let conn = sea_orm::Database::connect(format!("sqlite://{}?mode=ro", db.display()))
.await
.unwrap();
let model = project::Entity::find()
.filter(project::Column::Uuid.eq(uuid))
.one(&conn)
.await
.unwrap()
.expect("the row exists");
journal::Entity::find()
.filter(journal::Column::ProjectId.eq(model.id))
.all(&conn)
.await
.unwrap()
.len()
})
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Serialize a library test: hold the process-global undo-stack lock AND
/// the storage-config lock for the whole body, then point the library at a
/// temp SQLite file.
fn with_library<R>(db: &Path, f: impl FnOnce() -> R) -> R {
let _stack = GLOBAL_STACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _config = common::STORAGE_CONFIG_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let store = oakcommon::configstore::ConfigStore::instance();
store.set(Some("Storage"), "Backend", "sqlite");
store.set(Some("Storage"), "SqlitePath", &db.to_string_lossy());
f()
}
/// A fresh, unique temp directory for one test.
fn temp_dir(tag: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("oakengine_library_{}_{}", std::process::id(), tag));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Two-stage string read over a facade `(buf, size)` getter.
fn read_string(f: impl Fn(*mut std::ffi::c_char, i32) -> i32) -> String {
let needed = f(std::ptr::null_mut(), 0);
if needed <= 0 {
return String::new();
}
let mut buf = vec![0 as std::ffi::c_char; needed as usize + 1];
f(buf.as_mut_ptr(), needed + 1);
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) })
.into_owned()
}
/// The library list JSON.
fn list_json() -> String {
read_string(|buf, size| unsafe { crate::library::oakengine_library_list(buf, size) })
}
/// Create a library row; returns its uuid. The create export has a side
/// effect, so it is called ONCE with a stack buffer (never two-stage).
fn create(name: &str) -> String {
let name = CString::new(name).unwrap();
let mut buf = [0 as std::ffi::c_char; 256];
let rc = unsafe { crate::library::oakengine_library_create(name.as_ptr(), buf.as_mut_ptr(), 256) };
assert!(rc > 0, "create {name:?} rc={rc}");
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) })
.into_owned()
}
/// Duplicate a library row (single call with a stack buffer, see
/// [`create`]).
fn duplicate(uuid: &str) -> String {
let uuid = CString::new(uuid).unwrap();
let mut buf = [0 as std::ffi::c_char; 256];
let rc = unsafe {
crate::library::oakengine_library_duplicate(
uuid.as_ptr(),
std::ptr::null(),
buf.as_mut_ptr(),
256,
)
};
assert!(rc > 0, "duplicate rc={rc}");
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) })
.into_owned()
}
/// The uuids in the library list JSON (order preserved).
fn list_uuids(json: &str) -> Vec<String> {
serde_json::from_str::<serde_json::Value>(json)
.expect("list is JSON")
.as_array()
.expect("list is an array")
.iter()
.map(|row| {
row.get("uuid")
.and_then(|v| v.as_str())
.expect("row uuid")
.to_string()
})
.collect()
}
/// One row of the library list JSON by uuid.
fn list_row<'a>(json: &'a str, uuid: &str) -> Option<serde_json::Value> {
serde_json::from_str::<serde_json::Value>(json)
.expect("list is JSON")
.as_array()
.expect("list is an array")
.iter()
.find(|row| row.get("uuid").and_then(|v| v.as_str()) == Some(uuid))
.cloned()
}
/// Open a library row into a fresh facade project shell.
fn open(uuid: &str) -> *mut OakEngineProject {
let project = unsafe { crate::node::oakengine_project_create() };
assert!(!project.is_null());
let uuid_c = CString::new(uuid).unwrap();
let mut err = [0 as std::ffi::c_char; 4096];
let rc = unsafe {
crate::library::oakengine_project_load_library(
project,
uuid_c.as_ptr(),
err.as_mut_ptr(),
err.len() as i32,
)
};
assert_eq!(rc, OAKENGINE_OK, "open {uuid}");
project
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
/// Create lands a row immediately; list reports it with the metadata and
/// the (zero) stats.
#[test]
fn create_then_list_shows_the_row() {
let dir = temp_dir("create");
with_library(&dir.join("lib.db"), || {
let uuid = create("Demo Reel");
assert!(!uuid.is_empty(), "create reports the new uuid");
let json = list_json();
let row = list_row(&json, &uuid).expect("the created row is listed");
assert_eq!(row.get("name").and_then(|v| v.as_str()), Some("Demo Reel"));
assert!(row.get("modified_at").and_then(|v| v.as_i64()).unwrap() > 0);
assert_eq!(row.get("track_count").and_then(|v| v.as_i64()), Some(0));
assert_eq!(row.get("footage_count").and_then(|v| v.as_i64()), Some(0));
// A second create adds a second row.
let other = create("Second");
assert_ne!(uuid, other);
assert_eq!(list_uuids(&list_json()).len(), 2);
});
let _ = std::fs::remove_dir_all(&dir);
}
/// Opening a library row binds the project to the library session: the
/// next undoable edit write-throughs onto the row's journal.
#[test]
fn open_binds_and_write_through_advances_the_row() {
let dir = temp_dir("open");
let db = dir.join("lib.db");
with_library(&db, || {
let uuid = create("Editable");
let before = journal_rows(&db, &uuid);
let project = open(&uuid);
assert_eq!(
unsafe { crate::storage::oakengine_storage_is_bound(project) },
1,
"the library-opened project is bound"
);
// An undoable edit write-throughs.
let node = unsafe {
crate::node::oakengine_project_add_node(project, CString::new(MATH).unwrap().as_ptr())
};
assert!(!node.is_null());
unsafe { crate::node::oakengine_node_free(node) };
let after = journal_rows(&db, &uuid);
assert!(
after > before,
"the edit journaled new rows ({before} -> {after})"
);
// The row name comes from the projectname setting (the facade's
// project name is filename-derived, so a library project displays
// "(untitled)"; the app overrides it with the row name).
let row = list_row(&list_json(), &uuid).expect("row after the edit");
assert_eq!(row.get("name").and_then(|v| v.as_str()), Some("Editable"));
unsafe { crate::node::oakengine_project_free(project) };
});
let _ = std::fs::remove_dir_all(&dir);
}
/// Rename and duplicate keep the list coherent; delete removes the row and
/// opening it afterwards fails.
#[test]
fn rename_duplicate_delete() {
let dir = temp_dir("rdd");
with_library(&dir.join("lib.db"), || {
let uuid = create("Original");
// Rename.
let rc = unsafe {
crate::library::oakengine_library_rename(
CString::new(uuid.clone()).unwrap().as_ptr(),
CString::new("Renamed").unwrap().as_ptr(),
)
};
assert_eq!(rc, OAKENGINE_OK);
let row = list_row(&list_json(), &uuid).expect("renamed row");
assert_eq!(row.get("name").and_then(|v| v.as_str()), Some("Renamed"));
// Duplicate (default "<name> (copy)" name).
let copy = duplicate(&uuid);
assert_ne!(copy, uuid);
let row = list_row(&list_json(), &copy).expect("the copy is listed");
assert_eq!(
row.get("name").and_then(|v| v.as_str()),
Some("Renamed (copy)")
);
// The copy opens (its journal history came along).
let project = open(&copy);
unsafe { crate::node::oakengine_project_free(project) };
// Delete the copy; opening it afterwards fails.
let rc = unsafe {
crate::library::oakengine_library_delete(CString::new(copy.clone()).unwrap().as_ptr())
};
assert_eq!(rc, OAKENGINE_OK);
assert!(!list_uuids(&list_json()).contains(&copy));
let shell = unsafe { crate::node::oakengine_project_create() };
let rc = unsafe {
crate::library::oakengine_project_load_library(
shell,
CString::new(copy).unwrap().as_ptr(),
std::ptr::null_mut(),
0,
)
};
assert_eq!(rc, OAKENGINE_E_NOT_FOUND, "a deleted row does not open");
unsafe { crate::node::oakengine_project_free(shell) };
// Unknown uuids are E_NOT_FOUND; empty arguments E_INVALID.
let rc = unsafe {
crate::library::oakengine_library_delete(CString::new("{no-such}").unwrap().as_ptr())
};
assert_eq!(rc, OAKENGINE_E_NOT_FOUND);
let rc = unsafe { crate::library::oakengine_library_delete(c"".as_ptr()) };
assert_eq!(rc, OAKENGINE_E_INVALID);
});
let _ = std::fs::remove_dir_all(&dir);
}
/// Export writes the row's head state to a file (dispatched by extension),
/// and import brings a file back as a new library row that opens.
#[test]
fn export_then_import_round_trip() {
let dir = temp_dir("xport");
with_library(&dir.join("lib.db"), || {
// A row with one node, so the exported file has content.
let uuid = create("Exchange");
let project = open(&uuid);
let node = unsafe {
crate::node::oakengine_project_add_node(project, CString::new(MATH).unwrap().as_ptr())
};
assert!(!node.is_null());
unsafe { crate::node::oakengine_node_free(node) };
unsafe { crate::node::oakengine_project_free(project) };
// Export as .ove and as .otio.
let ove = dir.join("out.ove");
let otio = dir.join("out.otio");
for path in [&ove, &otio] {
let rc = unsafe {
crate::library::oakengine_library_export(
CString::new(uuid.clone()).unwrap().as_ptr(),
CString::new(path.to_string_lossy().into_owned()).unwrap().as_ptr(),
)
};
assert_eq!(rc, OAKENGINE_OK, "export {}", path.display());
assert!(path.exists(), "{} exists", path.display());
}
let xml = std::fs::read_to_string(&ove).unwrap();
assert!(xml.contains("<project"), "ove payload:\n{xml}");
// Import the .ove back as a new row and open it (single call with a
// stack buffer — the import export has a side effect).
let path_c = CString::new(ove.to_string_lossy().into_owned()).unwrap();
let mut buf = [0 as std::ffi::c_char; 256];
let rc = unsafe {
crate::library::oakengine_library_import(path_c.as_ptr(), buf.as_mut_ptr(), 256)
};
assert!(rc > 0, "import rc={rc}");
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
let imported =
String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) })
.into_owned();
assert!(!imported.is_empty(), "import reports the new uuid");
assert_ne!(imported, uuid, "import assigns a fresh uuid");
let project = open(&imported);
// The projectname setting round-trips into the imported row's name.
let row = list_row(&list_json(), &imported).expect("the imported row is listed");
assert_eq!(row.get("name").and_then(|v| v.as_str()), Some("Exchange"));
unsafe { crate::node::oakengine_project_free(project) };
// Importing a missing file fails.
let rc = unsafe {
crate::library::oakengine_library_import(
CString::new(dir.join("nope.ove").to_string_lossy().into_owned())
.unwrap()
.as_ptr(),
std::ptr::null_mut(),
0,
)
};
assert!(rc < 0, "a missing file does not import");
});
let _ = std::fs::remove_dir_all(&dir);
}
/// With the backend disabled the list is empty (not an error) and every
/// mutating call fails with E_STATE.
#[test]
fn disabled_backend_degrades_gracefully() {
let _stack = GLOBAL_STACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _off = common::storage_off_guard();
assert_eq!(list_json(), "[]", "no library configured reads as empty");
let rc = unsafe {
crate::library::oakengine_library_create(
c"Nope".as_ptr(),
std::ptr::null_mut(),
0,
)
};
assert_eq!(rc, OAKENGINE_E_STATE);
let rc = unsafe { crate::library::oakengine_library_delete(c"{x}".as_ptr()) };
assert_eq!(rc, OAKENGINE_E_STATE);
let rc = unsafe { crate::library::oakengine_library_export(c"{x}".as_ptr(), c"/tmp/x.ove".as_ptr()) };
assert_eq!(rc, OAKENGINE_E_STATE);
let shell = unsafe { crate::node::oakengine_project_create() };
let rc = unsafe {
crate::library::oakengine_project_load_library(
shell,
c"{x}".as_ptr(),
std::ptr::null_mut(),
0,
)
};
assert_eq!(rc, OAKENGINE_E_STATE);
unsafe { crate::node::oakengine_project_free(shell) };
}
+1
View File
@@ -41,6 +41,7 @@ mod it_audio;
mod it_codec;
mod it_common;
mod it_export;
mod it_library;
mod it_plugin;
mod it_storage;
mod it_task;
@@ -18,10 +18,10 @@
//!
//! One module per entity: the `DeriveEntityModel` macro generates
//! `Entity`/`Column`/`Model`/`ActiveModel` in the defining module, so
//! each table gets its own scope. The same definitions serve SQLite
//! today and PostgreSQL (D3) — the column types (`i64`, `String`,
//! `Option<String>`, `NaiveDateTime`) map to BIGINT/TEXT/TIMESTAMP on
//! both.
//! each table gets its own scope. The same definitions serve SQLite and
//! PostgreSQL — the column types (`i64`, `String`, `Option<String>`,
//! `NaiveDateTime`) map to BIGINT/TEXT/TIMESTAMP on both, so the shared
//! save/replay logic never branches on the dialect.
pub mod journal;
pub mod project;
@@ -60,22 +60,53 @@ const SQLITE_DDL: &[&str] = &[
)",
];
/// Apply the schema (idempotent; safe to run on every connection open).
///
/// PostgreSQL (D3) gets its own `BIGSERIAL` DDL — same shape, currently
/// unreachable because the backend rejects `oakdb+pg://` before any
/// connection is opened.
/// PostgreSQL DDL for the four tables — the same shape with the PG
/// dialect differences (plan D3: BIGSERIAL for the surrogate key,
/// BIGINT for the FKs; `TEXT` everywhere, matching the SQLite TEXT
/// decision — no BYTEA). `CREATE TABLE IF NOT EXISTS` makes it
/// idempotent, exactly like the SQLite side.
const POSTGRES_DDL: &[&str] = &[
"CREATE TABLE IF NOT EXISTS projects (
id BIGSERIAL PRIMARY KEY,
uuid TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
schema_ver INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL,
modified_at TIMESTAMP NOT NULL,
command_seq BIGINT NOT NULL DEFAULT 0
)",
"CREATE TABLE IF NOT EXISTS settings (
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (project_id, key)
)",
"CREATE TABLE IF NOT EXISTS snapshots (
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
command_seq BIGINT NOT NULL,
payload TEXT NOT NULL,
written_at TIMESTAMP NOT NULL,
PRIMARY KEY (project_id, command_seq)
)",
"CREATE TABLE IF NOT EXISTS journal (
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
seq BIGINT NOT NULL,
node_identity BIGINT NOT NULL,
kind TEXT NOT NULL,
old_xml TEXT,
new_xml TEXT,
at TIMESTAMP NOT NULL,
PRIMARY KEY (project_id, seq, node_identity)
)",
];
/// Apply the schema for the connection's dialect (idempotent; safe to
/// run on every connection open).
pub async fn migrate(db: &impl ConnectionTrait) -> crate::error::Result<()> {
let backend = db.get_database_backend();
let statements: &[&str] = match backend {
DatabaseBackend::Sqlite => SQLITE_DDL,
// D3: `BIGSERIAL PRIMARY KEY` for projects.id, `BIGINT` for the
// FKs — the rest of the column set is identical.
DatabaseBackend::Postgres => {
return Err(crate::error::Error::Failed(
"oakdb+pg is a D3 milestone; the PostgreSQL schema is not wired yet".to_string(),
));
}
DatabaseBackend::Postgres => POSTGRES_DDL,
other => {
return Err(crate::error::Error::Failed(format!(
"unsupported database backend {other:?}"
+166 -71
View File
@@ -14,16 +14,20 @@
// 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 database backend (plan M13 §1/§2), SQLite first.
//! The database backend (plan M13 §1/§2): SQLite and PostgreSQL share
//! one code path — the same sea-orm entities, diff/save/replay logic and
//! migrations, with the dialect differences (BIGSERIAL vs AUTOINCREMENT,
//! `IF NOT EXISTS` DDL) converged in the connection and migration layer.
//!
//! URI forms:
//! - `oakdb+sqlite:///absolute/path.db[?project=<uuid>]` — local file
//! database; the optional `project` query selects the library row to
//! load (default: the most recently modified project).
//! - `oakdb+pg://user:pass@host:5432/db[?project=<uuid>]` — PostgreSQL
//! (D3). The target is parsed into [`DbTarget::Pg`] today, but every
//! operation rejects it with [`crate::error::Error::NoBackend`] until
//! the D3 client lands.
//! - `oakdb+pg://user:pass@host:5432/db[?project=<uuid>]` — PostgreSQL;
//! the body is the libpq connection string without a scheme. A `?…`
//! part is the `?project=` selector only when it carries a `project=`
//! key — a bare query (e.g. `?sslmode=disable`) belongs to the
//! connection string and is passed through untouched.
//!
//! The async sea-orm API is driven by a private current-thread tokio
//! runtime so the public surface stays synchronous (plan §6: "后端内嵌
@@ -104,9 +108,11 @@ pub(crate) enum DbTarget {
/// `?project=` uuid; `None` = most recently modified row.
project: Option<String>,
},
/// PostgreSQL (parsed for D3; rejected until then).
/// PostgreSQL server.
Pg {
/// Connection string (`user:pass@host:port/db`).
/// Connection string body (`user:pass@host:port/db`, without a
/// scheme; may carry its own `?param=…` query when it does not
/// contain a `project=` key).
conn: String,
/// `?project=` uuid; `None` = most recently modified row.
project: Option<String>,
@@ -114,20 +120,6 @@ pub(crate) enum DbTarget {
}
impl DbTarget {
/// True for the PostgreSQL variant (all ops return E_NO_BACKEND
/// until D3).
fn is_pg(&self) -> bool {
matches!(self, DbTarget::Pg { .. })
}
/// The SQLite path (`None` for PG).
fn sqlite_path(&self) -> Option<&str> {
match self {
DbTarget::Sqlite { path, .. } => Some(path),
DbTarget::Pg { .. } => None,
}
}
/// The `?project=` selector.
fn project(&self) -> Option<&str> {
match self {
@@ -138,12 +130,23 @@ impl DbTarget {
}
}
/// The connection-cache key of a parsed target (the database location —
/// an absolute SQLite path or a PostgreSQL connection string body).
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
enum DbKey {
/// SQLite file database at an absolute path.
Sqlite(String),
/// PostgreSQL server (connection string body, see [`DbTarget::Pg`]).
Pg(String),
}
/// Parse a storage URI into a [`DbTarget`].
///
/// `oakdb+sqlite://` requires an absolute path (E_INVALID otherwise);
/// the body may carry `?project=<uuid>` (no percent-decoding — uuid
/// values contain only hex/braces/hyphens). Unknown `oakdb` variants
/// are E_NO_BACKEND.
/// values contain only hex/braces/hyphens). `oakdb+pg://` requires a
/// non-empty, parseable connection string (E_INVALID otherwise). Unknown
/// `oakdb` variants are E_NO_BACKEND.
pub(crate) fn parse_target(uri: &StorageUri) -> Result<DbTarget> {
match uri.scheme.as_str() {
"oakdb+sqlite" => {
@@ -157,16 +160,37 @@ pub(crate) fn parse_target(uri: &StorageUri) -> Result<DbTarget> {
})
}
"oakdb+pg" => {
let (conn, query) = split_query(&uri.body);
Ok(DbTarget::Pg {
conn: conn.to_string(),
project: query_param(query, "project"),
})
let (base, query) = split_query(&uri.body);
// A `?…` part is the `?project=` selector only when it carries
// a `project=` key; otherwise (e.g. `?sslmode=disable`) it
// belongs to the connection string and the whole body passes
// through.
let project = match query {
Some(q) if query_param(Some(q), "project").is_some() => {
query_param(Some(q), "project")
}
_ => None,
};
let conn = match &project {
Some(_) => base.to_string(),
None => uri.body.clone(),
};
if conn.is_empty() || !valid_pg_conn(&conn) {
return Err(Error::Invalid);
}
Ok(DbTarget::Pg { conn, project })
}
_ => Err(Error::NoBackend),
}
}
/// Whether `conn` parses as a PostgreSQL connection string (the libpq
/// URL form, `user:pass@host:port/db[?params]`, with a scheme prepended).
fn valid_pg_conn(conn: &str) -> bool {
use std::str::FromStr;
sea_orm::sqlx::postgres::PgConnectOptions::from_str(&format!("postgres://{conn}")).is_ok()
}
/// Split `body` at the first `?` (the query part, if any).
fn split_query(body: &str) -> (&str, Option<&str>) {
match body.split_once('?') {
@@ -310,8 +334,8 @@ pub struct DatabaseBackend {
runtime: OnceLock<tokio::runtime::Runtime>,
/// Serializes all database operations of this backend.
op_lock: Mutex<()>,
/// Open connections by database path.
connections: Mutex<HashMap<String, DatabaseConnection>>,
/// Open connections by database location (path or PG conn string).
connections: Mutex<HashMap<DbKey, DatabaseConnection>>,
}
impl DatabaseBackend {
@@ -332,10 +356,11 @@ impl DatabaseBackend {
.expect("tokio current_thread runtime")
}
/// Run `f` against the database at `key` (an absolute SQLite path).
/// The op lock is held for the whole call; the connection is opened
/// and migrated on first use, then cached.
fn run<T, F, Fut>(&self, key: &str, f: F) -> Result<T>
/// Run `f` against the database at `key` (an absolute SQLite path or a
/// PostgreSQL connection string). The op lock is held for the whole
/// call; the connection is opened and migrated on first use, then
/// cached.
fn run<T, F, Fut>(&self, key: DbKey, f: F) -> Result<T>
where
F: FnOnce(DatabaseConnection) -> Fut,
Fut: Future<Output = Result<T>>,
@@ -343,33 +368,36 @@ impl DatabaseBackend {
let _guard = self.op_lock.lock().unwrap_or_else(|e| e.into_inner());
let runtime = self.runtime.get_or_init(Self::build_runtime);
runtime.block_on(async {
let conn = self.connect_cached(key).await?;
let conn = self.connect_cached(&key).await?;
f(conn).await
})
}
/// Open (and migrate) the SQLite database at `path`, caching the
/// connection.
/// Open (and migrate) the database at `key`, caching the connection.
///
/// Two writers racing on a fresh file surface SQLite's `database is
/// Two writers racing on a fresh SQLite file surface `database is
/// locked` (SQLITE_BUSY) — most often when a fresh connection's WAL
/// mode switch collides with the other's, which `busy_timeout` cannot
/// wait out — so connect+ migrate are retried a few times (plan §6:
/// single-writer is the contract; a clean error beats a silent
/// failure).
async fn connect_cached(&self, path: &str) -> Result<DatabaseConnection> {
/// wait out — so the SQLite connect + migrate are retried a few times
/// (plan §6: single-writer is the contract; a clean error beats a
/// silent failure). PostgreSQL connections are never retried (their
/// errors are not retryable by [`retryable`]).
async fn connect_cached(&self, key: &DbKey) -> Result<DatabaseConnection> {
if let Some(conn) = self
.connections
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(path)
.get(key)
{
return Ok(conn.clone());
}
let mut attempt = 0;
let conn = loop {
let result = async {
let conn = connect_sqlite(path).await?;
let conn = match key {
DbKey::Sqlite(path) => connect_sqlite(path).await?,
DbKey::Pg(conn) => connect_pg(conn).await?,
};
migration::migrate(&conn).await?;
Ok::<_, Error>(conn)
}
@@ -386,7 +414,7 @@ impl DatabaseBackend {
self.connections
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(path.to_string(), conn.clone());
.insert(key.clone(), conn.clone());
Ok(conn)
}
@@ -396,8 +424,8 @@ impl DatabaseBackend {
/// node graph).
pub fn list_projects(&self, uri: &StorageUri) -> Result<Vec<ProjectInfo>> {
let target = parse_target(uri)?;
let path = sqlite_path_of(&target)?;
self.run(path, |conn| async move {
let key = db_key_of(&target);
self.run(key, |conn| async move {
let models = project::Entity::find()
.order_by_desc(project::Column::ModifiedAt)
.all(&conn)
@@ -422,9 +450,9 @@ impl DatabaseBackend {
/// the project's data is gone — the manager confirms before calling).
pub fn delete_project(&self, uri: &StorageUri, uuid: &str) -> Result<()> {
let target = parse_target(uri)?;
let path = sqlite_path_of(&target)?;
let key = db_key_of(&target);
let uuid = uuid.to_string();
self.run(path, move |conn| async move {
self.run(key, move |conn| async move {
let tx = conn.begin().await.map_err(db_err)?;
let res = project::Entity::delete_many()
.filter(project::Column::Uuid.eq(&uuid))
@@ -449,10 +477,10 @@ impl DatabaseBackend {
new_name: Option<&str>,
) -> Result<ProjectInfo> {
let target = parse_target(uri)?;
let path = sqlite_path_of(&target)?;
let key = db_key_of(&target);
let uuid = uuid.to_string();
let new_name = new_name.map(str::to_string);
self.run(path, move |conn| async move {
self.run(key, move |conn| async move {
let tx = conn.begin().await.map_err(db_err)?;
let src = project::Entity::find()
.filter(project::Column::Uuid.eq(&uuid))
@@ -556,10 +584,10 @@ impl DatabaseBackend {
/// in-app project name (`settings["projectname"]`) is untouched.
pub fn rename_project(&self, uri: &StorageUri, uuid: &str, new_name: &str) -> Result<()> {
let target = parse_target(uri)?;
let path = sqlite_path_of(&target)?;
let key = db_key_of(&target);
let uuid = uuid.to_string();
let new_name = new_name.to_string();
self.run(path, move |conn| async move {
self.run(key, move |conn| async move {
let now = chrono::Utc::now().naive_utc();
let tx = conn.begin().await.map_err(db_err)?;
let res = project::Entity::update_many()
@@ -633,9 +661,9 @@ impl DatabaseBackend {
/// the head seq.
pub fn snapshot(&self, uri: &StorageUri, uuid: &str) -> Result<()> {
let target = parse_target(uri)?;
let path = sqlite_path_of(&target)?;
let key = db_key_of(&target);
let uuid = uuid.to_string();
self.run(path, move |conn| async move {
self.run(key, move |conn| async move {
let model = project::Entity::find()
.filter(project::Column::Uuid.eq(&uuid))
.one(&conn)
@@ -657,9 +685,9 @@ impl DatabaseBackend {
/// the empty project. E_INVALID when `seq` is out of range.
pub fn load_at(&self, uri: &StorageUri, uuid: &str, seq: i64) -> Result<CHandle> {
let target = parse_target(uri)?;
let path = sqlite_path_of(&target)?;
let key = db_key_of(&target);
let uuid = uuid.to_string();
self.run(path, move |conn| async move {
self.run(key, move |conn| async move {
let model = project::Entity::find()
.filter(project::Column::Uuid.eq(&uuid))
.one(&conn)
@@ -695,9 +723,9 @@ impl DatabaseBackend {
/// handle (the head state).
fn load_handle_by_uuid(&self, uri: &StorageUri, uuid: &str) -> Result<CHandle> {
let target = parse_target(uri)?;
let path = sqlite_path_of(&target)?;
let key = db_key_of(&target);
let uuid = uuid.to_string();
self.run(path, move |conn| async move {
self.run(key, move |conn| async move {
let model = project::Entity::find()
.filter(project::Column::Uuid.eq(&uuid))
.one(&conn)
@@ -736,9 +764,9 @@ impl StorageBackend for DatabaseBackend {
fn load(&self, uri: &StorageUri) -> Result<LoadResult> {
let target = parse_target(uri)?;
let path = sqlite_path_of(&target)?;
let key = db_key_of(&target);
let project = target.project().map(str::to_string);
self.run(path, move |conn| async move {
self.run(key, move |conn| async move {
let model = pick_project(&conn, project.as_deref()).await?;
let xml = assemble_at(&conn, model.id, &model.uuid, model.command_seq).await?;
let handle =
@@ -749,7 +777,7 @@ impl StorageBackend for DatabaseBackend {
fn save(&self, project: CHandle, uri: &StorageUri, _options: u32) -> Result<()> {
let target = parse_target(uri)?;
let path = sqlite_path_of(&target)?;
let key = db_key_of(&target);
// Serialize under the project lock (the same per-node writer the
// `.ove` backend uses — one serialization truth).
let arc = unsafe { crate::nodeutil::project_arc(&project)? };
@@ -760,21 +788,19 @@ impl StorageBackend for DatabaseBackend {
let (nodes, settings_xml, settings_map) = serialize_project_state(&guard)?;
(uuid, name, nodes, settings_xml, settings_map)
};
self.run(path, move |conn| async move {
self.run(key, move |conn| async move {
save_tx(&conn, &uuid, &name, &nodes, &settings_xml, &settings_map).await
})?;
Ok(())
}
}
/// The SQLite path of a target, rejecting PG with E_NO_BACKEND (D3).
fn sqlite_path_of(target: &DbTarget) -> Result<&str> {
if target.is_pg() {
// D3: `oakdb+pg://` is parsed (see parse_target) but every
// operation refuses it until the PostgreSQL client is wired.
return Err(Error::NoBackend);
/// The connection-cache key of a target (SQLite path or PG conn string).
fn db_key_of(target: &DbTarget) -> DbKey {
match target {
DbTarget::Sqlite { path, .. } => DbKey::Sqlite(path.clone()),
DbTarget::Pg { conn, .. } => DbKey::Pg(conn.clone()),
}
Ok(target.sqlite_path().expect("pg rejected above"))
}
/// Display name for a project on first entry: the `projectname` setting
@@ -804,6 +830,34 @@ async fn connect_sqlite(path: &str) -> Result<DatabaseConnection> {
Ok(DatabaseConnection::from(pool))
}
/// Open the PostgreSQL database at `conn` (a connection string body,
/// e.g. `user:pass@host:5432/db`; a scheme is prepended for sqlx).
///
/// The server is first probed with a single, non-retrying connection
/// attempt: the pool retries refused connections with backoff for the
/// whole acquire timeout, which would hold a dead server for seconds.
/// A single `connect()` surfaces the refusal immediately, so a dead /
/// unreachable host fails fast with a clean E_IO.
async fn connect_pg(conn: &str) -> Result<DatabaseConnection> {
use std::str::FromStr;
use sea_orm::sqlx::postgres::{PgConnectOptions, PgPoolOptions};
use sea_orm::sqlx::ConnectOptions;
let url = format!("postgres://{conn}");
let options = PgConnectOptions::from_str(&url)
.map_err(|e| Error::Io(format!("invalid postgres connection string: {e}")))?;
options
.connect()
.await
.map_err(|e| Error::Io(format!("cannot connect to postgres database: {e}")))?;
let pool = PgPoolOptions::new()
.max_connections(1)
.acquire_timeout(Duration::from_secs(10))
.connect_with(options)
.await
.map_err(|e| Error::Io(format!("cannot connect to postgres database: {e}")))?;
Ok(DatabaseConnection::from(pool))
}
/// Whether an error is worth retrying (SQLite BUSY — plan §6 note in
/// [`DatabaseBackend::run`]).
fn retryable(e: &Error) -> bool {
@@ -1450,7 +1504,7 @@ mod tests {
}
#[test]
fn parse_target_pg_kept_for_d3() {
fn parse_target_pg_conn_string() {
let t = parse_target(&uri("oakdb+pg://user:pass@host:5432/db")).unwrap();
assert_eq!(
t,
@@ -1459,6 +1513,47 @@ mod tests {
project: None
}
);
// A `?project=` query is the row selector, not a conn param.
let t = parse_target(&uri("oakdb+pg://user@host/db?project={abc-123}")).unwrap();
assert_eq!(
t,
DbTarget::Pg {
conn: "user@host/db".to_string(),
project: Some("{abc-123}".to_string())
}
);
// A query without a `project=` key belongs to the conn string
// (e.g. `?sslmode=disable` passes through untouched).
let t = parse_target(&uri("oakdb+pg://user@host/db?sslmode=disable")).unwrap();
assert_eq!(
t,
DbTarget::Pg {
conn: "user@host/db?sslmode=disable".to_string(),
project: None
}
);
}
#[test]
fn parse_target_pg_invalid_rejected() {
// Empty / unparseable connection strings are E_INVALID (clean, no
// network touched), matching the sqlite relative-path rule.
assert!(matches!(
parse_target(&uri("oakdb+pg://")),
Err(Error::Invalid)
));
assert!(matches!(
parse_target(&uri("oakdb+pg://?project={x}")),
Err(Error::Invalid)
));
assert!(matches!(
parse_target(&uri("oakdb+pg://user@")),
Err(Error::Invalid)
));
assert!(matches!(
parse_target(&uri("oakdb+pg://not a url")),
Err(Error::Invalid)
));
}
#[test]
+525
View File
@@ -0,0 +1,525 @@
// 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/>.
//! Shared fixtures and helpers for the database-backend integration
//! tests (plan M13 D1/D3).
//!
//! Everything here is dialect-agnostic: the test bodies run against a
//! `oakdb+sqlite://…` or `oakdb+pg://…` uri string, and the helpers that
//! inspect rows behind the backend's back open a raw sea-orm connection
//! ([`inspect_sqlite`] / [`inspect_pg`]). `tests/database_test.rs` runs
//! the SQLite suite; `tests/database_pg_test.rs` runs the same behaviors
//! against a real PostgreSQL server gated on `OAK_TEST_PG_URL`.
// The module is compiled into two test binaries, each of which uses only
// the helpers for its own dialect, so the other dialect's helpers look
// dead to one binary while the other uses them.
#![allow(dead_code)]
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use sea_orm::entity::prelude::*;
use oakcore_rs::Rational;
use oaknode::block::ClipBlockBehavior;
use oaknode::footage::FootageBehavior;
use oaknode::id::NodeId;
use oaknode::keyframe::{Interpolation, Keyframe};
use oaknode::node::NodeCore;
use oaknode::project::Project;
use oaknode::sequence::SequenceBehavior;
use oaknode::track::{TrackBehavior, TrackListBehavior, TrackType};
use oaknode::value::NodeValue;
use oakstorage::backend::StorageBackend;
use oakstorage::backends::database::entities::settings;
use oakstorage::backends::database::DatabaseBackend;
use oakstorage::error::OAKSTORAGE_OK;
use oakstorage::handle::CHandle;
use oakstorage::nodeutil::{make_project_owned, project_arc};
use oakstorage::uri::StorageUri;
// ---------------------------------------------------------------------------
// URIs and paths
// ---------------------------------------------------------------------------
/// A fresh, unique temp directory for one test.
pub(crate) fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("oakstorage_db_{}_{}", std::process::id(), tag));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// `oakdb+sqlite:///…` URI for a database file.
pub(crate) fn db_uri(path: &Path) -> String {
format!("oakdb+sqlite://{}", path.display())
}
/// `oakdb+sqlite:///…?project=<uuid>` URI selecting one library row.
pub(crate) fn project_uri(db: &str, uuid: &str) -> String {
format!("{db}?project={uuid}")
}
/// `file://…` URI for a plain file.
pub(crate) fn file_uri(path: &Path) -> String {
format!("file://{}", path.display())
}
// ---------------------------------------------------------------------------
// Save / load through the backend
// ---------------------------------------------------------------------------
/// Release an owned handle (refcount 1).
pub(crate) fn release(h: CHandle) {
if let Some(release) = h.release {
unsafe { release(h.ctx) };
}
}
/// Save an `Arc<Mutex<Project>>` through the database backend.
pub(crate) fn save_project(
backend: &DatabaseBackend,
project: &Arc<Mutex<Project>>,
uri: &str,
) -> oakstorage::error::Result<()> {
let parsed = StorageUri::parse(uri).unwrap();
let handle = make_project_owned(project.clone());
let result = backend.save(handle, &parsed, 0);
release(handle);
result
}
/// Load a project through a *new* database backend session (a fresh
/// connection pool = a fresh session), returning `(uuid, loaded)`.
pub(crate) fn load_project(backend: &DatabaseBackend, uri: &str) -> (String, Arc<Mutex<Project>>) {
let parsed = StorageUri::parse(uri).unwrap();
let result = backend.load(&parsed).unwrap();
assert_eq!(result.version_info, OAKSTORAGE_OK);
let handle = result.project;
let loaded = unsafe { project_arc(&handle) }.unwrap();
let uuid = loaded.lock().unwrap().uuid.clone();
release(handle);
(uuid, loaded)
}
/// Load the state at `seq` (undo to any point).
pub(crate) fn load_at(backend: &DatabaseBackend, uri: &str, uuid: &str, seq: i64) -> Arc<Mutex<Project>> {
let parsed = StorageUri::parse(uri).unwrap();
let handle = backend.load_at(&parsed, uuid, seq).unwrap();
let loaded = unsafe { project_arc(&handle) }.unwrap();
release(handle);
loaded
}
pub(crate) fn r_to_f(r: Rational) -> f64 {
r.numerator() as f64 / r.denominator() as f64
}
pub(crate) fn assert_close(a: f64, b: f64) {
assert!((a - b).abs() < 1e-6, "expected {a} close to {b}");
}
// ---------------------------------------------------------------------------
// Row inspection (raw sea-orm connections behind the backend's back)
// ---------------------------------------------------------------------------
/// Open a raw sea-orm SQLite connection to `path` and drive one future
/// against it on a private current-thread runtime.
pub(crate) fn inspect_sqlite<R, Fut>(path: &Path, f: impl FnOnce(sea_orm::DatabaseConnection) -> Fut) -> R
where
Fut: std::future::Future<Output = R>,
{
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let options = sea_orm::sqlx::sqlite::SqliteConnectOptions::new()
.filename(path)
.create_if_missing(true)
.journal_mode(sea_orm::sqlx::sqlite::SqliteJournalMode::Wal)
.busy_timeout(Duration::from_secs(5))
.foreign_keys(true);
let pool = sea_orm::sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await
.unwrap();
f(sea_orm::DatabaseConnection::from(pool)).await
})
}
/// Open a raw sea-orm PostgreSQL connection to `url` and drive one
/// future against it on a private current-thread runtime.
pub(crate) fn inspect_pg<R, Fut>(url: &str, f: impl FnOnce(sea_orm::DatabaseConnection) -> Fut) -> R
where
Fut: std::future::Future<Output = R>,
{
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let pool = sea_orm::sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(url)
.await
.unwrap();
f(sea_orm::DatabaseConnection::from(pool)).await
})
}
/// The settings mirror rows of a project (SQLite).
pub(crate) fn settings_rows(path: &Path, project_id: i64) -> Vec<(String, String)> {
inspect_sqlite(path, |conn| async move {
settings::Entity::find()
.filter(settings::Column::ProjectId.eq(project_id))
.all(&conn)
.await
.unwrap()
.into_iter()
.map(|s| (s.key, s.value))
.collect()
})
}
/// The settings mirror rows of a project (PostgreSQL).
pub(crate) fn settings_rows_pg(url: &str, project_id: i64) -> Vec<(String, String)> {
inspect_pg(url, |conn| async move {
settings::Entity::find()
.filter(settings::Column::ProjectId.eq(project_id))
.all(&conn)
.await
.unwrap()
.into_iter()
.map(|s| (s.key, s.value))
.collect()
})
}
/// The project uuid.
pub(crate) fn uuid_of(project: &Arc<Mutex<Project>>) -> String {
project.lock().unwrap().uuid.clone()
}
/// Read a config value for the duration of a test (the config store is
/// process-global, so restore it afterwards).
pub(crate) fn with_config(group: &str, key: &str, value: i32, f: impl FnOnce()) {
let store = oakcommon::configstore::ConfigStore::instance();
let before = store.get_int(Some(group), key, 0);
store.set_int(Some(group), key, value);
f();
store.set_int(Some(group), key, before);
}
// ---------------------------------------------------------------------------
// Full-feature fixture (footage / sequence / track / clip / effect /
// keyframes) — the union of the ove round-trip and timeline fixtures.
// ---------------------------------------------------------------------------
pub(crate) const MATH: &str = "org.olivevideoeditor.Olive.math";
/// Build the round-trip fixture: root folder + two math nodes with
/// values/keyframes/label/color/link/connection + a sequence "My Seq"
/// with one video track carrying two clips (footage /a/b.mp4, /a/c.mp4).
pub(crate) fn build_full_project() -> Arc<Mutex<Project>> {
let project = Project::new();
let mut p = project.lock().unwrap();
p.initialize().unwrap();
let (core, behavior) = (oaknode::factory::Factory::global().find(MATH).unwrap().create)();
let a = p.graph.add_node(core, behavior);
{
let e = p.graph.get_mut(a).unwrap();
e.core.label = "Math A".to_string();
e.core.override_color = 2;
e.core.set_standard_value("param_a_in", -1, NodeValue::Float(2.5));
e.core
.keyframe_track_mut("param_a_in", -1)
.set_key(Keyframe {
time: Rational::new(0, 1),
value: NodeValue::Float(1.0),
interpolation: Interpolation::Linear,
bezier_in: (0.0, 0.0),
bezier_out: (0.0, 0.0),
});
e.core
.keyframe_track_mut("param_a_in", -1)
.set_key(Keyframe {
time: Rational::new(1, 1),
value: NodeValue::Float(3.0),
interpolation: Interpolation::Bezier,
bezier_in: (0.1, 0.2),
bezier_out: (0.3, 0.4),
});
}
let (core, behavior) = (oaknode::factory::Factory::global().find(MATH).unwrap().create)();
let b = p.graph.add_node(core, behavior);
p.graph
.get_mut(b)
.unwrap()
.core
.set_standard_value("param_a_in", -1, NodeValue::Float(4.0));
p.graph.connect(a, b, "param_b_in", -1).unwrap();
p.graph.link(a, b);
// Timeline: sequence + video track + two clips with footage.
let (seq_id, lists) = oakstorage::nodeutil::create_sequence(&mut p.graph);
p.graph.get_mut(seq_id).unwrap().core.label = "My Seq".to_string();
let mut tb = TrackBehavior::new(TrackType::Video);
tb.track_list = Some(lists[0]);
let track_id = p.graph.add_node(NodeCore::new(), Box::new(tb));
let foot1 = p
.graph
.add_node(NodeCore::new(), Box::new(FootageBehavior::new("/a/b.mp4")));
let clip1 = {
let (core, mut behavior) = oaknode::block::clip_create();
let clip = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
.unwrap();
clip.core.range = oakcore_rs::TimeRange::new(Rational::new(0, 1), Rational::new(100, 25));
clip.core.media_in = Rational::new(0, 1);
clip.core.track = Some(track_id);
clip.footage = Some(foot1);
p.graph.add_node(core, behavior)
};
let foot2 = p
.graph
.add_node(NodeCore::new(), Box::new(FootageBehavior::new("/a/c.mp4")));
let clip2 = {
let (core, mut behavior) = oaknode::block::clip_create();
let clip = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
.unwrap();
clip.core.range =
oakcore_rs::TimeRange::new(Rational::new(100, 25), Rational::new(150, 25));
clip.core.media_in = Rational::new(10, 25);
clip.core.track = Some(track_id);
clip.footage = Some(foot2);
p.graph.add_node(core, behavior)
};
if let Some(entry) = p.graph.get_mut(track_id) {
entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<TrackBehavior>())
.unwrap()
.blocks = vec![clip1, clip2];
}
if let Some(entry) = p.graph.get_mut(lists[0]) {
entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<TrackListBehavior>())
.unwrap()
.tracks = vec![track_id];
}
p.settings
.insert("projectname".to_string(), "full-fixture".to_string());
drop(p);
project
}
/// Field-by-field comparison of the full fixture after a round-trip
/// (uuid included).
pub(crate) fn assert_full_fields(orig: &Project, loaded: &Project) {
assert_eq!(loaded.uuid, orig.uuid, "uuid");
assert_full_state(orig, loaded);
}
/// Field-by-field comparison that ignores the uuid (a duplicated row
/// carries a fresh uuid by design).
pub(crate) fn assert_full_state(orig: &Project, loaded: &Project) {
assert_eq!(loaded.settings, orig.settings, "settings");
// Node set: count, type order (slot order preserved by the writer).
let o_ids = orig.graph.node_ids();
let l_ids = loaded.graph.node_ids();
assert_eq!(l_ids.len(), o_ids.len(), "node count");
let o_types: Vec<&str> = o_ids
.iter()
.map(|id| orig.graph.get(*id).unwrap().behavior.type_id())
.collect();
let l_types: Vec<&str> = l_ids
.iter()
.map(|id| loaded.graph.get(*id).unwrap().behavior.type_id())
.collect();
assert_eq!(l_types, o_types, "node types");
// Math A: label, color, value, keyframes.
let (a_o, a_l) = (o_ids[1], l_ids[1]);
assert_eq!(
loaded.graph.get(a_l).unwrap().core.label,
orig.graph.get(a_o).unwrap().core.label,
"label"
);
assert_eq!(
loaded.graph.get(a_l).unwrap().core.override_color,
orig.graph.get(a_o).unwrap().core.override_color,
"color"
);
assert_eq!(
loaded
.graph
.get(a_l)
.unwrap()
.core
.standard_value("param_a_in", -1),
orig.graph
.get(a_o)
.unwrap()
.core
.standard_value("param_a_in", -1),
"value a"
);
let keys_o = orig
.graph
.get(a_o)
.unwrap()
.core
.keyframe_track("param_a_in", -1)
.unwrap()
.keys()
.to_vec();
let keys_l = loaded
.graph
.get(a_l)
.unwrap()
.core
.keyframe_track("param_a_in", -1)
.unwrap()
.keys()
.to_vec();
assert_eq!(keys_l.len(), keys_o.len(), "keyframe count");
for (ko, kl) in keys_o.iter().zip(&keys_l) {
assert_eq!(kl.time, ko.time, "key time");
assert_eq!(kl.value.to_double(), ko.value.to_double(), "key value");
assert_eq!(kl.interpolation, ko.interpolation, "key interpolation");
assert_eq!(kl.bezier_in, ko.bezier_in, "key bezier in");
assert_eq!(kl.bezier_out, ko.bezier_out, "key bezier out");
}
// Connection a -> b.param_b_in and the link.
assert_eq!(
loaded.graph.connected_output(l_ids[2], "param_b_in", -1),
Some(a_l),
"connection"
);
assert!(loaded.graph.are_linked(a_l, l_ids[2]), "link");
// Timeline: sequence, track lists, track, clips, footage.
let mut seq: Option<(NodeId, Vec<NodeId>)> = None;
for id in loaded.graph.node_ids() {
let entry = loaded.graph.get(id).unwrap();
if entry.behavior.type_id() == "org.olivevideoeditor.Olive.sequence" {
let s = entry
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<SequenceBehavior>())
.unwrap();
seq = Some((id, s.track_lists.clone()));
break;
}
}
let (_seq_id, lists) = seq.expect("sequence present");
assert_eq!(lists.len(), 3, "video/audio/subtitle lists");
let list = loaded
.graph
.get(lists[0])
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<TrackListBehavior>())
.unwrap();
assert_eq!(list.kind, TrackType::Video);
assert_eq!(list.array_base, 0);
assert_eq!(list.tracks.len(), 1);
let track = loaded
.graph
.get(list.tracks[0])
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<TrackBehavior>())
.unwrap();
assert_eq!(track.kind, TrackType::Video);
assert_eq!(track.blocks.len(), 2);
let c1 = loaded
.graph
.get(track.blocks[0])
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<ClipBlockBehavior>())
.unwrap();
assert_close(r_to_f(c1.core.length()), 4.0);
assert_close(r_to_f(c1.core.media_in), 0.0);
let f1 = loaded
.graph
.get(c1.footage.unwrap())
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<FootageBehavior>())
.unwrap();
assert_eq!(f1.filename, "/a/b.mp4");
let c2 = loaded
.graph
.get(track.blocks[1])
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<ClipBlockBehavior>())
.unwrap();
assert_close(r_to_f(c2.core.length()), 2.0);
assert_close(r_to_f(c2.core.media_in), 0.4);
let f2 = loaded
.graph
.get(c2.footage.unwrap())
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<FootageBehavior>())
.unwrap();
assert_eq!(f2.filename, "/a/c.mp4");
}
/// Build a small project with a `projectname` setting (fast saves).
pub(crate) fn build_named_project(name: &str) -> Arc<Mutex<Project>> {
let project = Project::new();
{
let mut p = project.lock().unwrap();
p.initialize().unwrap();
p.settings.insert("projectname".to_string(), name.to_string());
}
project
}
/// Save a fresh named project, returning its uuid.
pub(crate) fn save_named_project(backend: &DatabaseBackend, uri: &str, name: &str) -> String {
let project = build_named_project(name);
let uuid = uuid_of(&project);
save_project(backend, &project, uri).unwrap();
uuid
}
+996
View File
@@ -0,0 +1,996 @@
// 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/>.
//! PostgreSQL variants of the database-backend integration tests (plan
//! M13 D3): the same behaviors `database_test.rs` runs against SQLite,
//! driven by the shared fixtures in `tests/common/mod.rs`, against a
//! real PostgreSQL server.
//!
//! Gating: when `OAK_TEST_PG_URL` (a libpq URL such as
//! `postgres://user:pass@host:5432/db`) is set, every test connects to
//! that server and runs for real; when it is absent the tests skip with
//! a note on stderr, so `cargo test -p oakstorage` is green without a
//! PostgreSQL server (CI included). The URL should point at a dedicated
//! test database: each test resets the four schema tables (DROP + the
//! migration recreates them on connect), and the tests run serialized
//! against the shared database.
mod common;
use std::sync::Mutex;
use sea_orm::entity::prelude::*;
use sea_orm::{ConnectionTrait, QueryOrder};
use oaknode::id::NodeId;
use oaknode::value::NodeValue;
use oakstorage::backend::StorageBackend;
use oakstorage::backends::database::entities::{journal, project, snapshot};
use oakstorage::backends::database::{
derive_stats, DatabaseBackend, KIND_IMPORT, KIND_REDO, ProjectStats, SNAPSHOT_KEEP,
};
use oakstorage::error::{
OAKSTORAGE_E_FORMAT, OAKSTORAGE_E_INVALID, OAKSTORAGE_E_NOT_FOUND, OAKSTORAGE_OK,
};
use oakstorage::nodeutil::project_arc;
use oakstorage::registry::Registry;
use oakstorage::uri::StorageUri;
use common::*;
// ---------------------------------------------------------------------------
// Gating and isolation
// ---------------------------------------------------------------------------
/// The `OAK_TEST_PG_URL` connection string (None when not configured).
fn pg_url() -> Option<String> {
std::env::var("OAK_TEST_PG_URL")
.ok()
.filter(|s| !s.trim().is_empty())
}
/// Strip the `postgres://` / `postgresql://` scheme from a connection
/// URL to get the `oakdb+pg://` uri body.
fn pg_body(url: &str) -> &str {
url.strip_prefix("postgres://")
.or_else(|| url.strip_prefix("postgresql://"))
.unwrap_or(url)
}
/// Reset the four schema tables (the migration recreates them on the
/// next connection). Serialized with the other PG tests.
fn reset_tables(url: &str) {
inspect_pg(url, |conn| async move {
for table in ["journal", "snapshots", "settings", "projects"] {
conn.execute_raw(sea_orm::Statement::from_string(
sea_orm::DatabaseBackend::Postgres,
format!("DROP TABLE IF EXISTS {table} CASCADE"),
))
.await
.unwrap();
}
});
}
/// Run `f` with `(pg_url, oakdb+pg uri)` for an isolated test session.
/// Skips (with a note) when `OAK_TEST_PG_URL` is absent; otherwise the
/// tables are reset and the test runs against the shared database (the
/// tests share one server and one schema, so they run one at a time).
fn with_pg_uri(tag: &str, f: impl FnOnce(&str, &str)) {
let Some(url) = pg_url() else {
eprintln!("OAK_TEST_PG_URL is not set — skipping the PostgreSQL variant of '{tag}'");
return;
};
static PG_LOCK: Mutex<()> = Mutex::new(());
let _g = PG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
reset_tables(&url);
let uri = format!("oakdb+pg://{}", pg_body(&url));
f(&url, &uri);
}
// ---------------------------------------------------------------------------
// Round-trip through a fresh session
// ---------------------------------------------------------------------------
#[test]
fn pg_roundtrip_field_by_field() {
with_pg_uri("roundtrip_field_by_field", |url, uri| {
let backend = DatabaseBackend::new();
let project = build_full_project();
let uuid = uuid_of(&project);
save_project(&backend, &project, uri).unwrap();
// A new backend instance = a new session; the library row is
// selected explicitly by uuid.
let session = DatabaseBackend::new();
let (loaded_uuid, loaded) = load_project(&session, &project_uri(uri, &uuid));
assert_eq!(loaded_uuid, uuid);
{
let o = project.lock().unwrap();
let l = loaded.lock().unwrap();
assert_full_fields(&o, &l);
}
// The default (no ?project=) pick returns the same single row.
let session = DatabaseBackend::new();
let (loaded_uuid, _) = load_project(&session, uri);
assert_eq!(loaded_uuid, uuid);
// The settings mirror carries the current keys.
let uuid_c = uuid.clone();
let row = inspect_pg(url, move |conn| async move {
project::Entity::find()
.filter(project::Column::Uuid.eq(&uuid_c))
.one(&conn)
.await
.unwrap()
.unwrap()
.id
});
let mirrored = settings_rows_pg(url, row);
assert!(
mirrored.contains(&("projectname".to_string(), "full-fixture".to_string())),
"{mirrored:?}"
);
});
}
// ---------------------------------------------------------------------------
// Journal semantics: import, diff, no-op save
// ---------------------------------------------------------------------------
/// First save is one `kind='import'` command carrying every node plus
/// the settings pseudo-node (plan §2); the project row advances to seq 1.
#[test]
fn pg_first_save_is_an_import_command() {
with_pg_uri("first_save_is_an_import_command", |url, uri| {
let backend = DatabaseBackend::new();
let project = build_full_project();
let uuid = uuid_of(&project);
let node_count = project.lock().unwrap().graph.node_count();
save_project(&backend, &project, uri).unwrap();
let uuid_c = uuid.clone();
let rows = inspect_pg(url, move |conn| async move {
let proj = project::Entity::find()
.filter(project::Column::Uuid.eq(&uuid_c))
.one(&conn)
.await
.unwrap()
.unwrap();
let rows = journal::Entity::find()
.filter(journal::Column::ProjectId.eq(proj.id))
.order_by_asc(journal::Column::Seq)
.order_by_asc(journal::Column::NodeIdentity)
.all(&conn)
.await
.unwrap();
(proj.id, proj.command_seq, rows)
});
let (pid, command_seq, rows) = rows;
assert_eq!(command_seq, 1);
assert_eq!(
rows.len(),
node_count + 1,
"every node + the settings pseudo-node"
);
assert!(rows.iter().all(|r| r.seq == 1 && r.kind == KIND_IMPORT), "one import command");
assert!(rows.iter().all(|r| r.old_xml.is_none()), "import has no before images");
assert!(rows.iter().all(|r| r.new_xml.is_some()), "import has after images");
// The settings pseudo-node (identity 0) is present.
assert!(rows.iter().any(|r| r.node_identity == 0), "settings row present");
assert_eq!(
rows.iter().filter(|r| r.node_identity == 0).count(),
1,
"exactly one settings row"
);
// The real-node rows use the +1 offset (settings 0 reserved).
assert!(rows.iter().all(|r| r.node_identity >= 1 || r.node_identity == 0));
// The node fragments round-trip: the settings row holds the settings
// element.
let settings_row = rows.iter().find(|r| r.node_identity == 0).unwrap();
assert!(settings_row.new_xml.as_deref().unwrap().starts_with("<settings>"));
let _ = pid;
});
}
/// A later save is a diff: only the changed nodes land as `kind='redo'`
/// rows with both before and after images (plan §0).
#[test]
fn pg_later_saves_are_diffs() {
with_pg_uri("later_saves_are_diffs", |url, uri| {
let backend = DatabaseBackend::new();
let project = build_full_project();
let uuid = uuid_of(&project);
save_project(&backend, &project, uri).unwrap();
// Change one math node's value and one setting key.
{
let mut p = project.lock().unwrap();
let math_ids: Vec<NodeId> = p
.graph
.node_ids()
.into_iter()
.filter(|id| p.graph.get(*id).unwrap().behavior.type_id() == MATH)
.collect();
let a = math_ids[0];
p.graph
.get_mut(a)
.unwrap()
.core
.set_standard_value("param_a_in", -1, NodeValue::Float(9.5));
p.settings.insert("projectname".to_string(), "diffed".to_string());
}
save_project(&backend, &project, uri).unwrap();
let uuid_c = uuid.clone();
let rows = inspect_pg(url, move |conn| async move {
let proj = project::Entity::find()
.filter(project::Column::Uuid.eq(&uuid_c))
.one(&conn)
.await
.unwrap()
.unwrap();
let rows = journal::Entity::find()
.filter(journal::Column::ProjectId.eq(proj.id))
.all(&conn)
.await
.unwrap();
(proj.id, proj.command_seq, rows)
});
let (pid, command_seq, rows) = rows;
assert_eq!(command_seq, 2);
let seq2: Vec<&journal::Model> = rows.iter().filter(|r| r.seq == 2).collect();
assert_eq!(seq2.len(), 2, "one changed node + the settings row, nothing else");
assert!(seq2.iter().all(|r| r.kind == KIND_REDO));
assert!(
seq2.iter().all(|r| r.old_xml.is_some() && r.new_xml.is_some()),
"diff carries both images"
);
let settings_row = seq2.iter().find(|r| r.node_identity == 0).unwrap();
assert!(settings_row.new_xml.as_deref().unwrap().contains(">diffed<"));
let _ = pid;
// Head state after the diff reflects both changes.
let session = DatabaseBackend::new();
let (_, loaded) = load_project(&session, &project_uri(uri, &uuid));
{
let l = loaded.lock().unwrap();
let math_ids: Vec<NodeId> = l
.graph
.node_ids()
.into_iter()
.filter(|id| l.graph.get(*id).unwrap().behavior.type_id() == MATH)
.collect();
assert_eq!(
l.graph.get(math_ids[0]).unwrap().core.standard_value("param_a_in", -1),
NodeValue::Float(9.5)
);
assert_eq!(
l.settings.get("projectname").cloned(),
Some("diffed".to_string())
);
}
});
}
/// A save that changes nothing bumps no command seq and writes no rows.
#[test]
fn pg_no_op_save_is_a_touch_only() {
with_pg_uri("no_op_save_is_a_touch_only", |url, uri| {
let backend = DatabaseBackend::new();
let project = build_full_project();
let uuid = uuid_of(&project);
save_project(&backend, &project, uri).unwrap();
save_project(&backend, &project, uri).unwrap();
let uuid_c = uuid.clone();
let (head, count) = inspect_pg(url, move |conn| async move {
let proj = project::Entity::find()
.filter(project::Column::Uuid.eq(&uuid_c))
.one(&conn)
.await
.unwrap()
.unwrap();
let n = journal::Entity::find()
.filter(journal::Column::ProjectId.eq(proj.id))
.count(&conn)
.await
.unwrap();
(proj.command_seq, n)
});
assert_eq!(head, 1, "no-op save keeps the head seq");
// The import wrote 12 nodes + 1 settings row; the no-op added none.
assert_eq!(count, 13, "only the import rows remain");
});
}
// ---------------------------------------------------------------------------
// Snapshot + journal replay and undo to any point
// ---------------------------------------------------------------------------
/// With `Storage/SnapshotIntervalSec` ≤ 0 every dirty save writes a
/// snapshot; the newest snapshot is the replay base and the journal rows
/// after it are applied on top. Deleting every snapshot still recovers
/// the same state from an empty base plus the full journal (plan §0).
#[test]
fn pg_snapshot_and_journal_replay() {
with_pg_uri("snapshot_and_journal_replay", |url, uri| {
let backend = DatabaseBackend::new();
with_config("Storage", "SnapshotIntervalSec", 0, || {
let project = build_full_project();
let uuid = uuid_of(&project);
save_project(&backend, &project, uri).unwrap();
// Second command: one value change.
{
let mut p = project.lock().unwrap();
let math_ids: Vec<NodeId> = p
.graph
.node_ids()
.into_iter()
.filter(|id| p.graph.get(*id).unwrap().behavior.type_id() == MATH)
.collect();
p.graph
.get_mut(math_ids[0])
.unwrap()
.core
.set_standard_value("param_a_in", -1, NodeValue::Float(7.25));
}
save_project(&backend, &project, uri).unwrap();
// Both saves produced snapshots (head seq 2).
let uuid_c = uuid.clone();
let snaps = inspect_pg(url, move |conn| async move {
let proj = project::Entity::find()
.filter(project::Column::Uuid.eq(&uuid_c))
.one(&conn)
.await
.unwrap()
.unwrap();
snapshot::Entity::find()
.filter(snapshot::Column::ProjectId.eq(proj.id))
.all(&conn)
.await
.unwrap()
});
assert_eq!(snaps.len(), 2, "one snapshot per dirty save");
// Load (head): snapshot seq 2 is the base, nothing after it.
let session = DatabaseBackend::new();
let (_, loaded) = load_project(&session, &project_uri(uri, &uuid));
{
let o = project.lock().unwrap();
let l = loaded.lock().unwrap();
assert_full_fields(&o, &l);
}
// Destroy the snapshots: replay degrades to empty base + full
// journal and still reconstructs the head.
let uuid_c = uuid.clone();
inspect_pg(url, move |conn| async move {
let proj = project::Entity::find()
.filter(project::Column::Uuid.eq(&uuid_c))
.one(&conn)
.await
.unwrap()
.unwrap();
snapshot::Entity::delete_many()
.filter(snapshot::Column::ProjectId.eq(proj.id))
.exec(&conn)
.await
.unwrap();
});
let session = DatabaseBackend::new();
let (_, loaded) = load_project(&session, &project_uri(uri, &uuid));
{
let o = project.lock().unwrap();
let l = loaded.lock().unwrap();
assert_full_fields(&o, &l);
}
});
});
}
/// The journal is the persistent undo history: `load_at(seq)` replays to
/// any point (plan §0 "撤销到任意点").
#[test]
fn pg_undo_to_any_point() {
with_pg_uri("undo_to_any_point", |_url, uri| {
let backend = DatabaseBackend::new();
let project = build_full_project();
let uuid = uuid_of(&project);
let math_a = {
let p = project.lock().unwrap();
p.graph
.node_ids()
.into_iter()
.find(|id| p.graph.get(*id).unwrap().behavior.type_id() == MATH)
.unwrap()
};
// Command 1: value 2.5 (fixture default).
save_project(&backend, &project, uri).unwrap();
// Command 2: value 6.0.
{
let mut p = project.lock().unwrap();
p.graph
.get_mut(math_a)
.unwrap()
.core
.set_standard_value("param_a_in", -1, NodeValue::Float(6.0));
}
save_project(&backend, &project, uri).unwrap();
// Command 3: add a node.
let (new_id, value) = {
let mut p = project.lock().unwrap();
let (core, behavior) = (oaknode::factory::Factory::global().find(MATH).unwrap().create)();
let id = p.graph.add_node(core, behavior);
p.graph
.get_mut(id)
.unwrap()
.core
.set_standard_value("param_a_in", -1, NodeValue::Float(11.0));
let v = p
.graph
.get(id)
.unwrap()
.core
.standard_value("param_a_in", -1);
(id, v)
};
save_project(&backend, &project, uri).unwrap();
// Head (seq 3): the extra node exists.
let session = DatabaseBackend::new();
let (_, head) = load_project(&session, &project_uri(uri, &uuid));
{
let h = head.lock().unwrap();
assert!(h.graph.is_valid(new_id), "node added in command 3 is live");
assert_eq!(
h.graph.get(new_id).unwrap().core.standard_value("param_a_in", -1),
value
);
}
// Undo to seq 2: the node is gone, the value is 6.0.
let session = DatabaseBackend::new();
let at2 = load_at(&session, uri, &uuid, 2);
{
let l = at2.lock().unwrap();
assert!(!l.graph.is_valid(new_id), "command 3 rolled back");
let id = l
.graph
.node_ids()
.into_iter()
.find(|id| l.graph.get(*id).unwrap().behavior.type_id() == MATH)
.unwrap();
assert_eq!(
l.graph.get(id).unwrap().core.standard_value("param_a_in", -1),
NodeValue::Float(6.0)
);
}
// Undo to seq 1: the value is back to the fixture default.
let session = DatabaseBackend::new();
let at1 = load_at(&session, uri, &uuid, 1);
{
let l = at1.lock().unwrap();
assert_eq!(l.graph.node_count(), 12, "fixture node count");
let id = l
.graph
.node_ids()
.into_iter()
.find(|id| l.graph.get(*id).unwrap().behavior.type_id() == MATH)
.unwrap();
assert_eq!(
l.graph.get(id).unwrap().core.standard_value("param_a_in", -1),
NodeValue::Float(2.5)
);
}
// Undo to seq 0: an empty project.
let session = DatabaseBackend::new();
let at0 = load_at(&session, uri, &uuid, 0);
{
let l = at0.lock().unwrap();
assert_eq!(l.graph.node_count(), 0, "empty project at seq 0");
}
// Out of range -> E_INVALID.
let session = DatabaseBackend::new();
assert_eq!(
session
.load_at(&StorageUri::parse(uri).unwrap(), &uuid, 99)
.err()
.unwrap()
.code(),
OAKSTORAGE_E_INVALID
);
});
}
/// Snapshot pruning keeps the newest [`SNAPSHOT_KEEP`] copies.
#[test]
fn pg_snapshot_pruning_keeps_three() {
with_pg_uri("snapshot_pruning_keeps_three", |url, uri| {
let backend = DatabaseBackend::new();
with_config("Storage", "SnapshotIntervalSec", 0, || {
let project = build_full_project();
let uuid = uuid_of(&project);
// 6 commands, each dirty-snapshotted.
for i in 0..6i64 {
{
let mut p = project.lock().unwrap();
let id = p.graph.node_ids()[0];
p.graph.get_mut(id).unwrap().core.label = format!("step {i}");
}
save_project(&backend, &project, uri).unwrap();
}
let uuid_c = uuid.clone();
let seqs = inspect_pg(url, move |conn| async move {
let proj = project::Entity::find()
.filter(project::Column::Uuid.eq(&uuid_c))
.one(&conn)
.await
.unwrap()
.unwrap();
snapshot::Entity::find()
.filter(snapshot::Column::ProjectId.eq(proj.id))
.order_by_desc(snapshot::Column::CommandSeq)
.all(&conn)
.await
.unwrap()
.into_iter()
.map(|s| s.command_seq)
.collect::<Vec<i64>>()
});
assert_eq!(seqs.len() as u64, SNAPSHOT_KEEP, "only the newest {SNAPSHOT_KEEP}");
assert_eq!(seqs[0], 6, "the newest survives");
});
});
}
/// Journal retention (`Storage/JournalRetentionDays`): rows older than
/// the window and covered by the newest snapshot are dropped; the head
/// stays reconstructible (snapshot + remaining rows).
#[test]
fn pg_journal_retention_truncation() {
with_pg_uri("journal_retention_truncation", |url, uri| {
let backend = DatabaseBackend::new();
let project = build_full_project();
let uuid = uuid_of(&project);
// Command 1 (import) + command 2 (a value change).
save_project(&backend, &project, uri).unwrap();
{
let mut p = project.lock().unwrap();
let math_ids: Vec<NodeId> = p
.graph
.node_ids()
.into_iter()
.filter(|id| p.graph.get(*id).unwrap().behavior.type_id() == MATH)
.collect();
p.graph
.get_mut(math_ids[0])
.unwrap()
.core
.set_standard_value("param_a_in", -1, NodeValue::Float(3.25));
}
save_project(&backend, &project, uri).unwrap();
// Snapshot at the head so rows ≤ 2 are covered.
backend
.snapshot(&StorageUri::parse(uri).unwrap(), &uuid)
.unwrap();
// Backdate every journal row two days, then save a third command
// with a 1-day retention window.
inspect_pg(url, |conn| async move {
let old = chrono::Utc::now().naive_utc() - chrono::Duration::days(2);
journal::Entity::update_many()
.col_expr(journal::Column::At, sea_orm::sea_query::Expr::value(old))
.exec(&conn)
.await
.unwrap();
});
with_config("Storage", "JournalRetentionDays", 1, || {
{
let mut p = project.lock().unwrap();
let math_ids: Vec<NodeId> = p
.graph
.node_ids()
.into_iter()
.filter(|id| p.graph.get(*id).unwrap().behavior.type_id() == MATH)
.collect();
p.graph
.get_mut(math_ids[0])
.unwrap()
.core
.set_standard_value("param_a_in", -1, NodeValue::Float(4.5));
}
save_project(&backend, &project, uri).unwrap();
});
// Only the third command's rows survive.
let uuid_c = uuid.clone();
let (pid, rows) = inspect_pg(url, move |conn| async move {
let proj = project::Entity::find()
.filter(project::Column::Uuid.eq(&uuid_c))
.one(&conn)
.await
.unwrap()
.unwrap();
let rows = journal::Entity::find()
.filter(journal::Column::ProjectId.eq(proj.id))
.all(&conn)
.await
.unwrap();
(proj.id, rows)
});
let seqs: Vec<i64> = rows.iter().map(|r| r.seq).collect();
assert!(!seqs.contains(&1) && !seqs.contains(&2), "old commands pruned: {seqs:?}");
assert_eq!(seqs, vec![3], "only the fresh command remains");
let _ = pid;
// The head state is still correct (snapshot at seq 2 + command 3).
let session = DatabaseBackend::new();
let (_, loaded) = load_project(&session, &project_uri(uri, &uuid));
{
let l = loaded.lock().unwrap();
let math_ids: Vec<NodeId> = l
.graph
.node_ids()
.into_iter()
.filter(|id| l.graph.get(*id).unwrap().behavior.type_id() == MATH)
.collect();
assert_eq!(
l.graph.get(math_ids[0]).unwrap().core.standard_value("param_a_in", -1),
NodeValue::Float(4.5)
);
assert_eq!(l.graph.node_count(), 12);
}
});
}
// ---------------------------------------------------------------------------
// Project-manager API surface
// ---------------------------------------------------------------------------
#[test]
fn pg_list_delete_duplicate_rename() {
with_pg_uri("list_delete_duplicate_rename", |_url, uri| {
let backend = DatabaseBackend::new();
// Empty library lists nothing.
assert!(backend.list_projects(&StorageUri::parse(uri).unwrap()).unwrap().is_empty());
let alpha = save_named_project(&backend, uri, "Alpha");
let beta = save_named_project(&backend, uri, "Beta");
let list = backend.list_projects(&StorageUri::parse(uri).unwrap()).unwrap();
assert_eq!(list.len(), 2, "two rows");
// Most recently modified first.
assert_eq!(list[0].name, "Beta");
assert_eq!(list[1].name, "Alpha");
assert_eq!(list[0].command_seq, 1);
assert_eq!(list[0].schema_ver, oaknode::serializer::CURRENT_VERSION.0 as i32);
// Rename (library metadata).
backend
.rename_project(&StorageUri::parse(uri).unwrap(), &alpha, "Alpha Renamed")
.unwrap();
let list = backend.list_projects(&StorageUri::parse(uri).unwrap()).unwrap();
assert!(list.iter().any(|p| p.name == "Alpha Renamed"), "{list:?}");
assert_eq!(
backend
.rename_project(&StorageUri::parse(uri).unwrap(), "{missing}", "X")
.err()
.unwrap()
.code(),
OAKSTORAGE_E_NOT_FOUND
);
// Duplicate: fresh uuid, default "(copy)" name, history copied.
let copy = backend
.duplicate_project(&StorageUri::parse(uri).unwrap(), &beta, None)
.unwrap();
assert_ne!(copy.uuid, beta, "fresh uuid");
assert_eq!(copy.name, "Beta (copy)");
assert_eq!(copy.command_seq, 1);
let list = backend.list_projects(&StorageUri::parse(uri).unwrap()).unwrap();
assert_eq!(list.len(), 3);
let renamed_copy = backend
.duplicate_project(&StorageUri::parse(uri).unwrap(), &beta, Some("Beta Clone"))
.unwrap();
assert_eq!(renamed_copy.name, "Beta Clone");
assert_eq!(
backend
.duplicate_project(&StorageUri::parse(uri).unwrap(), "{missing}", None)
.err()
.unwrap()
.code(),
OAKSTORAGE_E_NOT_FOUND
);
// Delete.
for uuid in [&alpha, &beta, &copy.uuid, &renamed_copy.uuid] {
backend
.delete_project(&StorageUri::parse(uri).unwrap(), uuid)
.unwrap();
}
assert!(backend.list_projects(&StorageUri::parse(uri).unwrap()).unwrap().is_empty());
assert_eq!(
backend
.delete_project(&StorageUri::parse(uri).unwrap(), "{missing}")
.err()
.unwrap()
.code(),
OAKSTORAGE_E_NOT_FOUND
);
});
}
/// Duplicating a full-feature project copies the whole history: the copy
/// loads identically under its own uuid and keeps the undo history.
#[test]
fn pg_duplicate_preserves_history() {
with_pg_uri("duplicate_preserves_history", |_url, uri| {
let backend = DatabaseBackend::new();
let project = build_full_project();
let uuid = uuid_of(&project);
save_project(&backend, &project, uri).unwrap();
{
let mut p = project.lock().unwrap();
let math_ids: Vec<NodeId> = p
.graph
.node_ids()
.into_iter()
.filter(|id| p.graph.get(*id).unwrap().behavior.type_id() == MATH)
.collect();
p.graph
.get_mut(math_ids[0])
.unwrap()
.core
.set_standard_value("param_a_in", -1, NodeValue::Float(6.0));
}
save_project(&backend, &project, uri).unwrap();
let copy = backend
.duplicate_project(&StorageUri::parse(uri).unwrap(), &uuid, None)
.unwrap();
assert_eq!(copy.command_seq, 2);
// The copy loads through a fresh session, field-for-field (uuid is
// fresh by design).
let session = DatabaseBackend::new();
let (loaded_uuid, loaded) = load_project(&session, &project_uri(uri, &copy.uuid));
assert_eq!(loaded_uuid, copy.uuid);
{
let o = project.lock().unwrap();
let l = loaded.lock().unwrap();
assert_full_state(&o, &l);
}
// Undo history travels with the copy: load_at(1) on the copy gives
// the pre-change state.
let session = DatabaseBackend::new();
let at1 = load_at(&session, uri, &copy.uuid, 1);
{
let l = at1.lock().unwrap();
let math_ids: Vec<NodeId> = l
.graph
.node_ids()
.into_iter()
.filter(|id| l.graph.get(*id).unwrap().behavior.type_id() == MATH)
.collect();
assert_eq!(
l.graph.get(math_ids[0]).unwrap().core.standard_value("param_a_in", -1),
NodeValue::Float(2.5)
);
}
});
}
/// Manager stats are derived from the node graph, not stored (plan §4).
#[test]
fn pg_project_stats_derived_from_graph() {
with_pg_uri("project_stats_derived_from_graph", |_url, uri| {
let backend = DatabaseBackend::new();
let project = build_full_project();
let uuid = uuid_of(&project);
save_project(&backend, &project, uri).unwrap();
let stats = backend
.project_stats(&StorageUri::parse(uri).unwrap(), &uuid)
.unwrap();
assert_eq!(
stats,
ProjectStats {
duration_ms: 6000,
track_count: 1,
clip_count: 2,
footage_count: 2,
}
);
// Same numbers derive_stats yields directly on the live project.
let guard = project.lock().unwrap();
assert_eq!(derive_stats(&guard), stats);
assert_eq!(
backend
.project_stats(&StorageUri::parse(uri).unwrap(), "{missing}")
.err()
.unwrap()
.code(),
OAKSTORAGE_E_NOT_FOUND
);
});
}
#[test]
fn pg_export_and_import_round_trip() {
with_pg_uri("export_and_import_round_trip", |_url, uri| {
let dir = temp_dir("pg_xi");
let backend = DatabaseBackend::new();
let project = build_full_project();
let uuid = uuid_of(&project);
save_project(&backend, &project, uri).unwrap();
// Export: .ove written from an in-memory assembly; the file backend
// re-imports it byte-for-byte.
let out = dir.join("exported.ove");
let file = file_uri(&out);
backend
.export_to_file(
&StorageUri::parse(uri).unwrap(),
&uuid,
&StorageUri::parse(&file).unwrap(),
)
.unwrap();
assert!(out.exists(), ".ove written");
let text = std::fs::read_to_string(&out).unwrap();
assert!(text.starts_with("<project version=\"1\">"), "{text}");
let file_backend = Registry::global()
.resolve(&StorageUri::parse(&file).unwrap())
.unwrap();
let result = file_backend
.load(&StorageUri::parse(&file).unwrap())
.unwrap();
assert_eq!(result.version_info, OAKSTORAGE_OK);
let loaded = unsafe { project_arc(&result.project) }.unwrap();
release(result.project);
{
let o = project.lock().unwrap();
let l = loaded.lock().unwrap();
assert_full_fields(&o, &l);
}
// Import: the .ove lands as a new library row under a fresh uuid;
// importing it again yields a distinct row.
let imported = backend
.import_from_file(
&StorageUri::parse(uri).unwrap(),
&StorageUri::parse(&file).unwrap(),
)
.unwrap();
assert_ne!(imported, uuid, "import gets a fresh uuid");
let imported2 = backend
.import_from_file(
&StorageUri::parse(uri).unwrap(),
&StorageUri::parse(&file).unwrap(),
)
.unwrap();
assert_ne!(imported2, imported, "repeat imports are new rows");
let list = backend.list_projects(&StorageUri::parse(uri).unwrap()).unwrap();
assert_eq!(list.len(), 3, "original + two imports");
let session = DatabaseBackend::new();
let (imported_uuid, imported_proj) = load_project(&session, &project_uri(uri, &imported));
assert_eq!(imported_uuid, imported);
{
let l = imported_proj.lock().unwrap();
assert_eq!(l.graph.node_count(), 12);
assert_eq!(
l.settings.get("projectname").cloned(),
Some("full-fixture".to_string())
);
}
// Error paths: unknown project on export; non-file target; corrupt
// file on import.
assert_eq!(
backend
.export_to_file(
&StorageUri::parse(uri).unwrap(),
"{missing}",
&StorageUri::parse(&file).unwrap(),
)
.err()
.unwrap()
.code(),
OAKSTORAGE_E_NOT_FOUND
);
assert_eq!(
backend
.export_to_file(
&StorageUri::parse(uri).unwrap(),
&uuid,
&StorageUri::parse("oakdb+sqlite:///tmp/x.db").unwrap(),
)
.err()
.unwrap()
.code(),
OAKSTORAGE_E_INVALID
);
let corrupt = dir.join("corrupt.ove");
std::fs::write(&corrupt, "<project><nodes><node></project>").unwrap();
assert_eq!(
backend
.import_from_file(
&StorageUri::parse(uri).unwrap(),
&StorageUri::parse(&file_uri(&corrupt)).unwrap(),
)
.err()
.unwrap()
.code(),
OAKSTORAGE_E_FORMAT
);
});
}
// ---------------------------------------------------------------------------
// Project selection (`?project=` vs default)
// ---------------------------------------------------------------------------
#[test]
fn pg_project_selection_via_query() {
with_pg_uri("project_selection_via_query", |_url, uri| {
let backend = DatabaseBackend::new();
let a = save_named_project(&backend, uri, "A");
let b = save_named_project(&backend, uri, "B");
// Explicit uuid picks the right row regardless of recency.
let session = DatabaseBackend::new();
let (loaded_uuid, _) = load_project(&session, &project_uri(uri, &a));
assert_eq!(loaded_uuid, a);
// Default pick = most recently modified (B was written last).
let session = DatabaseBackend::new();
let (loaded_uuid, loaded) = load_project(&session, uri);
assert_eq!(loaded_uuid, b);
assert_eq!(
loaded.lock().unwrap().settings.get("projectname").cloned(),
Some("B".to_string())
);
// Unknown uuid -> E_NOT_FOUND; empty library -> E_NOT_FOUND.
let session = DatabaseBackend::new();
assert_eq!(
session
.load(&StorageUri::parse(&project_uri(uri, "{missing}")).unwrap())
.err()
.unwrap()
.code(),
OAKSTORAGE_E_NOT_FOUND
);
});
}
+58 -468
View File
@@ -22,459 +22,33 @@
//! journal (import + diff), snapshot + journal replay, undo to any
//! point, snapshot pruning and journal retention, the project-manager
//! API surface (list/delete/duplicate/rename/import/export) and the
//! error/URI matrices. Nothing touches a real library.
//! error/URI matrices. Nothing touches a real library. The same
//! behaviors run against real PostgreSQL in `database_pg_test.rs`
//! (gated on `OAK_TEST_PG_URL`); both suites share the fixtures and
//! helpers in `tests/common/mod.rs`.
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
mod common;
use std::path::PathBuf;
use sea_orm::entity::prelude::*;
use sea_orm::QueryOrder;
use oakcore_rs::Rational;
use oaknode::block::ClipBlockBehavior;
use oaknode::footage::FootageBehavior;
use oaknode::id::NodeId;
use oaknode::keyframe::{Interpolation, Keyframe};
use oaknode::node::NodeCore;
use oaknode::project::Project;
use oaknode::sequence::SequenceBehavior;
use oaknode::track::{TrackBehavior, TrackListBehavior, TrackType};
use oaknode::value::NodeValue;
use oakstorage::backend::StorageBackend;
use oakstorage::backends::database::entities::{journal, project, settings, snapshot};
use oakstorage::backends::database::{derive_stats, DatabaseBackend, KIND_IMPORT, KIND_REDO, ProjectStats, SNAPSHOT_KEEP};
use oakstorage::backends::database::entities::{journal, project, snapshot};
use oakstorage::backends::database::{
derive_stats, DatabaseBackend, KIND_IMPORT, KIND_REDO, ProjectStats, SNAPSHOT_KEEP,
};
use oakstorage::error::{
OAKSTORAGE_E_FORMAT, OAKSTORAGE_E_INVALID, OAKSTORAGE_E_IO, OAKSTORAGE_E_NO_BACKEND,
OAKSTORAGE_E_NOT_FOUND, OAKSTORAGE_OK,
};
use oakstorage::handle::CHandle;
use oakstorage::nodeutil::{make_project_owned, project_arc};
use oakstorage::nodeutil::project_arc;
use oakstorage::registry::Registry;
use oakstorage::uri::StorageUri;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// A fresh, unique temp directory for one test.
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("oakstorage_db_{}_{}", std::process::id(), tag));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// `oakdb+sqlite:///…` URI for a database file.
fn db_uri(path: &Path) -> String {
format!("oakdb+sqlite://{}", path.display())
}
/// `oakdb+sqlite:///…?project=<uuid>` URI selecting one library row.
fn project_uri(db: &str, uuid: &str) -> String {
format!("{db}?project={uuid}")
}
/// `file://…` URI for a plain file.
fn file_uri(path: &Path) -> String {
format!("file://{}", path.display())
}
/// Release an owned handle (refcount 1).
fn release(h: CHandle) {
if let Some(release) = h.release {
unsafe { release(h.ctx) };
}
}
/// Save an `Arc<Mutex<Project>>` through the database backend.
fn save_project(
backend: &DatabaseBackend,
project: &Arc<Mutex<Project>>,
uri: &str,
) -> oakstorage::error::Result<()> {
let parsed = StorageUri::parse(uri).unwrap();
let handle = make_project_owned(project.clone());
let result = backend.save(handle, &parsed, 0);
release(handle);
result
}
/// Load a project through a *new* database backend session (a fresh
/// connection pool = a fresh session), returning `(uuid, loaded)`.
fn load_project(backend: &DatabaseBackend, uri: &str) -> (String, Arc<Mutex<Project>>) {
let parsed = StorageUri::parse(uri).unwrap();
let result = backend.load(&parsed).unwrap();
assert_eq!(result.version_info, OAKSTORAGE_OK);
let handle = result.project;
let loaded = unsafe { project_arc(&handle) }.unwrap();
let uuid = loaded.lock().unwrap().uuid.clone();
release(handle);
(uuid, loaded)
}
/// Load the state at `seq` (undo to any point).
fn load_at(backend: &DatabaseBackend, uri: &str, uuid: &str, seq: i64) -> Arc<Mutex<Project>> {
let parsed = StorageUri::parse(uri).unwrap();
let handle = backend.load_at(&parsed, uuid, seq).unwrap();
let loaded = unsafe { project_arc(&handle) }.unwrap();
release(handle);
loaded
}
fn r_to_f(r: Rational) -> f64 {
r.numerator() as f64 / r.denominator() as f64
}
fn assert_close(a: f64, b: f64) {
assert!((a - b).abs() < 1e-6, "expected {a} close to {b}");
}
/// Open a raw sea-orm connection to the database file (for inspecting
/// and injecting rows behind the backend's back) and drive one future
/// against it on a private current-thread runtime. The connection is
/// handed to `f` by value (a clone of the pool).
fn inspect_db<R, Fut>(path: &Path, f: impl FnOnce(sea_orm::DatabaseConnection) -> Fut) -> R
where
Fut: std::future::Future<Output = R>,
{
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let options = sea_orm::sqlx::sqlite::SqliteConnectOptions::new()
.filename(path)
.create_if_missing(true)
.journal_mode(sea_orm::sqlx::sqlite::SqliteJournalMode::Wal)
.busy_timeout(Duration::from_secs(5))
.foreign_keys(true);
let pool = sea_orm::sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await
.unwrap();
f(sea_orm::DatabaseConnection::from(pool)).await
})
}
/// The settings mirror rows of a project.
fn settings_rows(path: &Path, project_id: i64) -> Vec<(String, String)> {
inspect_db(path, |conn| async move {
settings::Entity::find()
.filter(settings::Column::ProjectId.eq(project_id))
.all(&conn)
.await
.unwrap()
.into_iter()
.map(|s| (s.key, s.value))
.collect()
})
}
/// The project uuid.
fn uuid_of(project: &Arc<Mutex<Project>>) -> String {
project.lock().unwrap().uuid.clone()
}
/// Read a config value for the duration of a test (the config store is
/// process-global, so restore it afterwards).
fn with_config(group: &str, key: &str, value: i32, f: impl FnOnce()) {
let store = oakcommon::configstore::ConfigStore::instance();
let before = store.get_int(Some(group), key, 0);
store.set_int(Some(group), key, value);
f();
store.set_int(Some(group), key, before);
}
// ---------------------------------------------------------------------------
// Full-feature fixture (footage / sequence / track / clip / effect /
// keyframes) — the union of the ove round-trip and timeline fixtures.
// ---------------------------------------------------------------------------
const MATH: &str = "org.olivevideoeditor.Olive.math";
/// Build the round-trip fixture: root folder + two math nodes with
/// values/keyframes/label/color/link/connection + a sequence "My Seq"
/// with one video track carrying two clips (footage /a/b.mp4, /a/c.mp4).
fn build_full_project() -> Arc<Mutex<Project>> {
let project = Project::new();
let mut p = project.lock().unwrap();
p.initialize().unwrap();
let (core, behavior) = (oaknode::factory::Factory::global().find(MATH).unwrap().create)();
let a = p.graph.add_node(core, behavior);
{
let e = p.graph.get_mut(a).unwrap();
e.core.label = "Math A".to_string();
e.core.override_color = 2;
e.core.set_standard_value("param_a_in", -1, NodeValue::Float(2.5));
e.core
.keyframe_track_mut("param_a_in", -1)
.set_key(Keyframe {
time: Rational::new(0, 1),
value: NodeValue::Float(1.0),
interpolation: Interpolation::Linear,
bezier_in: (0.0, 0.0),
bezier_out: (0.0, 0.0),
});
e.core
.keyframe_track_mut("param_a_in", -1)
.set_key(Keyframe {
time: Rational::new(1, 1),
value: NodeValue::Float(3.0),
interpolation: Interpolation::Bezier,
bezier_in: (0.1, 0.2),
bezier_out: (0.3, 0.4),
});
}
let (core, behavior) = (oaknode::factory::Factory::global().find(MATH).unwrap().create)();
let b = p.graph.add_node(core, behavior);
p.graph
.get_mut(b)
.unwrap()
.core
.set_standard_value("param_a_in", -1, NodeValue::Float(4.0));
p.graph.connect(a, b, "param_b_in", -1).unwrap();
p.graph.link(a, b);
// Timeline: sequence + video track + two clips with footage.
let (seq_id, lists) = oakstorage::nodeutil::create_sequence(&mut p.graph);
p.graph.get_mut(seq_id).unwrap().core.label = "My Seq".to_string();
let mut tb = TrackBehavior::new(TrackType::Video);
tb.track_list = Some(lists[0]);
let track_id = p.graph.add_node(NodeCore::new(), Box::new(tb));
let foot1 = p
.graph
.add_node(NodeCore::new(), Box::new(FootageBehavior::new("/a/b.mp4")));
let clip1 = {
let (core, mut behavior) = oaknode::block::clip_create();
let clip = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
.unwrap();
clip.core.range = oakcore_rs::TimeRange::new(Rational::new(0, 1), Rational::new(100, 25));
clip.core.media_in = Rational::new(0, 1);
clip.core.track = Some(track_id);
clip.footage = Some(foot1);
p.graph.add_node(core, behavior)
};
let foot2 = p
.graph
.add_node(NodeCore::new(), Box::new(FootageBehavior::new("/a/c.mp4")));
let clip2 = {
let (core, mut behavior) = oaknode::block::clip_create();
let clip = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
.unwrap();
clip.core.range =
oakcore_rs::TimeRange::new(Rational::new(100, 25), Rational::new(150, 25));
clip.core.media_in = Rational::new(10, 25);
clip.core.track = Some(track_id);
clip.footage = Some(foot2);
p.graph.add_node(core, behavior)
};
if let Some(entry) = p.graph.get_mut(track_id) {
entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<TrackBehavior>())
.unwrap()
.blocks = vec![clip1, clip2];
}
if let Some(entry) = p.graph.get_mut(lists[0]) {
entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<TrackListBehavior>())
.unwrap()
.tracks = vec![track_id];
}
p.settings
.insert("projectname".to_string(), "full-fixture".to_string());
drop(p);
project
}
/// Field-by-field comparison of the full fixture after a round-trip
/// (uuid included).
fn assert_full_fields(orig: &Project, loaded: &Project) {
assert_eq!(loaded.uuid, orig.uuid, "uuid");
assert_full_state(orig, loaded);
}
/// Field-by-field comparison that ignores the uuid (a duplicated row
/// carries a fresh uuid by design).
fn assert_full_state(orig: &Project, loaded: &Project) {
assert_eq!(loaded.settings, orig.settings, "settings");
// Node set: count, type order (slot order preserved by the writer).
let o_ids = orig.graph.node_ids();
let l_ids = loaded.graph.node_ids();
assert_eq!(l_ids.len(), o_ids.len(), "node count");
let o_types: Vec<&str> = o_ids
.iter()
.map(|id| orig.graph.get(*id).unwrap().behavior.type_id())
.collect();
let l_types: Vec<&str> = l_ids
.iter()
.map(|id| loaded.graph.get(*id).unwrap().behavior.type_id())
.collect();
assert_eq!(l_types, o_types, "node types");
// Math A: label, color, value, keyframes.
let (a_o, a_l) = (o_ids[1], l_ids[1]);
assert_eq!(
loaded.graph.get(a_l).unwrap().core.label,
orig.graph.get(a_o).unwrap().core.label,
"label"
);
assert_eq!(
loaded.graph.get(a_l).unwrap().core.override_color,
orig.graph.get(a_o).unwrap().core.override_color,
"color"
);
assert_eq!(
loaded
.graph
.get(a_l)
.unwrap()
.core
.standard_value("param_a_in", -1),
orig.graph
.get(a_o)
.unwrap()
.core
.standard_value("param_a_in", -1),
"value a"
);
let keys_o = orig
.graph
.get(a_o)
.unwrap()
.core
.keyframe_track("param_a_in", -1)
.unwrap()
.keys()
.to_vec();
let keys_l = loaded
.graph
.get(a_l)
.unwrap()
.core
.keyframe_track("param_a_in", -1)
.unwrap()
.keys()
.to_vec();
assert_eq!(keys_l.len(), keys_o.len(), "keyframe count");
for (ko, kl) in keys_o.iter().zip(&keys_l) {
assert_eq!(kl.time, ko.time, "key time");
assert_eq!(kl.value.to_double(), ko.value.to_double(), "key value");
assert_eq!(kl.interpolation, ko.interpolation, "key interpolation");
assert_eq!(kl.bezier_in, ko.bezier_in, "key bezier in");
assert_eq!(kl.bezier_out, ko.bezier_out, "key bezier out");
}
// Connection a -> b.param_b_in and the link.
assert_eq!(
loaded.graph.connected_output(l_ids[2], "param_b_in", -1),
Some(a_l),
"connection"
);
assert!(loaded.graph.are_linked(a_l, l_ids[2]), "link");
// Timeline: sequence, track lists, track, clips, footage.
let mut seq: Option<(NodeId, Vec<NodeId>)> = None;
for id in loaded.graph.node_ids() {
let entry = loaded.graph.get(id).unwrap();
if entry.behavior.type_id() == "org.olivevideoeditor.Olive.sequence" {
let s = entry
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<SequenceBehavior>())
.unwrap();
seq = Some((id, s.track_lists.clone()));
break;
}
}
let (_seq_id, lists) = seq.expect("sequence present");
assert_eq!(lists.len(), 3, "video/audio/subtitle lists");
let list = loaded
.graph
.get(lists[0])
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<TrackListBehavior>())
.unwrap();
assert_eq!(list.kind, TrackType::Video);
assert_eq!(list.array_base, 0);
assert_eq!(list.tracks.len(), 1);
let track = loaded
.graph
.get(list.tracks[0])
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<TrackBehavior>())
.unwrap();
assert_eq!(track.kind, TrackType::Video);
assert_eq!(track.blocks.len(), 2);
let c1 = loaded
.graph
.get(track.blocks[0])
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<ClipBlockBehavior>())
.unwrap();
assert_close(r_to_f(c1.core.length()), 4.0);
assert_close(r_to_f(c1.core.media_in), 0.0);
let f1 = loaded
.graph
.get(c1.footage.unwrap())
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<FootageBehavior>())
.unwrap();
assert_eq!(f1.filename, "/a/b.mp4");
let c2 = loaded
.graph
.get(track.blocks[1])
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<ClipBlockBehavior>())
.unwrap();
assert_close(r_to_f(c2.core.length()), 2.0);
assert_close(r_to_f(c2.core.media_in), 0.4);
let f2 = loaded
.graph
.get(c2.footage.unwrap())
.unwrap()
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<FootageBehavior>())
.unwrap();
assert_eq!(f2.filename, "/a/c.mp4");
}
/// Build a small project with a `projectname` setting (fast saves).
fn build_named_project(name: &str) -> Arc<Mutex<Project>> {
let project = Project::new();
{
let mut p = project.lock().unwrap();
p.initialize().unwrap();
p.settings.insert("projectname".to_string(), name.to_string());
}
project
}
use common::*;
// ---------------------------------------------------------------------------
// Round-trip through a fresh session
@@ -508,7 +82,7 @@ fn roundtrip_field_by_field() {
assert_eq!(loaded_uuid, uuid);
// The settings mirror carries the current keys.
let row = inspect_db(&db, |conn| async move {
let row = inspect_sqlite(&db, |conn| async move {
project::Entity::find()
.filter(project::Column::Uuid.eq(&uuid))
.one(&conn)
@@ -566,7 +140,7 @@ fn first_save_is_an_import_command() {
let node_count = project.lock().unwrap().graph.node_count();
save_project(&backend, &project, &uri).unwrap();
let rows = inspect_db(&db, |conn| async move {
let rows = inspect_sqlite(&db, |conn| async move {
let proj = project::Entity::find()
.filter(project::Column::Uuid.eq(&uuid))
.one(&conn)
@@ -637,7 +211,7 @@ fn later_saves_are_diffs() {
save_project(&backend, &project, &uri).unwrap();
let uuid_q = uuid.clone();
let rows = inspect_db(&db, move |conn| async move {
let rows = inspect_sqlite(&db, move |conn| async move {
let proj = project::Entity::find()
.filter(project::Column::Uuid.eq(&uuid_q))
.one(&conn)
@@ -693,7 +267,7 @@ fn no_op_save_is_a_touch_only() {
save_project(&backend, &project, &uri).unwrap();
save_project(&backend, &project, &uri).unwrap();
let (head, count) = inspect_db(&db, |conn| async move {
let (head, count) = inspect_sqlite(&db, |conn| async move {
let proj = project::Entity::find()
.filter(project::Column::Uuid.eq(&uuid))
.one(&conn)
@@ -751,7 +325,7 @@ fn snapshot_and_journal_replay() {
// Both saves produced snapshots (head seq 2).
let uuid_q = uuid.clone();
let snaps = inspect_db(&db, move |conn| async move {
let snaps = inspect_sqlite(&db, move |conn| async move {
let proj = project::Entity::find()
.filter(project::Column::Uuid.eq(&uuid_q))
.one(&conn)
@@ -778,7 +352,7 @@ fn snapshot_and_journal_replay() {
// Destroy the snapshots: replay degrades to empty base + full
// journal and still reconstructs the head.
let uuid_q = uuid.clone();
inspect_db(&db, move |conn| async move {
inspect_sqlite(&db, move |conn| async move {
let proj = project::Entity::find()
.filter(project::Column::Uuid.eq(&uuid_q))
.one(&conn)
@@ -944,7 +518,7 @@ fn snapshot_pruning_keeps_three() {
}
save_project(&backend, &project, &uri).unwrap();
}
let seqs = inspect_db(&db, |conn| async move {
let seqs = inspect_sqlite(&db, |conn| async move {
let proj = project::Entity::find()
.filter(project::Column::Uuid.eq(&uuid))
.one(&conn)
@@ -1000,7 +574,7 @@ fn journal_retention_truncation() {
// Backdate every journal row two days, then save a third command
// with a 1-day retention window.
inspect_db(&db, |conn| async move {
inspect_sqlite(&db, |conn| async move {
let old = chrono::Utc::now().naive_utc() - chrono::Duration::days(2);
journal::Entity::update_many()
.col_expr(journal::Column::At, sea_orm::sea_query::Expr::value(old))
@@ -1028,7 +602,7 @@ fn journal_retention_truncation() {
// Only the third command's rows survive.
let uuid_q = uuid.clone();
let (pid, rows) = inspect_db(&db, move |conn| async move {
let (pid, rows) = inspect_sqlite(&db, move |conn| async move {
let proj = project::Entity::find()
.filter(project::Column::Uuid.eq(&uuid_q))
.one(&conn)
@@ -1070,13 +644,6 @@ fn journal_retention_truncation() {
// Project-manager API surface
// ---------------------------------------------------------------------------
fn save_named_project(backend: &DatabaseBackend, uri: &str, name: &str) -> String {
let project = build_named_project(name);
let uuid = uuid_of(&project);
save_project(backend, &project, uri).unwrap();
uuid
}
#[test]
fn list_delete_duplicate_rename() {
let dir = temp_dir("mgr");
@@ -1404,8 +971,13 @@ fn project_selection_via_query() {
// Error paths, locking, URI matrix
// ---------------------------------------------------------------------------
/// PG targets are now live: malformed connection strings fail cleanly at
/// parse time (E_INVALID, no network touched) and an unreachable server
/// fails cleanly at connect (E_IO, no panic). Both run without a real
/// PostgreSQL server — the connection-refused case uses port 1 on
/// loopback, which nothing listens on.
#[test]
fn pg_target_rejected_until_d3() {
fn pg_invalid_and_unreachable_targets_error_cleanly() {
let dir = temp_dir("pg");
let db = dir.join("lib.db");
let uri = db_uri(&db);
@@ -1414,27 +986,45 @@ fn pg_target_rejected_until_d3() {
save_project(&backend, &project, &uri).unwrap();
let uuid = uuid_of(&project);
let pg = "oakdb+pg://user:pass@host:5432/db";
// Invalid connection strings -> E_INVALID at parse (no connection).
for bad in ["oakdb+pg://", "oakdb+pg://?project={x}", "oakdb+pg://user@"] {
let err = backend
.list_projects(&StorageUri::parse(bad).unwrap())
.err()
.unwrap();
assert_eq!(err.code(), OAKSTORAGE_E_INVALID, "{bad}");
}
// Unreachable server (port 1 on loopback) -> E_IO at connect, and the
// error is clean (no panic) across every operation. The connect probe
// fails on the refused TCP connection without waiting out the pool's
// acquire timeout.
let dead = "oakdb+pg://user@127.0.0.1:1/db";
for call in [
backend.load(&StorageUri::parse(pg).unwrap()).map(|_| ()),
backend.save(CHandle::null(), &StorageUri::parse(pg).unwrap(), 0),
backend.list_projects(&StorageUri::parse(pg).unwrap()).map(|_| ()),
backend.delete_project(&StorageUri::parse(pg).unwrap(), &uuid),
backend.rename_project(&StorageUri::parse(pg).unwrap(), &uuid, "X"),
backend.load(&StorageUri::parse(dead).unwrap()).map(|_| ()),
save_project(&backend, &project, dead),
backend.list_projects(&StorageUri::parse(dead).unwrap()).map(|_| ()),
backend.delete_project(&StorageUri::parse(dead).unwrap(), &uuid),
backend.rename_project(&StorageUri::parse(dead).unwrap(), &uuid, "X"),
backend
.duplicate_project(&StorageUri::parse(pg).unwrap(), &uuid, None)
.duplicate_project(&StorageUri::parse(dead).unwrap(), &uuid, None)
.map(|_| ()),
backend
.export_to_file(
&StorageUri::parse(pg).unwrap(),
&StorageUri::parse(dead).unwrap(),
&uuid,
&StorageUri::parse("file:///tmp/x.ove").unwrap(),
),
backend.snapshot(&StorageUri::parse(pg).unwrap(), &uuid),
backend.load_at(&StorageUri::parse(pg).unwrap(), &uuid, 1).map(|_| ()),
backend.snapshot(&StorageUri::parse(dead).unwrap(), &uuid),
backend.load_at(&StorageUri::parse(dead).unwrap(), &uuid, 1).map(|_| ()),
] {
assert_eq!(call.err().unwrap().code(), OAKSTORAGE_E_NO_BACKEND, "pg target");
assert_eq!(call.err().unwrap().code(), OAKSTORAGE_E_IO, "dead pg target");
}
// The sqlite library next to it is untouched by all of the above.
let session = DatabaseBackend::new();
let (loaded_uuid, _) = load_project(&session, &project_uri(&uri, &uuid));
assert_eq!(loaded_uuid, uuid);
}
#[test]
@@ -1606,7 +1196,7 @@ fn concurrent_writers_are_serialized() {
let session = DatabaseBackend::new();
let (loaded_uuid, _) = load_project(&session, &project_uri(&uri, &same));
assert_eq!(loaded_uuid, same, "library consistent after races");
let (head, seqs) = inspect_db(&db, |conn| async move {
let (head, seqs) = inspect_sqlite(&db, |conn| async move {
let proj = project::Entity::find()
.filter(project::Column::Uuid.eq(&same))
.one(&conn)
+29 -2
View File
@@ -91,12 +91,39 @@ removed clips.
or otio backend; nothing is read from or written to the database
beyond the current state.
## Configuration
Library selection and behavior are driven by the `Storage` config group
(read by `crates/oakengine/src/storage.rs`):
- `Storage/Backend``"sqlite"` (the documented default), `"database"`
or `"pg"` enables write-through; any other value (e.g. `"off"`)
disables it. When the key is absent no library is configured and
projects stay unbound (headless consumers and the test suite never
touch the user's real library).
- `Storage/SqlitePath` — the SQLite library file; default
`<system data dir>/library.db` (honoring `OAK_CONFIG_DIR`).
- `Storage/PgUrl` — the PostgreSQL connection string, used when
`Backend = "pg"`: `user:pass@host:5432/dbname` (libpq URL form; an
optional `postgres://`/`postgresql://` scheme is stripped). The
resolved library URI is `oakdb+pg://<PgUrl>`.
- `Storage/SnapshotIntervalSec` (default 600) and
`Storage/JournalRetentionDays` (default 0 = keep forever) — see above.
## Multi-writer and platforms
v1 assumes a single writer per database (SQLite `busy_timeout`, PG row
locks). Multi-writer collaboration is future work (M14). The default
database is a single user-level SQLite file; PostgreSQL is selected
with an `oakdb+pg://` URI.
database is a single user-level SQLite file; PostgreSQL is selected with
`Storage/Backend = "pg"` + `Storage/PgUrl`, or directly with an
`oakdb+pg://` URI.
Database tests: `cargo test -p oakstorage` is green without PostgreSQL —
the SQLite suite always runs; the PG suite (`tests/database_pg_test.rs`)
connects to a real server when `OAK_TEST_PG_URL` is set (e.g.
`postgres://user:pass@host:5432/db`) and skips with a note otherwise.
The URL should point at a dedicated test database: each test resets the
four tables.
See also: [M10 oakstorage manual](plans/riir/M10-oakstorage.md),
[M13 write-through plan](plans/riir/M13-storage-live.md),
Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 KiB

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 441 KiB

After

Width:  |  Height:  |  Size: 438 KiB

+42
View File
@@ -109,6 +109,24 @@ facade 的 undo 推送路径挂钩(`oakengine_undo_push` / `undo_group_end`
> 键缺失 = "无库配置"(工程不绑定、写穿不触发)——这是 §2"无库配置优雅
> 降级"的默认形态,保证 headless 消费者(oak-cli)与测试进程永远不写
> 用户的真实库。app 侧(D4/D5)在启动时显式设置该配置即可启用。
>
> D3 落地记录(2026-08):`oakdb+pg://` 全量走通(load/save/load_at/
> snapshot/list/delete/duplicate/rename/export/import)。方言差异收敛在
> 连接与 migration 两层:`crates/oakstorage/src/backends/database/
> migration.rs` 按 `DatabaseBackend` 选 SQLite/PG DDLPG 仅 `BIGSERIAL`
> PK + `BIGINT` FK,其余同构;`CREATE TABLE IF NOT EXISTS` 幂等,每次
> 连接时执行;payload 维持 TEXT,不做 BYTEA);sea-orm 实体与 save/replay
> 逻辑两库共用零分支。`connect_pg` 先单次直连探测(pool 对被拒连接会
> 退避重试到 acquire 超时,死库会挂起数秒),失败映射干净 E_IO。
> **配置**`Storage/Backend = "pg"` 启用,`Storage/PgUrl` 给连接串
> `user:pass@host:5432/dbname`,容忍 `postgres://` 前缀,oakdb uri
> 剥掉);URI 的 `?project=` 选择器只在 query 含 `project=` 键时生效,
> 否则整段(含 `?sslmode=…`)视为连接串。**测试矩阵**SQLite 18 个
> 常驻;PG 13 个变体(round-trip/journal 语义/快照重放/撤销/清理/管理
> API/导入导出/选择)门控 `OAK_TEST_PG_URL`,未设置则早退打印说明
> (CI 无 PG 也绿);非法连接串(E_INVALIDparse 期)+ 连不上(E_IO
> 单次探测)为常驻错误路径测试,无需真实 PG。共享 fixture 抽到
> `tests/common/mod.rs`(建 fixture/保存加载/行检查 sqlite+pg 双探针)。
## 4. 项目管理器窗口(app
@@ -119,6 +137,30 @@ facade 的 undo 推送路径挂钩(`oakengine_undo_push` / `undo_group_end`
- 导入 .ove/.otio/.fcpxml 为新库行;选中工程导出为 .ove/.otio/.fcpxml。
- 数据源走 oakstorage 会话 APIRust 直调;需要 C ABI 时 facade 只增)。
> D4 落地记录(2026-08):app 只链接 `liboakengine` dylib,故数据源走
> 新增 C ABI`crates/oakengine/src/library.rs`,只增):
> `oakengine_library_list`JSON 行:uuid/name/created/modified + 派生
> 统计)/ `_create` / `_delete` / `_rename` / `_duplicate` / `_import`
> / `_export`(按扩展名走 oakstorage registry 分发 ove-xml 或 otio 后端)
> / `oakengine_project_load_library`(载入并**绑定**写穿会话,与
> `project_load` 同契约)。有副作用的 create/duplicate/import 返回 uuid
> 用单次定长缓冲调用(不能两段式 measure-then-read——会在 C 侧执行两次)。
> app 侧:`src/manager.rs``ProjectManager` 内容视图 + `ManagerEvent`
> 请求枚举 + `NamePrompt`/`ConfirmContent` 子对话框;时间/时长格式化为
> 无依赖纯函数)。`src/app.rs`:无 `--project` 启动时开管理器(驱动根
> entity,不能走 `WindowHandle::update`——`spawn_modal` 的
> `update_window` 会重入失败);`run_with` 启动时
> `real::configure_storage()`(仅在 `Storage/Backend` 未配置时设
> `sqlite`,路径用 facade 默认 `<数据目录>/library.db`),退出前
> `storage_flush()`。菜单语义(提前并入 D5):保存/另存为 →
> 导出工程文件…(⌘S,扩展名分发 ove/otio/fcpxml);打开 → 从库中打开…
> (= 管理器)+ 打开工程文件…;新增 项目管理器…。状态栏自动保存段 →
> 库写入状态(`storage_bound` / `last_error`,失败红色)。MockEngine 带
> 内存假库(3 行种子 + 全操作),app gpui 测试覆盖建/删/复制/重命名/
> 导入/导出/打开;facade 侧 `it_library.rs` 5 测试(建/列/打开绑定写穿/
> 重命名复制删除/导出导入 round-trip/禁用降级)。截图
> docs/screenshot-manager.png + -en.png。
## 5. 分期与判据
| 期 | 内容 | 完成判据 |
+26 -2
View File
@@ -74,11 +74,35 @@ clip 行自带时间线区间(`<range in out/>`)、媒体偏移(`<media_in
- 导出:内存序列化经 ove-xml 或 otio 后端写出;除当前状态外不读写
数据库。
## 配置
库的选择与行为全部由 `Storage` 配置组驱动(写穿由
`crates/oakengine/src/storage.rs` 读取):
- `Storage/Backend``"sqlite"`(默认值)、`"database"``"pg"`
启用写穿;其它值(如 `"off"`)禁用。键缺失 = 无库配置,工程不
绑定库(headless 消费者与测试进程永不写用户真实库)。
- `Storage/SqlitePath` — SQLite 库文件;默认
`<系统数据目录>/library.db`(尊重 `OAK_CONFIG_DIR`)。
- `Storage/PgUrl` — PostgreSQL 连接串,`Backend = "pg"` 时使用:
`user:pass@host:5432/dbname`libpq URL 形式,带
`postgres://`/`postgresql://` 前缀也会被剥掉)。解析出的库 URI 为
`oakdb+pg://<PgUrl>`
- `Storage/SnapshotIntervalSec`(默认 600)、`Storage/JournalRetentionDays`
(默认 0 = 全保留)见上。
## 多写者与平台
v1 假设单写者(SQLite `busy_timeout`PG 行锁);多写者协作是后续
工作(M14)。默认数据库是用户级单一 SQLite 文件;PostgreSQL
`oakdb+pg://` 连接串选择。
工作(M14)。默认数据库是用户级单一 SQLite 文件;PostgreSQL 通过
`Storage/Backend = "pg"` + `Storage/PgUrl` 选择,或直接用
`oakdb+pg://` 连接串 URI。
数据库测试:`cargo test -p oakstorage` 全绿无需 PostgreSQL——SQLite
套件常驻运行;PG 套件(`tests/database_pg_test.rs`)在设置了
`OAK_TEST_PG_URL`(如 `postgres://user:pass@host:5432/db`)时连接真实
PG 全量运行,未设置则跳过并打印说明。该 URL 应指向专用测试库:每个
测试会重置四张表。
另见:[M10 oakstorage 手册](plans/riir/M10-oakstorage.md)、
[M13 写穿计划](plans/riir/M13-storage-live.md)、
+52 -17
View File
@@ -19,9 +19,10 @@
//! Renders the full [`OakApp`] shell at 1600×900 (2× = 3200×1866 px) in an
//! offscreen macOS window and writes the PNGs to
//! `docs/screenshot-window.png` (zh-CN) and `docs/screenshot-window-en.png`
//! (en-US), using the same [`VisualTestAppContext`] machinery the gpui visual
//! tests use. The window is created at `(-10000, -10000)` so nothing
//! flickers on screen.
//! (en-US), then opens the project manager (M13 D4) on the same shell and
//! captures it to `docs/screenshot-manager.png` / `-en.png`, using the same
//! [`VisualTestAppContext`] machinery the gpui visual tests use. The window
//! is created at `(-10000, -10000)` so nothing flickers on screen.
//!
//! Unlike the real app's startup ([`oakapp::app::run`]) the example must
//! initialize the i18n layer itself, or every menu renders in the en-US
@@ -37,7 +38,7 @@
//! cargo run --example screenshot -- 1100 900 # any size (same filenames)
//! ```
use gpui::{px, size, AnyWindowHandle, AppContext, Result, VisualTestAppContext};
use gpui::{px, size, AnyWindowHandle, AppContext, Entity, Result, VisualTestAppContext};
use gpui_platform::current_platform;
use oakapp::app::OakApp;
use oakapp::i18n::{self, Language};
@@ -47,6 +48,8 @@ const DEFAULT_WIDTH: f32 = 1600.0;
const DEFAULT_HEIGHT: f32 = 900.0;
const OUT_ZH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-window.png");
const OUT_EN: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-window-en.png");
const OUT_MGR_ZH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-manager.png");
const OUT_MGR_EN: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/screenshot-manager-en.png");
/// Logical y of the timeline toolbar row, which sits at the top of the
/// bottom dock panel in the default layout: the dock starts at y 27.5 (the
@@ -77,47 +80,62 @@ fn main() -> Result<()> {
let original = i18n::language();
i18n::set_language(Language::ZhCN);
{
let handle = open_shell(&mut cx, width, height);
let image = cx.capture_screenshot(handle)?;
let (handle, root) = open_shell(&mut cx, width, height);
let image = cx.capture_screenshot(handle.into())?;
std::fs::create_dir_all(std::path::Path::new(OUT_ZH).parent().unwrap())?;
image.save(OUT_ZH)?;
println!("wrote {OUT_ZH} ({}×{})", image.width(), image.height());
assert_toolbar(&image, "zh-CN");
capture_manager(&mut cx, handle, &root, OUT_MGR_ZH)?;
}
i18n::set_language(Language::EnUs);
{
let handle = open_shell(&mut cx, width, height);
let image = cx.capture_screenshot(handle)?;
let (handle, root) = open_shell(&mut cx, width, height);
let image = cx.capture_screenshot(handle.into())?;
std::fs::create_dir_all(std::path::Path::new(OUT_EN).parent().unwrap())?;
image.save(OUT_EN)?;
println!("wrote {OUT_EN} ({}×{})", image.width(), image.height());
assert_toolbar(&image, "en-US");
capture_manager(&mut cx, handle, &root, OUT_MGR_EN)?;
}
i18n::set_language(original);
Ok(())
}
/// Opens the app shell offscreen and draws enough frames for the layout to
/// settle and the async toolbar-icon assets to decode: the node editor fits
/// its graph once the canvas size is known, the viewers upload their first
/// CPU frame, and the PNG toolbar icons load through the background executor
/// on the frame after the asset future resolves.
/// Opens the app shell offscreen and lets the layout settle (see
/// [`settle`]). Returns the typed window handle and the root entity so the
/// caller can still drive the shell (the manager capture) after the plain
/// screenshot.
fn open_shell(
cx: &mut VisualTestAppContext,
width: f32,
height: f32,
) -> AnyWindowHandle {
) -> (
gpui::WindowHandle<OakApp<MockEngine>>,
Entity<OakApp<MockEngine>>,
) {
let mut root_slot = None;
let window = cx
.open_offscreen_window(size(px(width), px(height)), |window, cx| {
// Compact pro-app text metrics, matching the real app's startup
// (`src/app.rs run_with` sets rem 14px; gpui's default is 16px).
window.set_rem_size(px(14.0));
cx.new(|cx| OakApp::<MockEngine>::new(window, None, cx))
let root = cx.new(|cx| OakApp::<MockEngine>::new(window, None, cx));
root_slot = Some(root.clone());
root
})
.expect("offscreen window opens");
let handle: AnyWindowHandle = window.into();
settle(cx, window.into());
(window, root_slot.expect("root entity"))
}
/// Draws enough frames for the layout to settle and the async toolbar-icon
/// assets to decode: the node editor fits its graph once the canvas size is
/// known, the viewers upload their first CPU frame, and the PNG toolbar
/// icons load through the background executor on the frame after the asset
/// future resolves.
fn settle(cx: &mut VisualTestAppContext, handle: AnyWindowHandle) {
for _ in 0..16 {
cx.run_until_parked();
cx.update_window(handle, |_root, window, app| {
@@ -135,7 +153,24 @@ fn open_shell(
.expect("window still open");
}
cx.run_until_parked();
handle
}
/// Opens the project manager on the shell (M13 D4) and captures it: the
/// modal card lists the mock library with its per-project stats. Drives the
/// root ENTITY (not the window handle — a window update borrows the window,
/// and building the modal inside it would re-enter it).
fn capture_manager(
cx: &mut VisualTestAppContext,
handle: gpui::WindowHandle<OakApp<MockEngine>>,
root: &Entity<OakApp<MockEngine>>,
out: &str,
) -> Result<()> {
root.update(cx, |app, cx| app.show_project_manager(cx));
settle(cx, handle.into());
let image = cx.capture_screenshot(handle.into())?;
image.save(out)?;
println!("wrote {out} ({}×{})", image.width(), image.height());
Ok(())
}
/// The timeline toolbar's tool icons (16px at 2× = 32px on 48px pitch) must
+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,