P1e: persist undo and redo history across reloads

This commit is contained in:
Emil
2026-11-01 01:53:05 +03:00
parent 346035e869
commit 9d84c2f162
9 changed files with 113 additions and 16 deletions
+14 -2
View File
@@ -141,6 +141,8 @@ pub struct ProjectData {
pub cores: Vec<Core>,
pub journal: Vec<JournalEntry>,
pub seq: u64,
pub history: Vec<ProjectEdit>,
pub redo: Vec<ProjectEdit>,
}
#[derive(Clone, Serialize, Deserialize)]
@@ -271,8 +273,8 @@ impl Session {
cores: data.cores,
journal: data.journal,
project_name: data.name,
history: Vec::new(),
redo: Vec::new(),
history: data.history,
redo: data.redo,
engine,
};
session.sanitize_faces();
@@ -296,6 +298,14 @@ impl Session {
!self.redo.is_empty()
}
pub fn history(&self) -> &[ProjectEdit] {
&self.history
}
pub fn redo(&self) -> &[ProjectEdit] {
&self.redo
}
pub fn bpm(&self) -> f32 {
self.bpm
}
@@ -1227,6 +1237,8 @@ mod tests {
cores: vec![test_core()],
journal: Vec::new(),
seq: 0,
history: Vec::new(),
redo: Vec::new(),
},
);
engine.clear();
+2
View File
@@ -224,6 +224,8 @@ async fn save_now(shared: &Shared) {
pattern: session.pattern(),
cores: &state.cores,
journal: session.journal(),
history: session.history(),
redo: session.redo(),
};
let result = {
let mut store = shared.store.lock().expect("project store lock poisoned");
+52 -6
View File
@@ -10,7 +10,7 @@ use std::path::{Path, PathBuf};
use rusqlite::{Connection, OptionalExtension, params};
use serde_json::Value;
use thiserror::Error;
use zhila_core::{JournalEntry, ProjectData};
use zhila_core::{JournalEntry, ProjectData, ProjectEdit};
use zhila_protocol::{CoreSummary, Pattern, Seal, VEIN_COUNT};
const SCHEMA_VERSION: &str = "1";
@@ -45,6 +45,8 @@ pub struct ProjectSnapshot<'a> {
pub pattern: &'a Pattern,
pub cores: &'a [CoreSummary],
pub journal: &'a [JournalEntry],
pub history: &'a [ProjectEdit],
pub redo: &'a [ProjectEdit],
}
impl ProjectStore {
@@ -68,6 +70,8 @@ impl ProjectStore {
bpm REAL NOT NULL,
gains TEXT NOT NULL,
pattern TEXT NOT NULL,
history TEXT NOT NULL DEFAULT '[]',
redo TEXT NOT NULL DEFAULT '[]',
saved_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS cores (
@@ -88,6 +92,14 @@ impl ProjectStore {
);",
)?;
let _ = conn.execute("ALTER TABLE cores ADD COLUMN faces TEXT", []);
let _ = conn.execute(
"ALTER TABLE project ADD COLUMN history TEXT NOT NULL DEFAULT '[]'",
[],
);
let _ = conn.execute(
"ALTER TABLE project ADD COLUMN redo TEXT NOT NULL DEFAULT '[]'",
[],
);
conn.execute(
"INSERT INTO meta (key, value) VALUES ('schema', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -108,7 +120,7 @@ impl ProjectStore {
let row = self
.conn
.query_row(
"SELECT name, revision, bpm, gains, pattern FROM project WHERE id = 1",
"SELECT name, revision, bpm, gains, pattern, history, redo FROM project WHERE id = 1",
[],
|row| {
Ok((
@@ -117,11 +129,14 @@ impl ProjectStore {
row.get::<_, f64>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
row.get::<_, String>(5)?,
row.get::<_, String>(6)?,
))
},
)
.optional()?;
let Some((name, revision, bpm, gains_json, pattern_json)) = row else {
let Some((name, revision, bpm, gains_json, pattern_json, history_json, redo_json)) = row
else {
return Ok(None);
};
@@ -132,6 +147,15 @@ impl ProjectStore {
}
let mut pattern: Pattern = serde_json::from_str(&pattern_json)?;
let mut warnings = Vec::new();
let history: Vec<ProjectEdit> =
serde_json::from_str(&history_json).unwrap_or_else(|error| {
warnings.push(format!("history: {error}"));
Vec::new()
});
let redo: Vec<ProjectEdit> = serde_json::from_str(&redo_json).unwrap_or_else(|error| {
warnings.push(format!("redo: {error}"));
Vec::new()
});
let mut cores = Vec::new();
{
@@ -254,6 +278,8 @@ impl ProjectStore {
cores,
journal,
seq,
history,
redo,
},
warnings,
}))
@@ -262,17 +288,21 @@ impl ProjectStore {
pub fn save(&mut self, snapshot: &ProjectSnapshot<'_>) -> Result<(), ProjectError> {
let gains = serde_json::to_string(snapshot.gains)?;
let pattern = serde_json::to_string(snapshot.pattern)?;
let history = serde_json::to_string(snapshot.history)?;
let redo = serde_json::to_string(snapshot.redo)?;
let saved_at = now_ms();
let tx = self.conn.transaction()?;
tx.execute(
"INSERT INTO project (id, name, revision, bpm, gains, pattern, saved_at)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6)
"INSERT INTO project (id, name, revision, bpm, gains, pattern, history, redo, saved_at)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
revision = excluded.revision,
bpm = excluded.bpm,
gains = excluded.gains,
pattern = excluded.pattern,
history = excluded.history,
redo = excluded.redo,
saved_at = excluded.saved_at",
params![
snapshot.name,
@@ -280,6 +310,8 @@ impl ProjectStore {
snapshot.bpm as f64,
gains,
pattern,
history,
redo,
saved_at as i64
],
)?;
@@ -423,7 +455,7 @@ fn now_ms() -> u64 {
#[cfg(test)]
mod tests {
use super::*;
use zhila_core::Core;
use zhila_core::{Core, CoreEdit, ProjectEdit};
use zhila_protocol::{Actor, Command, FaceRef, PatternRow, Seal};
fn fixture() -> Vec<u8> {
@@ -484,6 +516,15 @@ mod tests {
at_ms: 12,
command: Command::SetTempo { bpm: 132.0 },
}];
let history = vec![ProjectEdit {
bpm: 120.0,
gains,
pattern: pattern.clone(),
cores: vec![CoreEdit {
core: core.summary.id.clone(),
boundaries: core.face_boundaries(),
}],
}];
store
.write_asset(&core.summary, "clicks-01.wav", &bytes)
.unwrap();
@@ -496,6 +537,8 @@ mod tests {
pattern: &pattern,
cores: std::slice::from_ref(&core.summary),
journal: &journal,
history: &history,
redo: &[],
})
.unwrap();
@@ -517,6 +560,9 @@ mod tests {
core.summary.id
);
assert_eq!(loaded.data.journal.len(), 1);
assert_eq!(loaded.data.history.len(), 1);
assert_eq!(loaded.data.history[0].bpm, 120.0);
assert_eq!(loaded.data.history[0].cores[0].core, core.summary.id);
assert_eq!(loaded.data.seq, 1);
assert!(loaded.warnings.is_empty());
assert!(store.asset_path("assets/../project.db").is_none());