git.lucas.co / cce-files
file manager
git clone https://git.lucas.co/cce-files.git

commitaa4c894c03f2e83026ca87f759b1cc784d0788fe
parent671f449f8a
authorLucas Galante <[email protected]>
date2026-07-18 15:31
fix: a state.kdl no longer makes a directory an unenterable project

`is_project_dir` accepted a directory holding EITHER a state.json or a
state.kdl, and project dirs are deliberately not navigable — they open in the
designer. But state.kdl is what cce-designer names its own settings file, so
~/.config/cce/cce-designer/ matched and could not be entered in the browser.

No project format ever wrote a state.kdl: cce-designer saves a project as a
directory holding state.json, and its loader looks for nothing else. So match
that, here and in the matching get_mime_type check.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

 src/pages/browse.rs | 63 ++++++++++++++++++++++++++++++++++++++++++-----------
 src/services/fs.rs  |  3 ++-
 2 files changed, 52 insertions(+), 14 deletions(-)

diff --git a/src/pages/browse.rs b/src/pages/browse.rs
index a93c8e9..ee5a71b 100644
--- a/src/pages/browse.rs
+++ b/src/pages/browse.rs
@@ -93,8 +93,15 @@ pub enum BrowseNavigation {
     Down,
 }
 
+/// Whether `path` is a cce-designer project — a directory holding a `state.json`, which is
+/// the only shape the designer writes (and the only one it loads).
+///
+/// A project dir is deliberately NOT navigable: it opens in the designer instead. So this
+/// must not match ordinary directories — it used to accept a `state.kdl` too, which made
+/// `~/.config/cce/cce-designer/` (the designer's own settings live there, in a state.kdl)
+/// impossible to enter. No project format ever wrote that file.
 pub fn is_project_dir(path: &Path) -> bool {
-    path.is_dir() && (path.join("state.json").exists() || path.join("state.kdl").exists())
+    path.is_dir() && path.join("state.json").exists()
 }
 
 /// Reconstruct the ancestor path for a clicked breadcrumb segment index.
@@ -582,6 +589,36 @@ mod tests {
         assert_eq!(state.entries.len(), 2);
     }
 
+    #[test]
+    fn navigate_enters_a_dir_holding_a_settings_state_kdl() {
+        // The shape of ~/.config/cce/cce-designer: a plain directory whose only content is
+        // the designer's own state.kdl. Treating that as a project made it un-enterable.
+        let dir = std::env::temp_dir().join(format!(
+            "clear_test_navdir_{}",
+            chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
+        ));
+        std::fs::create_dir_all(&dir).unwrap();
+        std::fs::write(dir.join("state.kdl"), "graph {\n    grid_size_x (f64)71.0\n}").unwrap();
+
+        let mut state = BrowseState::default();
+        state.all_entries = vec![entry("cce-designer", dir.to_str().unwrap(), true)];
+        apply_filters(&mut state);
+        let req = update(&mut state, BrowseMessage::NavigateTo(0));
+        assert!(
+            matches!(req, Some(crate::services::fs::FsRequest::ReadDirectory(ref p)) if *p == dir),
+            "navigating into it should read the directory, got {req:?}",
+        );
+
+        // A real project (state.json) still opens instead of being entered.
+        std::fs::write(dir.join("state.json"), "{}").unwrap();
+        let req = update(&mut state, BrowseMessage::NavigateTo(0));
+        assert!(req.is_none(), "a project dir is not navigable");
+
+        let _ = std::fs::remove_file(dir.join("state.kdl"));
+        let _ = std::fs::remove_file(dir.join("state.json"));
+        let _ = std::fs::remove_dir(&dir);
+    }
+
     #[test]
     fn last_dir_loaded_some_reads_that_dir() {
         let mut state = BrowseState::default();
@@ -601,29 +638,29 @@ mod tests {
         let unique_dir = std::env::temp_dir().join(format!("clear_test_dir_{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)));
         std::fs::create_dir_all(&unique_dir).unwrap();
         
-        // Initially, path is a directory but doesn't have state.json or state.kdl
+        // Initially, path is a directory but doesn't have state.json
         assert!(!is_project_dir(&unique_dir));
-        
+
         // Create state.json
         let file_path_json = unique_dir.join("state.json");
         std::fs::write(&file_path_json, "{}").unwrap();
-        
+
         // Now it should be recognized as a project dir
         assert!(is_project_dir(&unique_dir));
-        
+
+        // If it's a file rather than a directory, even if named state.json, it shouldn't be
+        // a project dir itself
+        assert!(!is_project_dir(&file_path_json));
+
         // Remove state.json and verify it's not a project dir
         std::fs::remove_file(&file_path_json).unwrap();
         assert!(!is_project_dir(&unique_dir));
 
-        // Create state.kdl
+        // A state.kdl does NOT make a project: that is what the designer names its settings
+        // file, so matching it made ~/.config/cce/cce-designer/ un-enterable in the browser.
         let file_path_kdl = unique_dir.join("state.kdl");
-        std::fs::write(&file_path_kdl, "name \"test\"").unwrap();
-
-        // Now it should be recognized as a project dir
-        assert!(is_project_dir(&unique_dir));
-
-        // If it's a file rather than a directory, even if named state.kdl, it shouldn't be a project dir itself
-        assert!(!is_project_dir(&file_path_kdl));
+        std::fs::write(&file_path_kdl, "graph {\n    grid_size_x (f64)71.0\n}").unwrap();
+        assert!(!is_project_dir(&unique_dir), "a settings state.kdl is not a project");
 
         // Clean up
         let _ = std::fs::remove_file(&file_path_kdl);
diff --git a/src/services/fs.rs b/src/services/fs.rs
index 0c554f2..0a4626f 100644
--- a/src/services/fs.rs
+++ b/src/services/fs.rs
@@ -414,7 +414,8 @@ pub fn save_last_dir_internal(dir: &Path) {
 }
 
 pub fn get_mime_type(path: &Path) -> Option<String> {
-    if path.is_dir() && (path.join("state.json").exists() || path.join("state.kdl").exists()) {
+    // Matches `browse::is_project_dir`: a designer project is a dir holding a state.json.
+    if path.is_dir() && path.join("state.json").exists() {
         return Some("application/x-cce-project".to_string());
     }
     if let Some(ext) = path.extension().and_then(|e| e.to_str()) {