graphic design tool
git clone https://git.lucas.co/cce-designer.git
fix: a default project that is missing at launch is not forgotten
It was DELETED from the settings, on the reasoning that a dead pointer
should not fail on every launch. The trade is the wrong way round: failing
costs one line of stderr and a fallback that already works, while
forgetting costs a setting the user can only restore by reopening the
project and pressing the button again.
And a path is absent for reasons that pass — a cloud-synced folder the
daemon has not mounted yet, an external drive, an autostart that beat the
network — so the one launch that raced the filesystem took the setting with
it, silently. Found exactly that way: a default under ~/Dropbox that
stopped opening, with the key simply gone from state.kdl.
The launch now falls back to the bundled project, keeps the pointer, and
says so on the status line rather than only on a stderr nobody reads.
Co-Authored-By: Claude Opus 5 <[email protected]>
CLAUDE.md | 15 +++++++++++++--
src/main.rs | 31 +++++++++++++++++++++++++++++++
src/project.rs | 23 ++++++++++++++++-------
3 files changed, 60 insertions(+), 9 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 4519a21..195543f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -285,10 +285,21 @@ src/render.rs, walked with the scene's visibility chain).
startup (the Main node's File > "Set As Default" button; absent = the bundled
`default_project.json`). It is a POINTER, never a rewrite of
default_project.json — that file is versioned and is the detached-window sync
-channel. A default whose path no longer exists is dropped from the settings on
-launch. Detached windows ignore it: they must keep seeding from the sync
+channel. Detached windows ignore it: they must keep seeding from the sync
channel.
+**A default that cannot be opened is not forgotten.** The launch falls back to
+the bundled project and says so on the status line, keeping the pointer. Until
+2026-09-23 a path that did not exist was DELETED from the settings, reasoning
+that a dead default should not fail on every launch — the trade is the wrong
+way round. Failing costs one line of stderr and a fallback that already works;
+forgetting costs a setting the user can only restore by reopening the project
+and pressing the button again. And a path is absent for reasons that pass — a
+cloud-synced folder the daemon has not mounted yet, an external drive, an
+autostart that beat the network — so the one launch that raced the filesystem
+took the setting with it, silently. (Found exactly that way: a default under
+`~/Dropbox` that stopped opening, with the key simply gone from state.kdl.)
+
`DesignSettings` (viewport/graph display state the app rewrites itself:
colors, grid sizes, show flags) persists to `state.kdl` — deliberately NOT
`config.kdl`, which is the user-authored toolkit-config override slot that
diff --git a/src/main.rs b/src/main.rs
index 9d0ba6b..e8a0f41 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -141,6 +141,37 @@ mod tests {
"chooser must start where the current project lives");
}
+ /// A default project that is not there at launch is not FORGOTTEN. It
+ /// used to be deleted from the settings on the reasoning that a dead
+ /// pointer should not fail every launch — but a path is absent for
+ /// reasons that pass (a cloud-synced folder the daemon has not mounted
+ /// yet, an external drive, an autostart that beat the network), and the
+ /// one launch that raced the filesystem took a setting the user could
+ /// only restore by reopening the project and pressing the button again.
+ #[test]
+ fn a_default_project_that_is_missing_is_not_forgotten() {
+ let gone = std::env::temp_dir().join(format!("cce-designer-no-such-{}", std::process::id()));
+ let _ = fs::remove_dir_all(&gone);
+ assert!(!gone.exists());
+
+ let mut state = State::new(false);
+ let before = state.fs_root.children.len();
+ state.default_project_setting = Some(gone.to_string_lossy().into_owned());
+ state.load_default_project_setting();
+
+ assert_eq!(
+ state.default_project_setting.as_deref(),
+ Some(gone.to_string_lossy().as_ref()),
+ "the pointer survives a launch that could not see it"
+ );
+ assert_eq!(state.fs_root.children.len(), before, "and the bundled project still stands");
+ assert!(
+ state.last_status_text.contains("not found"),
+ "the status line says so: {}",
+ state.last_status_text
+ );
+ }
+
/// The default-project pointer must survive the KDL round trip state.kdl
/// actually goes through — serde alone passing means nothing if
/// json_to_kdl_string / parse_kdl_to_json drop or retype the field.
diff --git a/src/project.rs b/src/project.rs
index 384f864..016f3d9 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -507,21 +507,30 @@ impl State {
/// Open the configured startup project, if any. Main window only — the
/// detached windows must keep seeding from default_project.json, which is
- /// their sync channel with the parent. A missing or unloadable default
- /// falls back to what State::new already loaded, and a default that no
- /// longer exists is dropped from the settings so it does not fail on every
- /// launch from now on.
+ /// their sync channel with the parent.
+ ///
+ /// A default that cannot be opened — gone, or unreadable — falls back to
+ /// what `State::new` already loaded and **keeps the setting**, saying so
+ /// on the status line. It used to DELETE the pointer on a path that did
+ /// not exist, reasoning that a dead default should not fail on every
+ /// launch. The trade is the wrong way round: failing costs one line of
+ /// stderr and a fallback that already works, while forgetting costs the
+ /// user a setting they cannot get back without reopening the project and
+ /// pressing the button again. And a path is absent for reasons that pass
+ /// — a cloud-synced folder the daemon has not mounted yet, an external
+ /// drive, a machine that autostarts the app before the network is up —
+ /// so the one launch that raced the filesystem took the setting with it.
pub(crate) fn load_default_project_setting(&mut self) {
let Some(configured) = self.default_project_setting.clone() else { return };
let path = std::path::PathBuf::from(&configured);
if !path.exists() {
- eprintln!("Default project is gone, clearing the setting: {configured}");
- self.default_project_setting = None;
- self.save_settings();
+ eprintln!("Default project is not there right now: {configured}");
+ self.update_status_text(&format!("Default project not found: {configured}"));
return;
}
if let Err(e) = self.load_from_file(&path) {
eprintln!("Failed to load default project {configured}: {e:?}");
+ self.update_status_text(&format!("Default project would not open: {configured}"));
}
}