Workspaces: saved workspaces last between sessions (#320)

Window > Workspace > Save as New Workspace and Save Changes to this
Workspace kept the layout only in memory (`UiState::saved_workspaces` /
`saved_floating`): nothing wrote them, so they were gone after a restart.

They are now written to `workspaces.json` in the settings store (the config
folder on desktop, OPFS on the web) when a workspace is saved, renamed or
deleted, and read back when the app starts; a file that doesn't read is
ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
This commit is contained in:
Claude 2026-10-10 19:32:18 +00:00
parent da06c42de1
commit e96638093c
No known key found for this signature in database
3 changed files with 79 additions and 0 deletions

View File

@ -295,6 +295,17 @@ pub struct EffectcraftApp {
pub(crate) template_list: Option<(Vec<String>, Vec<effectcraft_engine::templates::TemplateInfo>)>,
}
/// The saved workspaces in the settings store ([`effectcraft_engine::config::ConfigStore`]).
pub const WORKSPACES_FILE: &str = "workspaces.json";
#[derive(serde::Serialize, serde::Deserialize)]
struct SavedWorkspaces {
#[serde(default)]
workspaces: std::collections::BTreeMap<String, dock::DockNode>,
#[serde(default)]
floating: std::collections::BTreeMap<String, Vec<dock::Floating>>,
}
impl EffectcraftApp {
pub fn new(session: Session) -> Self {
EffectcraftApp {
@ -353,6 +364,34 @@ impl EffectcraftApp {
template_list: None,
}
.with_ui_commands()
.with_saved_workspaces()
}
/// The workspaces saved in an earlier session (Window ▸ Workspace ▸ Save as New Workspace,
/// Save Changes to this Workspace), from the settings store. A file that doesn't read is
/// ignored.
fn with_saved_workspaces(mut self) -> Self {
let text = self.session.config.as_ref().and_then(|c| c.read(WORKSPACES_FILE));
match text.map(|t| serde_json::from_str::<SavedWorkspaces>(&t)) {
Some(Ok(w)) => {
self.ui.saved_workspaces = w.workspaces;
self.ui.saved_floating = w.floating;
}
Some(Err(e)) => log::warn!("{WORKSPACES_FILE}: {e}"),
None => {}
}
self
}
/// Keep the saved workspaces for the next session (see [`EffectcraftApp::new`]): After
/// Effects' saved workspaces last until they are deleted (#320).
pub(crate) fn store_saved_workspaces(&self) {
let Some(c) = &self.session.config else { return };
let w = SavedWorkspaces { workspaces: self.ui.saved_workspaces.clone(), floating: self.ui.saved_floating.clone() };
let written = serde_json::to_string(&w).map_err(std::io::Error::other).and_then(|t| c.write(WORKSPACES_FILE, &t));
if let Err(e) = written {
log::warn!("{WORKSPACES_FILE}: {e}");
}
}
/// Offer the frontend's commands (tools, timeline navigation…) for keyboard shortcuts.

View File

@ -943,6 +943,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
let name = app.ui.workspace.clone();
app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone());
app.ui.saved_floating.insert(name.clone(), app.ui.floating.clone());
app.store_saved_workspaces();
json!({"workspace": name})
}
"window.saveWorkspaceAs" => {
@ -959,6 +960,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
app.ui.saved_workspaces.insert(name.clone(), app.ui.dock.clone());
app.ui.saved_floating.insert(name.clone(), app.ui.floating.clone());
app.ui.workspace = name.clone();
app.store_saved_workspaces();
json!({"workspace": name})
}
"window.editWorkspaces" => {
@ -983,6 +985,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
if app.ui.saved_workspaces.remove(&name).is_none() && !builtin {
return Err(format!("no workspace `{name}`"));
}
app.store_saved_workspaces();
if app.ui.workspace == name {
app.set_workspace("Default");
}
@ -1000,6 +1003,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
if app.ui.workspace == name {
app.ui.workspace = new.to_string();
}
app.store_saved_workspaces();
return Ok(json!({"renamed": new}));
}
json!({"workspaces": app.workspace_names()})

View File

@ -117,6 +117,42 @@ fn hover(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.run_steps(3);
}
/// Issue #320: saved workspaces last between sessions (they were only kept in memory): a new
/// app on the same settings store lists them and brings their layout back; renamed and deleted
/// ones stay that way.
#[test]
fn saved_workspaces_persist_across_sessions() {
use effectcraft_engine::config::{ConfigStore, MemoryConfig};
use effectcraft_ui_egui::menus::invoke;
let store = std::sync::Arc::new(MemoryConfig::default());
let launch = || {
let s = Session { config: Some(store.clone()), ..Default::default() };
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(2);
h
};
let mut h = launch();
let ctx = h.ctx.clone();
invoke(h.state_mut(), &ctx, "window.workspace", json!({"name": "Minimal"})).unwrap();
invoke(h.state_mut(), &ctx, "window.saveWorkspaceAs", json!({"name": "My Layout"})).unwrap();
invoke(h.state_mut(), &ctx, "window.saveWorkspaceAs", json!({"name": "Scratch"})).unwrap();
let saved = h.state().ui.dock.clone();
drop(h);
let mut h = launch();
assert_eq!(h.state().saved_workspace_names(), ["My Layout", "Scratch"]);
assert_eq!(h.state().ui.workspace, "Default");
let ctx = h.ctx.clone();
invoke(h.state_mut(), &ctx, "window.workspace", json!({"name": "My Layout"})).unwrap();
assert_eq!(h.state().ui.dock, saved);
invoke(h.state_mut(), &ctx, "window.editWorkspaces", json!({"name": "Scratch", "delete": true})).unwrap();
invoke(h.state_mut(), &ctx, "window.editWorkspaces", json!({"name": "My Layout", "rename": "Edit Bay"})).unwrap();
drop(h);
assert_eq!(launch().state().saved_workspace_names(), ["Edit Bay"]);
// A damaged file is ignored.
store.write(effectcraft_ui_egui::WORKSPACES_FILE, "{not json").unwrap();
assert!(launch().state().saved_workspace_names().is_empty());
}
/// Issue #191: a workspace saved with Save as New Workspace is listed in Window ▸ Workspace
/// (below the built-ins, where the submenu used to be cut off) and choosing it there brings its
/// layout back.