- facade storage session manager: project handles bind to the default SQLite library on project_new/load; every undo push/group_end/jump write-throughs via DatabaseBackend::save (diff journal); project_free flushes and unbinds - background snapshot thread (Storage/SnapshotIntervalSec, latest-wins, newest 3 kept) with exit flush (oakengine_storage_flush) - config-gated: storage only activates with an explicit Storage/Backend=sqlite, so headless consumers and tests never touch the real library; new exports: storage_flush/is_bound/last_error - it_storage: kill -9 recovery, cross-session undo, snapshot pruning, multi-project isolation, graceful degradation - also fixes a real config test polluting the user config.ini and the undo-stack test races
475 lines
16 KiB
Rust
475 lines
16 KiB
Rust
// 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/>.
|
|
|
|
//! `engine/include/oakengine/undo.h` — the process-wide undo stack,
|
|
//! undo groups and command lifecycle over the oakundo module.
|
|
//!
|
|
//! The facade owns the process-wide undo stack (module 00 analogue of
|
|
//! `EngineCore::undo_stack()`): it is created lazily on first use and
|
|
//! lives for the process (mirroring the C++ EngineCore shell, which is
|
|
//! also leaked intentionally). The open undo group is facade state too:
|
|
//! while a group is open, every command a wrapped family hands to
|
|
//! [`push_or_run`] is added to the group instead of the stack.
|
|
//!
|
|
//! Command creators declared in undo.h but backed by other modules
|
|
//! (`oakengine_node_*_command`, `oakengine_track_*_command`,
|
|
//! `oakengine_block_*_command`, `oakengine_timeline_*_command`) live in
|
|
//! the corresponding family modules, mirroring the C++ capi layout.
|
|
|
|
use std::ffi::{c_char, c_int, c_void};
|
|
use std::sync::{Mutex, OnceLock};
|
|
|
|
use oakundo::undocommand::{
|
|
command_free, command_init, command_init_multi, command_multi_add_child,
|
|
command_multi_child, command_multi_child_count, command_redo_now, command_undo_now,
|
|
};
|
|
use oakundo::undostack::{
|
|
undostack_can_redo, undostack_can_undo, undostack_clear, undostack_command_is_done,
|
|
undostack_command_text, undostack_count, undostack_index, undostack_init, undostack_jump,
|
|
undostack_push, undostack_push_pre_executed,
|
|
};
|
|
use crate::error::{Error, Result};
|
|
use crate::handle::{box_handle, free_box, guard, guard_void, unbox, CHandle, OakEngineClipboard};
|
|
|
|
/// The process-wide undo stack handle (oakundo `OakUndoStack`), created
|
|
/// lazily and kept for the process lifetime.
|
|
fn global_stack() -> &'static CHandle {
|
|
static STACK: OnceLock<CHandle> = OnceLock::new();
|
|
STACK.get_or_init(|| unsafe { undostack_init() })
|
|
}
|
|
|
|
/// Stable opaque token for `oakengine_undo_handle`: the module stack's
|
|
/// `ctx` pointer (never dereferenced by the facade; lives for the
|
|
/// process).
|
|
fn stack_token() -> *mut c_void {
|
|
global_stack().ctx
|
|
}
|
|
|
|
/// The currently open undo group (a multi command handle) plus its name.
|
|
struct OpenGroup {
|
|
/// Multi command handle; owned by this state until end/abort.
|
|
multi: CHandle,
|
|
/// Group label.
|
|
#[allow(dead_code)]
|
|
name: String,
|
|
}
|
|
|
|
static GROUP: Mutex<Option<OpenGroup>> = Mutex::new(None);
|
|
|
|
fn group_lock() -> std::sync::MutexGuard<'static, Option<OpenGroup>> {
|
|
GROUP.lock().unwrap_or_else(|e| e.into_inner())
|
|
}
|
|
|
|
/// Push `command` onto the stack, add it to the open group, or run it
|
|
/// directly — whichever applies (module 00 analogue of the C++ capi's
|
|
/// `oakengine_undo_push_or_run`). `command_box` is consumed.
|
|
///
|
|
/// # Safety
|
|
/// `command_box` must be a live box created by a facade command creator.
|
|
pub(crate) unsafe fn push_or_run(
|
|
command_box: *mut OakEngineClipboard,
|
|
name: *const c_char,
|
|
) -> Result<()> {
|
|
let cmd = unsafe { unbox(command_box)? };
|
|
let label = unsafe { crate::handle::read_cstr(name) };
|
|
let g = group_lock();
|
|
if let Some(group) = g.as_ref() {
|
|
// The module's `oakundo_command_multi_add_child` consumes the
|
|
// child's command value (command_take), so the eager redo must
|
|
// happen on the still-owned handle FIRST — the group takes the
|
|
// already-done command (C++ semantics: add_child + redo_now, net
|
|
// effect identical for the group's reverse-order undo).
|
|
let rc = unsafe { command_redo_now(cmd) };
|
|
if rc != 0 {
|
|
return Err(Error::Module(rc));
|
|
}
|
|
let rc = unsafe { command_multi_add_child(group.multi, cmd) };
|
|
drop(g);
|
|
unsafe { free_box(command_box) };
|
|
return if rc == 0 {
|
|
Ok(())
|
|
} else {
|
|
Err(Error::Module(rc))
|
|
};
|
|
}
|
|
let stack = *global_stack();
|
|
// The module treats a NULL name like an empty label, but an empty Rust
|
|
// String's `as_ptr()` is a DANGLING non-NULL pointer (0x1): the module's
|
|
// `read_name` would strlen it and SIGSEGV. Pass a real NULL instead.
|
|
let label_ptr = if label.is_empty() {
|
|
std::ptr::null()
|
|
} else {
|
|
label.as_ptr() as *const c_char
|
|
};
|
|
let rc = unsafe { undostack_push(stack, cmd, label_ptr) };
|
|
if rc == 0 {
|
|
// Stack took a reference; release ours by freeing the box. The
|
|
// command's redo already ran (plan M13 D2): persist the project.
|
|
unsafe { free_box(command_box) };
|
|
crate::storage::note_command();
|
|
Ok(())
|
|
} else {
|
|
// Push failed (e.g. empty multi): the module deleted the command;
|
|
// release the box shell without touching the (already consumed)
|
|
// handle.
|
|
unsafe { free_box(command_box) };
|
|
Err(Error::Module(rc))
|
|
}
|
|
}
|
|
|
|
/// `oakengine_undo_handle` — borrowed token of the global undo stack
|
|
/// (NULL never: the facade creates the stack lazily).
|
|
#[no_mangle]
|
|
pub extern "C" fn oakengine_undo_handle() -> *mut c_void {
|
|
crate::handle::guard_ptr(|| Ok(stack_token()))
|
|
}
|
|
|
|
/// `oakengine_undo_push` — push `command` onto the stack and execute its
|
|
/// redo (or add it to the open group). Takes ownership of `command`.
|
|
#[no_mangle]
|
|
pub unsafe extern "C" fn oakengine_undo_push(command: *mut c_void, name: *const c_char) -> c_int {
|
|
guard(|| unsafe {
|
|
if command.is_null() {
|
|
return Err(Error::Invalid);
|
|
}
|
|
push_or_run(command.cast::<OakEngineClipboard>(), name)
|
|
})
|
|
}
|
|
|
|
/// `oakengine_undo_group_begin` — start collecting commands into a group.
|
|
#[no_mangle]
|
|
pub extern "C" fn oakengine_undo_group_begin(name: *const c_char) -> c_int {
|
|
guard(|| {
|
|
let mut g = group_lock();
|
|
if g.is_some() {
|
|
return Err(Error::State);
|
|
}
|
|
let multi = unsafe { command_init_multi() };
|
|
if multi.is_null() {
|
|
return Err(Error::Failed("undo group allocation failed".into()));
|
|
}
|
|
*g = Some(OpenGroup {
|
|
multi,
|
|
name: unsafe { crate::handle::read_cstr(name) },
|
|
});
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
/// `oakengine_undo_group_end` — close the group and push it as one entry.
|
|
/// An empty group is discarded (no undo entry).
|
|
#[no_mangle]
|
|
pub extern "C" fn oakengine_undo_group_end() -> c_int {
|
|
guard(|| {
|
|
let mut g = group_lock();
|
|
let open = g.take().ok_or(Error::State)?;
|
|
let multi = open.multi;
|
|
let name = open.name;
|
|
drop(g);
|
|
// Same NULL-for-empty convention as `push_or_run`: the module's
|
|
// `read_name` treats NULL like an empty label, while an empty String's
|
|
// dangling `as_ptr()` (0x1) would be strlen'd -> SIGSEGV.
|
|
let name_ptr = if name.is_empty() {
|
|
std::ptr::null()
|
|
} else {
|
|
name.as_ptr() as *const c_char
|
|
};
|
|
// push_pre_executed discards an empty multi command. Either way
|
|
// the stack took (or destroyed) the command; release our own
|
|
// reference to the multi handle.
|
|
let stack = *global_stack();
|
|
let rc = unsafe { undostack_push_pre_executed(stack, multi, name_ptr) };
|
|
let mut multi_handle = multi;
|
|
unsafe { command_free(&mut multi_handle) };
|
|
if rc == 0 {
|
|
// The group's children were redo'd eagerly at push time; the
|
|
// whole group is one command (plan §2: commit at group_end).
|
|
crate::storage::note_command();
|
|
Ok(())
|
|
} else {
|
|
Err(Error::Module(rc))
|
|
}
|
|
})
|
|
}
|
|
|
|
/// `oakengine_undo_group_abort` — undo all executed children and discard
|
|
/// the group.
|
|
#[no_mangle]
|
|
pub extern "C" fn oakengine_undo_group_abort() -> c_int {
|
|
guard(|| {
|
|
let mut g = group_lock();
|
|
let open = g.take().ok_or(Error::State)?;
|
|
drop(g);
|
|
// The multi command itself is never marked done (each child was
|
|
// redo'd eagerly at push time), so `undo_now` on it is a no-op.
|
|
// Undo the executed children individually instead, in reverse
|
|
// insertion order (mirroring the multi's reverse-order undo), each
|
|
// through its own borrowed handle.
|
|
let mut count: c_int = 0;
|
|
let rc = unsafe { command_multi_child_count(open.multi, &mut count) };
|
|
if rc != 0 {
|
|
let mut multi = open.multi;
|
|
unsafe { command_free(&mut multi) };
|
|
return Err(Error::Module(rc));
|
|
}
|
|
for i in (0..count).rev() {
|
|
let mut child = CHandle::null();
|
|
let rc = unsafe { command_multi_child(open.multi, i, &mut child) };
|
|
if rc != 0 {
|
|
let mut multi = open.multi;
|
|
unsafe { command_free(&mut multi) };
|
|
return Err(Error::Module(rc));
|
|
}
|
|
let rc = unsafe { command_undo_now(child) };
|
|
// The child handle is borrowed (owns:false): release only its
|
|
// shell — the child value lives on in the multi until the multi
|
|
// itself is freed below.
|
|
unsafe { command_free(&mut child) };
|
|
if rc != 0 {
|
|
let mut multi = open.multi;
|
|
unsafe { command_free(&mut multi) };
|
|
return Err(Error::Module(rc));
|
|
}
|
|
}
|
|
let mut multi = open.multi;
|
|
unsafe { command_free(&mut multi) };
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
/// `oakengine_undo_command_redo_now` — execute the redo of `command`
|
|
/// without taking ownership.
|
|
#[no_mangle]
|
|
pub unsafe extern "C" fn oakengine_undo_command_redo_now(command: *mut c_void) -> c_int {
|
|
guard(|| unsafe {
|
|
let cmd = unbox(command.cast::<OakEngineClipboard>())?;
|
|
Error::from_module(command_redo_now(cmd))
|
|
})
|
|
}
|
|
|
|
/// `oakengine_undo_command_undo_now` — execute the undo of `command`
|
|
/// without taking ownership.
|
|
#[no_mangle]
|
|
pub unsafe extern "C" fn oakengine_undo_command_undo_now(command: *mut c_void) -> c_int {
|
|
guard(|| unsafe {
|
|
let cmd = unbox(command.cast::<OakEngineClipboard>())?;
|
|
Error::from_module(command_undo_now(cmd))
|
|
})
|
|
}
|
|
|
|
/// Engine-side callback types for app-defined undo commands
|
|
/// (`engine/include/oakengine/undo.h`).
|
|
type UndoRedoFn = unsafe extern "C" fn(userdata: *mut c_void);
|
|
type UndoFreeFn = unsafe extern "C" fn(userdata: *mut c_void);
|
|
|
|
/// `oakengine_undo_command_create` — create an app-defined undo command
|
|
/// backed by C callbacks. Takes ownership of `userdata`.
|
|
#[no_mangle]
|
|
pub unsafe extern "C" fn oakengine_undo_command_create(
|
|
name: *const c_char,
|
|
redo: Option<UndoRedoFn>,
|
|
undo: Option<UndoRedoFn>,
|
|
free_fn: Option<UndoFreeFn>,
|
|
userdata: *mut c_void,
|
|
) -> *mut c_void {
|
|
crate::handle::guard_ptr(|| unsafe {
|
|
let _ = crate::handle::read_cstr(name);
|
|
let vtable = oakundo::undocommand::OakUndoCommandVtable {
|
|
redo,
|
|
undo,
|
|
free_fn,
|
|
};
|
|
let cmd = command_init(&vtable, userdata);
|
|
if cmd.is_null() {
|
|
return Ok(std::ptr::null_mut());
|
|
}
|
|
Ok(box_handle::<OakEngineClipboard>(cmd).cast())
|
|
})
|
|
}
|
|
|
|
/// `oakengine_undo_command_create_multi` — create an empty
|
|
/// MultiUndoCommand as an opaque command pointer.
|
|
#[no_mangle]
|
|
pub extern "C" fn oakengine_undo_command_create_multi() -> *mut c_void {
|
|
crate::handle::guard_ptr(|| {
|
|
let cmd = unsafe { command_init_multi() };
|
|
if cmd.is_null() {
|
|
return Ok(std::ptr::null_mut());
|
|
}
|
|
Ok(box_handle::<OakEngineClipboard>(cmd).cast())
|
|
})
|
|
}
|
|
|
|
/// `oakengine_undo_command_multi_add_child` — add `child` to `multi`
|
|
/// (the multi takes one reference; `child`'s box is consumed).
|
|
#[no_mangle]
|
|
pub unsafe extern "C" fn oakengine_undo_command_multi_add_child(
|
|
multi: *mut c_void,
|
|
child: *mut c_void,
|
|
) -> c_int {
|
|
guard(|| unsafe {
|
|
if multi.is_null() || child.is_null() {
|
|
return Err(Error::Invalid);
|
|
}
|
|
let m = unbox(multi.cast::<OakEngineClipboard>())?;
|
|
let c = unbox(child.cast::<OakEngineClipboard>())?;
|
|
let rc = command_multi_add_child(m, c);
|
|
free_box(child.cast::<OakEngineClipboard>());
|
|
if rc == 0 {
|
|
Ok(())
|
|
} else {
|
|
Err(Error::Module(rc))
|
|
}
|
|
})
|
|
}
|
|
|
|
/// `oakengine_undo_command_multi_child_count` — children of `multi`.
|
|
#[no_mangle]
|
|
pub unsafe extern "C" fn oakengine_undo_command_multi_child_count(multi: *mut c_void) -> c_int {
|
|
crate::handle::guard_int(|| unsafe {
|
|
let m = unbox(multi.cast::<OakEngineClipboard>())?;
|
|
let mut count: c_int = 0;
|
|
Error::from_module(command_multi_child_count(m, &mut count))?;
|
|
Ok(count)
|
|
})
|
|
}
|
|
|
|
/// `oakengine_undo_command_free` — destroy a command without pushing it.
|
|
#[no_mangle]
|
|
pub unsafe extern "C" fn oakengine_undo_command_free(command: *mut c_void) {
|
|
guard_void(|| unsafe {
|
|
free_box(command.cast::<OakEngineClipboard>());
|
|
})
|
|
}
|
|
|
|
/// `oakengine_undo_count` — total number of history rows.
|
|
#[no_mangle]
|
|
pub extern "C" fn oakengine_undo_count() -> i64 {
|
|
crate::handle::guard_i64(|| unsafe {
|
|
let mut count: i64 = 0;
|
|
Error::from_module(undostack_count(*global_stack(), &mut count))?;
|
|
Ok(count)
|
|
})
|
|
}
|
|
|
|
/// `oakengine_undo_index` — current position in the history.
|
|
#[no_mangle]
|
|
pub extern "C" fn oakengine_undo_index() -> i64 {
|
|
crate::handle::guard_i64(|| unsafe {
|
|
let mut index: i64 = 0;
|
|
Error::from_module(undostack_index(*global_stack(), &mut index))?;
|
|
Ok(index)
|
|
})
|
|
}
|
|
|
|
/// `oakengine_undo_command_text` — label of the row at `row`
|
|
/// (buf/size; OAKENGINE_E_NOT_FOUND for an invalid row).
|
|
#[no_mangle]
|
|
pub unsafe extern "C" fn oakengine_undo_command_text(
|
|
row: i64,
|
|
buf: *mut c_char,
|
|
buf_size: c_int,
|
|
) -> c_int {
|
|
// The oakundo getter is itself two-stage: it reports the required
|
|
// size when `buf` is NULL/too small and copies otherwise, so the
|
|
// module return value is returned verbatim (guarded against panic),
|
|
// converted to the engine's length-excluding-NUL convention.
|
|
crate::handle::guard_int(|| unsafe {
|
|
let rc = undostack_command_text(*global_stack(), row, buf, buf_size);
|
|
if rc < 0 {
|
|
Err(Error::Module(rc))
|
|
} else {
|
|
Ok(crate::handle::string_result(rc))
|
|
}
|
|
})
|
|
}
|
|
|
|
/// `oakengine_undo_command_is_done` — 1 when the row is done, 0 when
|
|
/// undone, OAKENGINE_E_NOT_FOUND for an invalid row.
|
|
#[no_mangle]
|
|
pub extern "C" fn oakengine_undo_command_is_done(row: i64) -> c_int {
|
|
crate::handle::guard_int(|| unsafe {
|
|
let mut value: c_int = 0;
|
|
Error::from_module(undostack_command_is_done(
|
|
*global_stack(),
|
|
row,
|
|
&mut value,
|
|
))?;
|
|
Ok(value)
|
|
})
|
|
}
|
|
|
|
/// `oakengine_undo_jump` — undo/redo until the done-command count equals
|
|
/// `index`. On success the bound projects are written through (the jump
|
|
/// executed the undo/redo callbacks that mutated them).
|
|
#[no_mangle]
|
|
pub extern "C" fn oakengine_undo_jump(index: i64) -> c_int {
|
|
let rc = guard(|| unsafe { Error::from_module(undostack_jump(*global_stack(), index)) });
|
|
if rc == crate::error::OAKENGINE_OK {
|
|
crate::storage::note_command();
|
|
}
|
|
rc
|
|
}
|
|
|
|
/// `oakengine_undo_clear` — delete all commands and push the fresh
|
|
/// "New/Open Project" empty command.
|
|
#[no_mangle]
|
|
pub extern "C" fn oakengine_undo_clear() -> c_int {
|
|
guard(|| unsafe { Error::from_module(undostack_clear(*global_stack())) })
|
|
}
|
|
|
|
/// `oakengine_undo_update_actions` — no-op: the QAction members were
|
|
/// removed in the de-Qt pass (see notes.md), the app builds its own
|
|
/// undo/redo actions from `oakengine_undo_can_undo/redo`.
|
|
#[no_mangle]
|
|
pub extern "C" fn oakengine_undo_update_actions() -> c_int {
|
|
crate::error::OAKENGINE_OK
|
|
}
|
|
|
|
/// `oakengine_undo_can_undo` — 1/0.
|
|
#[no_mangle]
|
|
pub extern "C" fn oakengine_undo_can_undo() -> c_int {
|
|
crate::handle::guard_int(|| unsafe {
|
|
let mut value: c_int = 0;
|
|
Error::from_module(undostack_can_undo(*global_stack(), &mut value))?;
|
|
Ok(value)
|
|
})
|
|
}
|
|
|
|
/// `oakengine_undo_can_redo` — 1/0.
|
|
#[no_mangle]
|
|
pub extern "C" fn oakengine_undo_can_redo() -> c_int {
|
|
crate::handle::guard_int(|| unsafe {
|
|
let mut value: c_int = 0;
|
|
Error::from_module(undostack_can_redo(*global_stack(), &mut value))?;
|
|
Ok(value)
|
|
})
|
|
}
|
|
|
|
/// `oakengine_undo_undo_action` — Qt leftover: the de-Qt module world has
|
|
/// no QAction; returns NULL. The app builds its own action.
|
|
#[no_mangle]
|
|
pub extern "C" fn oakengine_undo_undo_action() -> *mut c_void {
|
|
std::ptr::null_mut()
|
|
}
|
|
|
|
/// `oakengine_undo_redo_action` — Qt leftover; returns NULL (see
|
|
/// `oakengine_undo_undo_action`).
|
|
#[no_mangle]
|
|
pub extern "C" fn oakengine_undo_redo_action() -> *mut c_void {
|
|
std::ptr::null_mut()
|
|
}
|