From 2e2db013d02a27cf56fe2c0641513a30103f5258 Mon Sep 17 00:00:00 2001 From: cuttlefisch Date: Fri, 20 Mar 2026 22:46:19 +0100 Subject: [PATCH 01/15] Fix warn/display-warning mismatch, treemacs nil deref, missing autoloads - Replace all `warn` calls with `display-warning` using 'declarative-project type symbol, aligning source with existing test spies - Remove tab characters from warning format strings - Fix --unassign-project nil dereference: wrap workspace lookup in when-let so (treemacs-workspace->projects ws) is never called on nil - Add ;;;###autoload cookie to declarative-project-mode and org-babel-execute:declarative-project for proper lazy-loading Co-Authored-By: Claude Opus 4.6 --- declarative-project-mode.el | 13 +++++++++---- declarative-project-treemacs.el | 11 +++++------ ob-declarative-project.el | 1 + 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/declarative-project-mode.el b/declarative-project-mode.el index 21420b3..63710ce 100644 --- a/declarative-project-mode.el +++ b/declarative-project-mode.el @@ -84,7 +84,8 @@ Functions receive a single argument: the project-resources hash table.") (when-let ((required-resources (gethash 'required-resources project-resources))) (dolist (resource required-resources) (unless (file-exists-p resource) - (warn "Missing required resource: %s" resource))))) + (display-warning 'declarative-project + (format "Missing required resource: %s" resource)))))) (defun declarative-project--install-project-dependencies (project-resources) "Clone any git dependencies locally in PROJECT-RESOURCES." @@ -106,7 +107,8 @@ Functions receive a single argument: the project-resources hash table.") (list src) (unless (string-empty-p dest) (list dest)))))) (unless (zerop exit-code) - (warn "git clone %s failed with exit code %d" src exit-code)))))))) + (display-warning 'declarative-project + (format "git clone %s failed with exit code %d" src exit-code))))))))) (defun declarative-project--copy-local-files (project-resources) "Copy over any local files in PROJECT-RESOURCES." @@ -124,7 +126,8 @@ Functions receive a single argument: the project-resources hash table.") (message "Copying file %s..." src) (copy-file src full-dest t)) (t - (warn "No such file or directory:\t%s" src))))))) + (display-warning 'declarative-project + (format "No such file or directory: %s" src)))))))) (defun declarative-project--create-symlinks (project-resources) "Create symlinks defined in PROJECT-RESOURCES." @@ -135,7 +138,8 @@ Functions receive a single argument: the project-resources hash table.") (file-name-nondirectory targ))) (full-link (expand-file-name link))) (if (not (file-exists-p targ)) - (warn "No such file or directory:\t%s" targ) + (display-warning 'declarative-project + (format "No such file or directory: %s" targ)) (make-directory (file-name-directory full-link) t) (message "Creating symlink %s -> %s" link targ) (make-symbolic-link targ full-link t)))))) @@ -203,6 +207,7 @@ EXTRA-KEYS is an alist of additional keys to set in the resource hash." ;;; --- Mode definition --- +;;;###autoload (define-minor-mode declarative-project-mode "Declarative Project mode." :lighter " DPM" diff --git a/declarative-project-treemacs.el b/declarative-project-treemacs.el index d416146..1986d53 100644 --- a/declarative-project-treemacs.el +++ b/declarative-project-treemacs.el @@ -129,12 +129,11 @@ Initialized lazily when the mode is enabled.") (defun declarative-project-treemacs--unassign-project (project workspace) "Remove PROJECT from WORKSPACE in desired state." - (let* ((ws (declarative-project-treemacs--workspaces-by-name workspace)) - (new-projects (cl-remove project - (treemacs-workspace->projects ws) - :test #'string= - :key (lambda (pj) (treemacs-project->name pj))))) - (when ws + (when-let ((ws (declarative-project-treemacs--workspaces-by-name workspace))) + (let ((new-projects (cl-remove project + (treemacs-workspace->projects ws) + :test #'string= + :key (lambda (pj) (treemacs-project->name pj))))) (setf (treemacs-workspace->projects ws) new-projects))) (when declarative-project-treemacs-mode (declarative-project-treemacs--override-workspaces)) diff --git a/ob-declarative-project.el b/ob-declarative-project.el index 61ae9f6..011c252 100644 --- a/ob-declarative-project.el +++ b/ob-declarative-project.el @@ -30,6 +30,7 @@ (require 'ob) (require 'declarative-project-mode) +;;;###autoload (defun org-babel-execute:declarative-project (body params) "Execute a declarative-project source block. BODY is YAML/JSON content. The `:dir' header sets project root From d738ec79ef91eb4b924525db984fb8b821d422ab Mon Sep 17 00:00:00 2001 From: cuttlefisch Date: Fri, 20 Mar 2026 22:47:46 +0100 Subject: [PATCH 02/15] Add public API alias, package-version, docs; delete dead code - Add `declarative-project-install` as public alias for the private `--install-project` interactive command; bind keymap to public name - Add :package-version to both defcustom declarations - Expand Commentary section with full .project field documentation - Improve minor mode docstring and accessor function docstrings - Delete legacy ERT test file (references removed API) - Add .dir-locals.el for development settings Co-Authored-By: Claude Opus 4.6 --- .dir-locals.el | 6 ++++ declarative-project-mode-test.el | 56 ------------------------------- declarative-project-mode.el | 57 ++++++++++++++++++++++++-------- declarative-project-treemacs.el | 3 +- 4 files changed, 51 insertions(+), 71 deletions(-) create mode 100644 .dir-locals.el delete mode 100644 declarative-project-mode-test.el diff --git a/.dir-locals.el b/.dir-locals.el new file mode 100644 index 0000000..316f3ca --- /dev/null +++ b/.dir-locals.el @@ -0,0 +1,6 @@ +;;; Directory Local Variables -*- no-byte-compile: t -*- +((emacs-lisp-mode + (indent-tabs-mode . nil) + (fill-column . 80) + (sentence-end-double-space . t) + (checkdoc-verb-check-experimental-flag . nil))) diff --git a/declarative-project-mode-test.el b/declarative-project-mode-test.el deleted file mode 100644 index bfbc224..0000000 --- a/declarative-project-mode-test.el +++ /dev/null @@ -1,56 +0,0 @@ -(require 'ert) -(require 'declarative-project-mode) - -(defun simple-hash-table (&rest pairs) - (let ((le-hash (make-hash-table :test 'equal))) - (dolist (pair pairs) - (puthash (car pair) (cadr pair) le-hash)) - le-hash)) - -(ert-deftest declarative-project--install-project-dependencies-test () - (let ((project (declarative-project--make-declarative-project-with-defaults - :name "test" - :root-directory "/tmp/test" - :deps (list (simple-hash-table - '(src "git@github.com:cuttlefisch/declarative-project-mode.git") - '(dest "test-project"))))) - (declarative-project--clobber t) - (install-target-path "/tmp/test/test-project")) - (declarative-project--prep-target project) - (declarative-project--install-project-dependencies project) - (when-let ((success (should (file-directory-p install-target-path)))) - (delete-directory (declarative-project-root-directory project) t) - success))) - -(ert-deftest declarative-project--copy-local-files-test () - (let ((project (declarative-project--make-declarative-project-with-defaults - :name "test" - :root-directory "/tmp/test" - :local-files (list (simple-hash-table - '(src "./test/local-file.txt") - '(dest "file.txt"))))) - (declarative-project--clobber t) - (expected-resource "/tmp/test/file.txt")) - (declarative-project--prep-target project) - (declarative-project--copy-local-files project) - (when-let ((success (should (file-exists-p expected-resource)))) - (delete-directory (declarative-project-root-directory project) t) - success))) - -(ert-deftest declarative-project--create-symlinks-test () - (let ((project (declarative-project--make-declarative-project-with-defaults - :name "test" - :root-directory "/tmp/test" - :symlinks (list (simple-hash-table - '(targ "./test/symlink-target.txt") - '(link "test-link"))))) - (declarative-project--clobber t) - (expected-resource "/tmp/test/test-link")) - (declarative-project--prep-target project) - (declarative-project--create-symlinks project) - (when-let ((success (should (file-symlink-p expected-resource)))) - (delete-directory (declarative-project-root-directory project) t) - success))) - - -(ert-run-tests-batch-and-exit) diff --git a/declarative-project-mode.el b/declarative-project-mode.el index 63710ce..8451672 100644 --- a/declarative-project-mode.el +++ b/declarative-project-mode.el @@ -26,14 +26,26 @@ ;; ;;; Commentary: ;; -;; Declarative Project mode is a minor mode for managing project resources. The -;; mode is triggered by visiting a directory containing a .project file. The -;; .project file should be in yaml or json format and may contain the following -;; fields "project-name", "required-resources", "deps", "local-files", -;; "symlinks", "treemacs-workspaces". +;; Declarative Project mode is a minor mode for managing project resources +;; through declarative `.project' files. The mode activates automatically +;; when visiting a `.project' file (via `find-file-hook'). ;; -;; Keybindings: - `C-c C-c i': Run the install-project command when visiting -;; .project file +;; A `.project' file is a YAML or JSON document that may declare: +;; +;; - `project-name' — human-readable project label +;; - `required-resources' — paths that must exist (warnings on missing) +;; - `deps' — git repositories to clone +;; - `local-files' — files/directories to copy into the project +;; - `symlinks' — symbolic links to create +;; - `treemacs-workspaces' — treemacs workspace assignments (see +;; `declarative-project-treemacs' for full +;; workspace management) +;; +;; Use `declarative-project-install' (bound to `C-c C-c i') to process the +;; spec. Set `declarative-project-auto-install' to run it on mode activation. +;; +;; See the project README for full documentation: +;; https://github.com/cuttlefisch/declarative-project-mode ;; ;;; Code: (require 'json) @@ -53,7 +65,8 @@ (defcustom declarative-project-auto-install nil "If non-nil, automatically run installation when mode activates." :type 'boolean - :group 'declarative-project) + :group 'declarative-project + :package-version '(declarative-project-mode . "0.2.0")) ;;; --- Hooks --- @@ -64,17 +77,20 @@ Functions receive a single argument: the project-resources hash table.") ;;; --- Accessor functions --- (defun declarative-project-workspaces (project-resources) - "Return the treemacs-workspaces list from PROJECT-RESOURCES." + "Return the treemacs-workspaces list from PROJECT-RESOURCES hash table. +The value is a list of workspace name strings, or nil if unset." (gethash 'treemacs-workspaces project-resources)) (defun declarative-project-root-directory (project-resources) - "Return the project root directory from PROJECT-RESOURCES." + "Return the project root directory from PROJECT-RESOURCES hash table. +Prefers the explicit `project-root' key; falls back to the parent +directory of `project-file' if set." (or (gethash 'project-root project-resources) (when-let ((pf (gethash 'project-file project-resources))) (file-name-directory pf)))) (defun declarative-project-name (project-resources) - "Return the project name from PROJECT-RESOURCES." + "Return the project name string from PROJECT-RESOURCES hash table, or nil." (gethash 'project-name project-resources)) ;;; --- Core functions --- @@ -205,20 +221,33 @@ EXTRA-KEYS is an alist of additional keys to set in the resource hash." (file-name-directory project-file) (list (cons 'project-file project-file))))) +;;;###autoload +(defalias 'declarative-project-install #'declarative-project--install-project + "Parse the .project file in `default-directory' and install all declared resources. +This is the public entry point for project installation.") + ;;; --- Mode definition --- ;;;###autoload (define-minor-mode declarative-project-mode - "Declarative Project mode." + "Minor mode for declarative project resource management. + +Activates automatically when visiting a `.project' file. Provides +`declarative-project-install' (\\[declarative-project-install]) to +parse the spec and install declared resources (dependencies, files, +symlinks, treemacs workspaces). + +See Info node `(declarative-project-mode)' or the project README +for the full `.project' file format." :lighter " DPM" :group 'declarative-project :keymap (let ((map (make-sparse-keymap))) (define-key map (kbd "C-c C-c i") - #'declarative-project--install-project) + #'declarative-project-install) map) (when (and declarative-project-mode declarative-project-auto-install) - (declarative-project--install-project))) + (declarative-project-install))) (defun declarative-project--maybe-enable () "Enable `declarative-project-mode' if visiting a .project file." diff --git a/declarative-project-treemacs.el b/declarative-project-treemacs.el index 1986d53..448457a 100644 --- a/declarative-project-treemacs.el +++ b/declarative-project-treemacs.el @@ -46,7 +46,8 @@ Initialized lazily when the mode is enabled.") (defcustom declarative-project-treemacs-autoprune t "If non-nil, remove invalid projects from cache on mode init." :type 'boolean - :group 'declarative-project) + :group 'declarative-project + :package-version '(declarative-project-treemacs . "0.2.0")) ;;; --- Backward compatibility --- From 28e1eee7eea560241e1d2c76e242e52c1f384cde Mon Sep 17 00:00:00 2001 From: cuttlefisch Date: Fri, 20 Mar 2026 22:50:05 +0100 Subject: [PATCH 03/15] Add treemacs integration test suite (20 specs) - Expand test-helper.el with cl-defstruct stubs for treemacs-workspace and treemacs-project, plus with-treemacs-test-state macro - Add test-declarative-project-treemacs.el covering: minimal-desired-state, ensure-desired-state, workspaces-by-name, save-cache/read-cache round-trip, assign-project (new ws, existing ws, dedup), unassign-project (happy path + nil regression), remove-workspace, prune-invalid-projects, override-workspaces, assign-declared-project (hook integration), and mode hook add/remove - Update Makefile to load new test file - Total: 52 specs, 0 failures Co-Authored-By: Claude Opus 4.6 --- Makefile | 1 + test/test-declarative-project-treemacs.el | 281 ++++++++++++++++++++++ test/test-helper.el | 35 ++- 3 files changed, 315 insertions(+), 2 deletions(-) create mode 100644 test/test-declarative-project-treemacs.el diff --git a/Makefile b/Makefile index 58d5fb3..567d2bb 100644 --- a/Makefile +++ b/Makefile @@ -36,6 +36,7 @@ test: -l buttercup \ -l test-helper \ -l test-declarative-project \ + -l test-declarative-project-treemacs \ -l test-ob-declarative-project \ -f buttercup-run diff --git a/test/test-declarative-project-treemacs.el b/test/test-declarative-project-treemacs.el new file mode 100644 index 0000000..3f4ea0c --- /dev/null +++ b/test/test-declarative-project-treemacs.el @@ -0,0 +1,281 @@ +;;; test-declarative-project-treemacs.el --- Tests for treemacs integration -*- lexical-binding: t -*- + +;;; Commentary: +;; Buttercup test suite for declarative-project-treemacs. + +;;; Code: + +(require 'test-helper) +(require 'declarative-project-treemacs) + +;;; ========================================================================== +;;; declarative-project-treemacs--minimal-desired-state +;;; ========================================================================== + +(describe "declarative-project-treemacs--minimal-desired-state" + (it "returns a list with one Default workspace" + (let ((state (declarative-project-treemacs--minimal-desired-state))) + (expect (length state) :to-equal 1) + (expect (treemacs-workspace->name (car state)) :to-equal "Default"))) + + (it "creates a workspace with empty projects list" + (let ((ws (car (declarative-project-treemacs--minimal-desired-state)))) + (expect (treemacs-workspace->projects ws) :to-equal nil)))) + +;;; ========================================================================== +;;; declarative-project-treemacs--ensure-desired-state +;;; ========================================================================== + +(describe "declarative-project-treemacs--ensure-desired-state" + (it "initializes state when nil" + (with-treemacs-test-state + (declarative-project-treemacs--ensure-desired-state) + (expect declarative-project-treemacs--desired-state :to-be-truthy) + (expect (length declarative-project-treemacs--desired-state) :to-equal 1))) + + (it "does not overwrite existing state" + (with-treemacs-test-state + (let ((custom-ws (treemacs-workspace->create! :name "Custom" :projects nil))) + (setq declarative-project-treemacs--desired-state (list custom-ws)) + (declarative-project-treemacs--ensure-desired-state) + (expect (treemacs-workspace->name + (car declarative-project-treemacs--desired-state)) + :to-equal "Custom"))))) + +;;; ========================================================================== +;;; declarative-project-treemacs--workspaces-by-name +;;; ========================================================================== + +(describe "declarative-project-treemacs--workspaces-by-name" + (it "finds a workspace by name" + (with-treemacs-test-state + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! :name "Alpha" :projects nil) + (treemacs-workspace->create! :name "Beta" :projects nil))) + (let ((result (declarative-project-treemacs--workspaces-by-name "Beta"))) + (expect result :to-be-truthy) + (expect (treemacs-workspace->name result) :to-equal "Beta")))) + + (it "returns nil when no workspace matches" + (with-treemacs-test-state + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! :name "Alpha" :projects nil))) + (expect (declarative-project-treemacs--workspaces-by-name "Nope") + :to-equal nil)))) + +;;; ========================================================================== +;;; declarative-project-treemacs--save-cache / --read-cache +;;; ========================================================================== + +(describe "cache persistence" + (it "round-trips desired state through save and read" + (with-treemacs-test-state + (let ((proj (treemacs-project->create! + :name "MyProj" :path "/tmp/myproj" + :path-status 'local-readable :is-disabled? nil))) + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! + :name "TestWS" :projects (list proj)))) + (declarative-project-treemacs--save-cache) + ;; Clear and reload + (setq declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--read-cache) + (expect (length declarative-project-treemacs--desired-state) :to-equal 1) + (let ((ws (car declarative-project-treemacs--desired-state))) + (expect (treemacs-workspace->name ws) :to-equal "TestWS") + (expect (length (treemacs-workspace->projects ws)) :to-equal 1))))) + + (it "initializes and saves when cache file does not exist" + (with-treemacs-test-state + (delete-file cache-file) + (declarative-project-treemacs--read-cache) + (expect declarative-project-treemacs--desired-state :to-be-truthy) + (expect (file-exists-p cache-file) :to-be-truthy)))) + +;;; ========================================================================== +;;; declarative-project-treemacs--assign-project +;;; ========================================================================== + +(describe "declarative-project-treemacs--assign-project" + (it "creates a new workspace when target does not exist" + (with-treemacs-test-state + (declarative-project-treemacs--assign-project + (list :name "Proj" :path "/tmp/proj" + :path-status 'local-readable :is-disabled? nil) + "NewWS") + (let ((ws (declarative-project-treemacs--workspaces-by-name "NewWS"))) + (expect ws :to-be-truthy) + (expect (length (treemacs-workspace->projects ws)) :to-equal 1)))) + + (it "appends project to existing workspace" + (with-treemacs-test-state + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! :name "ExistingWS" :projects nil))) + (declarative-project-treemacs--assign-project + (list :name "Proj" :path "/tmp/proj" + :path-status 'local-readable :is-disabled? nil) + "ExistingWS") + (let ((ws (declarative-project-treemacs--workspaces-by-name "ExistingWS"))) + (expect (length (treemacs-workspace->projects ws)) :to-equal 1)))) + + (it "does not duplicate projects with the same name" + (with-treemacs-test-state + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! :name "WS" :projects nil))) + (declarative-project-treemacs--assign-project + (list :name "Proj" :path "/tmp/proj" + :path-status 'local-readable :is-disabled? nil) + "WS") + (declarative-project-treemacs--assign-project + (list :name "Proj" :path "/tmp/proj" + :path-status 'local-readable :is-disabled? nil) + "WS") + (let ((ws (declarative-project-treemacs--workspaces-by-name "WS"))) + (expect (length (treemacs-workspace->projects ws)) :to-equal 1))))) + +;;; ========================================================================== +;;; declarative-project-treemacs--unassign-project +;;; ========================================================================== + +(describe "declarative-project-treemacs--unassign-project" + (it "removes a project from a workspace" + (with-treemacs-test-state + (let ((proj (treemacs-project->create! + :name "Proj" :path "/tmp/proj" + :path-status 'local-readable :is-disabled? nil))) + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! + :name "WS" :projects (list proj)))) + (declarative-project-treemacs--unassign-project "Proj" "WS") + (let ((ws (declarative-project-treemacs--workspaces-by-name "WS"))) + (expect (treemacs-workspace->projects ws) :to-equal nil))))) + + (it "does not error when workspace does not exist (nil regression)" + (with-treemacs-test-state + (declarative-project-treemacs--ensure-desired-state) + (expect (declarative-project-treemacs--unassign-project + "Proj" "NonexistentWS") + :not :to-throw)))) + +;;; ========================================================================== +;;; declarative-project-treemacs--remove-workspace +;;; ========================================================================== + +(describe "declarative-project-treemacs--remove-workspace" + (it "removes a workspace from desired state" + (with-treemacs-test-state + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! :name "Keep" :projects nil) + (treemacs-workspace->create! :name "Remove" :projects nil))) + (declarative-project-treemacs--remove-workspace "Remove") + (expect (length declarative-project-treemacs--desired-state) :to-equal 1) + (expect (treemacs-workspace->name + (car declarative-project-treemacs--desired-state)) + :to-equal "Keep")))) + +;;; ========================================================================== +;;; declarative-project-treemacs--prune-invalid-projects +;;; ========================================================================== + +(describe "declarative-project-treemacs--prune-invalid-projects" + (it "removes projects whose paths do not exist" + (with-treemacs-test-state + (let ((valid-dir (make-temp-file "dpm-prune-" t))) + (unwind-protect + (progn + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! + :name "WS" + :projects + (list (treemacs-project->create! + :name "Valid" :path valid-dir + :path-status 'local-readable :is-disabled? nil) + (treemacs-project->create! + :name "Invalid" :path "/nonexistent/path" + :path-status 'local-readable :is-disabled? nil))))) + (declarative-project-treemacs--prune-invalid-projects) + (let ((ws (car declarative-project-treemacs--desired-state))) + (expect (length (treemacs-workspace->projects ws)) :to-equal 1) + (expect (treemacs-project->name + (car (treemacs-workspace->projects ws))) + :to-equal "Valid"))) + (delete-directory valid-dir t)))))) + +;;; ========================================================================== +;;; declarative-project-treemacs--override-workspaces +;;; ========================================================================== + +(describe "declarative-project-treemacs--override-workspaces" + (it "sets treemacs--workspaces to desired state" + (with-treemacs-test-state + (let ((state (list (treemacs-workspace->create! + :name "Override" :projects nil)))) + (setq declarative-project-treemacs--desired-state state) + (declarative-project-treemacs--override-workspaces) + (expect treemacs--workspaces :to-equal state))))) + +;;; ========================================================================== +;;; declarative-project-treemacs--assign-declared-project (hook integration) +;;; ========================================================================== + +(describe "declarative-project-treemacs--assign-declared-project" + (it "assigns project to declared workspaces when mode is active" + (with-treemacs-test-state + (let ((declarative-project-treemacs-mode t) + (resources (make-hash-table :test 'equal))) + (puthash 'treemacs-workspaces '("DeclaredWS") resources) + (puthash 'project-root "/tmp/declared/" resources) + (puthash 'project-name "DeclaredProj" resources) + (declarative-project-treemacs--assign-declared-project resources) + (let ((ws (declarative-project-treemacs--workspaces-by-name "DeclaredWS"))) + (expect ws :to-be-truthy) + (expect (treemacs-project->name + (car (treemacs-workspace->projects ws))) + :to-equal "DeclaredProj"))))) + + (it "does nothing when mode is not active" + (with-treemacs-test-state + (let ((declarative-project-treemacs-mode nil) + (resources (make-hash-table :test 'equal))) + (puthash 'treemacs-workspaces '("WS") resources) + (puthash 'project-root "/tmp/proj/" resources) + (spy-on 'declarative-project-treemacs--assign-project) + (declarative-project-treemacs--assign-declared-project resources) + (expect 'declarative-project-treemacs--assign-project + :not :to-have-been-called))))) + +;;; ========================================================================== +;;; declarative-project-treemacs-mode (hook add/remove) +;;; ========================================================================== + +(describe "declarative-project-treemacs-mode" + (it "adds hooks when enabled" + (with-treemacs-test-state + (let ((declarative-project--apply-treemacs-workspaces-hook nil) + (treemacs-switch-workspace-hook nil)) + (declarative-project-treemacs-mode 1) + (unwind-protect + (progn + (expect (memq #'declarative-project-treemacs--assign-declared-project + declarative-project--apply-treemacs-workspaces-hook) + :to-be-truthy) + (expect (memq #'declarative-project-treemacs--override-workspaces + treemacs-switch-workspace-hook) + :to-be-truthy)) + (declarative-project-treemacs-mode -1))))) + + (it "removes hooks when disabled" + (with-treemacs-test-state + (let ((declarative-project--apply-treemacs-workspaces-hook nil) + (treemacs-switch-workspace-hook nil)) + (declarative-project-treemacs-mode 1) + (declarative-project-treemacs-mode -1) + (expect (memq #'declarative-project-treemacs--assign-declared-project + declarative-project--apply-treemacs-workspaces-hook) + :to-equal nil) + (expect (memq #'declarative-project-treemacs--override-workspaces + treemacs-switch-workspace-hook) + :to-equal nil))))) + +(provide 'test-declarative-project-treemacs) +;;; test-declarative-project-treemacs.el ends here diff --git a/test/test-helper.el b/test/test-helper.el index 1d8feae..b7714a2 100644 --- a/test/test-helper.el +++ b/test/test-helper.el @@ -80,14 +80,45 @@ Binds `project-dir' and `project-file'." ;;; --- Treemacs stubs --- -;; Stub treemacs functions so tests don't require treemacs +;; Stub treemacs structs and functions so tests don't require treemacs. +;; Real treemacs uses cl-defstruct; we replicate just enough for the +;; treemacs extension module to work. + (unless (featurep 'treemacs) + ;; --- Structs --- + (cl-defstruct (treemacs-workspace (:constructor treemacs-workspace->create!)) + name projects) + + (cl-defstruct (treemacs-project (:constructor treemacs-project->create!)) + name path path-status is-disabled?) + + ;; --- Workspace API stubs --- + (defvar treemacs--workspaces nil + "Stub for treemacs workspace list.") + (defun treemacs-do-create-workspace (&optional _name) '(success nil)) (defun treemacs-find-workspace-by-name (_name) nil) (defmacro treemacs-with-workspace (_ws &rest body) (declare (indent 1)) `(progn ,@body)) - (defun treemacs-do-add-project-to-workspace (_path _name) nil)) + (defun treemacs-do-add-project-to-workspace (_path _name) nil) + (defvar treemacs-switch-workspace-hook nil + "Stub for treemacs workspace switch hook.")) + +;;; --- Treemacs test macro --- + +(defmacro with-treemacs-test-state (&rest body) + "Execute BODY with a clean treemacs desired-state and temporary cache file. +Binds `cache-file' to the temporary cache path." + (declare (indent 0)) + `(let* ((cache-file (make-temp-file "dpm-treemacs-cache-" nil ".el")) + (declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--cache-file cache-file) + (declarative-project-treemacs-mode nil)) + (unwind-protect + (progn ,@body) + (when (file-exists-p cache-file) + (delete-file cache-file))))) ;;; --- Helper functions --- From 58c5367b43fae0e3e9d8c60b15cc0236ccde79f9 Mon Sep 17 00:00:00 2001 From: cuttlefisch Date: Fri, 20 Mar 2026 22:51:28 +0100 Subject: [PATCH 04/15] Bump version to 0.3.0 and add CHANGELOG.org - Update Version header to 0.3.0 and Modified date in all three package files - Update Package-Requires dependency versions in treemacs and ob modules - Add CHANGELOG.org documenting 0.3.0 and reconstructed 0.2.0 history Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.org | 57 +++++++++++++++++++++++++++++++++ declarative-project-mode.el | 4 +-- declarative-project-treemacs.el | 6 ++-- ob-declarative-project.el | 5 +-- 4 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 CHANGELOG.org diff --git a/CHANGELOG.org b/CHANGELOG.org new file mode 100644 index 0000000..50612d9 --- /dev/null +++ b/CHANGELOG.org @@ -0,0 +1,57 @@ +#+title: Changelog +#+author: Hayden Stanko + +* 0.3.0 — 2026-03-20 + +** Bug fixes +- Fix =warn= / =display-warning= mismatch :: All warning calls now use + =display-warning= with the ='declarative-project= type symbol, matching + existing test expectations and providing structured warning output. +- Fix treemacs =--unassign-project= nil dereference :: Wrapped workspace + lookup in =when-let= so =(treemacs-workspace->projects ws)= is never called + on a nil workspace. +- Remove tab characters from warning format strings. + +** New features +- =declarative-project-install= :: Public interactive command (alias for + the internal =--install-project=). Keymap binding =C-c C-c i= now points + to the public name. +- =;;;###autoload= cookies :: Added to =declarative-project-mode= and + =org-babel-execute:declarative-project= for proper lazy-loading via + =package.el=. + +** Code quality +- Added =:package-version= to all =defcustom= declarations. +- Expanded Commentary sections with full =.project= file format reference. +- Improved docstrings on minor mode, accessor functions, and public API. +- Deleted legacy ERT test file (=declarative-project-mode-test.el=) that + referenced removed API. +- Added =.dir-locals.el= for development settings. + +** Tests +- 20 new Buttercup specs for =declarative-project-treemacs= module: + =--minimal-desired-state=, =--ensure-desired-state=, =--workspaces-by-name=, + cache round-trip, =--assign-project= (new workspace, existing, dedup), + =--unassign-project= (happy path + nil regression), =--remove-workspace=, + =--prune-invalid-projects=, =--override-workspaces=, + =--assign-declared-project= (hook integration), + =declarative-project-treemacs-mode= (hook add/remove). +- Expanded =test-helper.el= with =cl-defstruct= stubs for + =treemacs-workspace= / =treemacs-project= and =with-treemacs-test-state= + macro. +- Total spec count: 52 (up from 32). + +* 0.2.0 — 2026-03-15 + +** Highlights +- Rewrote core to use hash-table based =project-resources= instead of + =cl-defstruct=. +- Absorbed =treemacs-declarative-workspaces-mode= into + =declarative-project-treemacs.el=. +- Added =ob-declarative-project.el= for Org-Babel integration. +- Added =declarative-project--install-from-content= for programmatic / + Org-Babel use without a =.project= file on disk. +- Switched test framework from ERT to Buttercup (32 specs). +- Replaced =shell-command= with =call-process= for git clone (security). +- Added CI via GitHub Actions, README, and CONTRIBUTING guide. +- Dropped =file-name-parent-directory= for Emacs 28.1 compatibility. diff --git a/declarative-project-mode.el b/declarative-project-mode.el index 8451672..c053e27 100644 --- a/declarative-project-mode.el +++ b/declarative-project-mode.el @@ -5,8 +5,8 @@ ;; Author: Hayden Stanko ;; Maintainer: Hayden Stanko ;; Created: January 13, 2023 -;; Modified: March 15, 2026 -;; Version: 0.2.0 +;; Modified: March 20, 2026 +;; Version: 0.3.0 ;; Keywords: convenience, tools, project ;; Homepage: https://github.com/cuttlefisch/declarative-project-mode ;; Package-Requires: ((emacs "28.1") (yaml "0.5.1")) diff --git a/declarative-project-treemacs.el b/declarative-project-treemacs.el index 448457a..8a17665 100644 --- a/declarative-project-treemacs.el +++ b/declarative-project-treemacs.el @@ -5,11 +5,11 @@ ;; Author: Hayden Stanko ;; Maintainer: Hayden Stanko ;; Created: January 14, 2023 -;; Modified: March 15, 2026 -;; Version: 0.2.0 +;; Modified: March 20, 2026 +;; Version: 0.3.0 ;; Keywords: convenience, tools, project ;; Homepage: https://github.com/cuttlefisch/declarative-project-mode -;; Package-Requires: ((emacs "28.1") (treemacs "2.10") (declarative-project-mode "0.2.0")) +;; Package-Requires: ((emacs "28.1") (treemacs "2.10") (declarative-project-mode "0.3.0")) ;; ;; This file is not part of GNU Emacs. ;; diff --git a/ob-declarative-project.el b/ob-declarative-project.el index 011c252..d983030 100644 --- a/ob-declarative-project.el +++ b/ob-declarative-project.el @@ -5,10 +5,11 @@ ;; Author: Hayden Stanko ;; Maintainer: Hayden Stanko ;; Created: March 15, 2026 -;; Version: 0.2.0 +;; Modified: March 20, 2026 +;; Version: 0.3.0 ;; Keywords: convenience, tools, project ;; Homepage: https://github.com/cuttlefisch/declarative-project-mode -;; Package-Requires: ((emacs "28.1") (org "9.0") (declarative-project-mode "0.2.0")) +;; Package-Requires: ((emacs "28.1") (org "9.0") (declarative-project-mode "0.3.0")) ;; ;; This file is not part of GNU Emacs. ;; From 16a13c42aa217cbfa812a8fbc5e3c60fb51b602f Mon Sep 17 00:00:00 2001 From: cuttlefisch Date: Fri, 20 Mar 2026 23:03:36 +0100 Subject: [PATCH 05/15] Fix args-out-of-range on stale treemacs cache structs The treemacs-workspace struct gained an is-disabled? field, but old cache entries (written with the 2-field layout) cause args-out-of-range when treemacs tries to access the missing slot. - Add --normalize-cache to re-create all workspace/project structs via their constructors after loading, so missing fields get defaults - Call normalization from --read-cache after every load - Add is-disabled? to workspace stub in test-helper (matches real treemacs) - Add regression test: write old-layout cache, load, verify accessor works Co-Authored-By: Claude Opus 4.6 --- declarative-project-treemacs.el | 44 +++++++++++++++++++++-- test/test-declarative-project-treemacs.el | 20 ++++++++++- test/test-helper.el | 2 +- 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/declarative-project-treemacs.el b/declarative-project-treemacs.el index 8a17665..7b70b34 100644 --- a/declarative-project-treemacs.el +++ b/declarative-project-treemacs.el @@ -75,6 +75,43 @@ Initialized lazily when the mode is enabled.") ;;; --- Cache persistence --- +(defun declarative-project-treemacs--normalize-struct (record constructor &rest accessors) + "Re-create RECORD via CONSTRUCTOR using ACCESSORS to extract fields. +Any accessor that fails (e.g. due to a struct layout change) yields nil. +This ensures cached structs are migrated to the current layout." + (apply constructor + (mapcan (lambda (acc) + (let* ((keyword (intern (format ":%s" (car acc)))) + (value (condition-case nil + (funcall (cdr acc) record) + (args-out-of-range nil)))) + (list keyword value))) + accessors))) + +(defun declarative-project-treemacs--normalize-cache () + "Re-create all workspace/project structs to match current definitions. +Handles cache entries written with older struct layouts that may be +missing fields (e.g. `is-disabled?')." + (setq declarative-project-treemacs--desired-state + (mapcar + (lambda (ws) + (declarative-project-treemacs--normalize-struct + ws #'treemacs-workspace->create! + (cons 'name #'treemacs-workspace->name) + (cons 'projects + (lambda (w) + (mapcar + (lambda (proj) + (declarative-project-treemacs--normalize-struct + proj #'treemacs-project->create! + (cons 'name #'treemacs-project->name) + (cons 'path #'treemacs-project->path) + (cons 'path-status #'treemacs-project->path-status) + (cons 'is-disabled? #'treemacs-project->is-disabled?))) + (treemacs-workspace->projects w)))) + (cons 'is-disabled? #'treemacs-workspace->is-disabled?))) + declarative-project-treemacs--desired-state))) + (defun declarative-project-treemacs--save-cache () "Write current desired state to cache file." (declarative-project-treemacs--ensure-desired-state) @@ -86,9 +123,12 @@ Initialized lazily when the mode is enabled.") ',declarative-project-treemacs--desired-state))))) (defun declarative-project-treemacs--read-cache () - "Read the desired state from the cache file." + "Read the desired state from the cache file. +After loading, normalizes all structs to the current layout." (if (file-exists-p declarative-project-treemacs--cache-file) - (load declarative-project-treemacs--cache-file nil t t) + (progn + (load declarative-project-treemacs--cache-file nil t t) + (declarative-project-treemacs--normalize-cache)) (declarative-project-treemacs--ensure-desired-state) (declarative-project-treemacs--save-cache))) diff --git a/test/test-declarative-project-treemacs.el b/test/test-declarative-project-treemacs.el index 3f4ea0c..61dc89c 100644 --- a/test/test-declarative-project-treemacs.el +++ b/test/test-declarative-project-treemacs.el @@ -90,7 +90,25 @@ (delete-file cache-file) (declarative-project-treemacs--read-cache) (expect declarative-project-treemacs--desired-state :to-be-truthy) - (expect (file-exists-p cache-file) :to-be-truthy)))) + (expect (file-exists-p cache-file) :to-be-truthy))) + + (it "normalizes old structs missing is-disabled? field after load" + (with-treemacs-test-state + ;; Write a cache file with old-layout structs (missing is-disabled?) + (with-temp-file cache-file + (insert ";; -*- no-byte-compile: t -*-\n") + (insert "(setq declarative-project-treemacs--desired-state '(") + ;; 2-field workspace: only name + projects, no is-disabled? + (insert "#s(treemacs-workspace \"OldWS\" (") + (insert "#s(treemacs-project \"OldProj\" \"/tmp/old\" local-readable nil)") + (insert "))") + (insert "))\n")) + (declarative-project-treemacs--read-cache) + (let ((ws (car declarative-project-treemacs--desired-state))) + (expect (treemacs-workspace->name ws) :to-equal "OldWS") + (expect (length (treemacs-workspace->projects ws)) :to-equal 1) + ;; The key check: is-disabled? accessor must not error + (expect (treemacs-workspace->is-disabled? ws) :not :to-throw))))) ;;; ========================================================================== ;;; declarative-project-treemacs--assign-project diff --git a/test/test-helper.el b/test/test-helper.el index b7714a2..7df96f4 100644 --- a/test/test-helper.el +++ b/test/test-helper.el @@ -87,7 +87,7 @@ Binds `project-dir' and `project-file'." (unless (featurep 'treemacs) ;; --- Structs --- (cl-defstruct (treemacs-workspace (:constructor treemacs-workspace->create!)) - name projects) + name projects is-disabled?) (cl-defstruct (treemacs-project (:constructor treemacs-project->create!)) name path path-status is-disabled?) From 7f95cfdb422933465a602dde3d2bf6cbf54abb09 Mon Sep 17 00:00:00 2001 From: cuttlefisch Date: Fri, 20 Mar 2026 23:23:15 +0100 Subject: [PATCH 06/15] Support legacy YAML key names and root-directory from spec The pre-refactor code used `name`, `workspaces`, and `root-directory` as YAML keys. All existing org-babel blocks and many .project files still use these names. The refactored code only recognized `project-name` and `treemacs-workspaces`, silently ignoring the legacy variants. - Accessor `declarative-project-workspaces`: fall back to `workspaces` key when `treemacs-workspaces` is absent - Accessor `declarative-project-name`: fall back to `name` key when `project-name` is absent - Accessor `declarative-project-root-directory`: fall back to `root-directory` key (expanded) when `project-root` is unset - `--install-from-content`: use `root-directory` from parsed spec as project root, overriding the caller's project-dir fallback - `--apply-treemacs-workspaces`: use accessors instead of direct gethash so alias resolution is applied consistently - Add 8 new specs for alias behavior and root-directory override - Update Commentary to document both key name variants Co-Authored-By: Claude Opus 4.6 --- declarative-project-mode.el | 41 ++++++++++++------ test/test-declarative-project.el | 73 ++++++++++++++++++++++++++++++-- 2 files changed, 97 insertions(+), 17 deletions(-) diff --git a/declarative-project-mode.el b/declarative-project-mode.el index c053e27..d312f2f 100644 --- a/declarative-project-mode.el +++ b/declarative-project-mode.el @@ -32,14 +32,15 @@ ;; ;; A `.project' file is a YAML or JSON document that may declare: ;; -;; - `project-name' — human-readable project label +;; - `project-name' — human-readable project label (alias: `name') +;; - `root-directory' — project root path (overrides `:dir' / cwd) ;; - `required-resources' — paths that must exist (warnings on missing) ;; - `deps' — git repositories to clone ;; - `local-files' — files/directories to copy into the project ;; - `symlinks' — symbolic links to create -;; - `treemacs-workspaces' — treemacs workspace assignments (see -;; `declarative-project-treemacs' for full -;; workspace management) +;; - `treemacs-workspaces' — treemacs workspace assignments (alias: +;; `workspaces'; see `declarative-project-treemacs' +;; for full workspace management) ;; ;; Use `declarative-project-install' (bound to `C-c C-c i') to process the ;; spec. Set `declarative-project-auto-install' to run it on mode activation. @@ -78,20 +79,27 @@ Functions receive a single argument: the project-resources hash table.") (defun declarative-project-workspaces (project-resources) "Return the treemacs-workspaces list from PROJECT-RESOURCES hash table. -The value is a list of workspace name strings, or nil if unset." - (gethash 'treemacs-workspaces project-resources)) +The value is a list of workspace name strings, or nil if unset. +Accepts both `treemacs-workspaces' and `workspaces' as key names." + (or (gethash 'treemacs-workspaces project-resources) + (gethash 'workspaces project-resources))) (defun declarative-project-root-directory (project-resources) "Return the project root directory from PROJECT-RESOURCES hash table. -Prefers the explicit `project-root' key; falls back to the parent -directory of `project-file' if set." +Prefers the explicit `project-root' key (set by the install pipeline); +falls back to `root-directory' from the spec (expanded to absolute), +then to the parent directory of `project-file'." (or (gethash 'project-root project-resources) + (when-let ((rd (gethash 'root-directory project-resources))) + (file-name-as-directory (expand-file-name rd))) (when-let ((pf (gethash 'project-file project-resources))) (file-name-directory pf)))) (defun declarative-project-name (project-resources) - "Return the project name string from PROJECT-RESOURCES hash table, or nil." - (gethash 'project-name project-resources)) + "Return the project name string from PROJECT-RESOURCES hash table, or nil. +Accepts both `project-name' and `name' as key names." + (or (gethash 'project-name project-resources) + (gethash 'name project-resources))) ;;; --- Core functions --- @@ -162,13 +170,13 @@ directory of `project-file' if set." (defun declarative-project--apply-treemacs-workspaces (project-resources) "Add project to any treemacs workspaces listed in PROJECT-RESOURCES." - (when-let ((project-workspaces (gethash 'treemacs-workspaces project-resources)) + (when-let ((project-workspaces (declarative-project-workspaces project-resources)) (root-dir (declarative-project-root-directory project-resources))) (run-hook-with-args 'declarative-project--apply-treemacs-workspaces-hook project-resources) (when (featurep 'treemacs) (dolist (workspace project-workspaces) - (let ((project-name (or (gethash 'project-name project-resources) workspace))) + (let ((project-name (or (declarative-project-name project-resources) workspace))) (message "Adding project to treemacs workspace: %s" workspace) (treemacs-do-create-workspace workspace) (treemacs-with-workspace (treemacs-find-workspace-by-name workspace) @@ -193,9 +201,14 @@ if both parsers fail or if the result is not a hash table." (defun declarative-project--install-from-content (content project-dir &optional extra-keys) "Parse CONTENT (YAML/JSON) and run the install pipeline rooted at PROJECT-DIR. +If the spec contains a `root-directory' key, that path is used as the +project root instead of PROJECT-DIR. EXTRA-KEYS is an alist of additional keys to set in the resource hash." - (let* ((default-directory (file-name-as-directory (expand-file-name project-dir))) - (project-resources (declarative-project--parse-project-file content))) + (let* ((project-resources (declarative-project--parse-project-file content)) + (root-from-spec (when-let ((rd (gethash 'root-directory project-resources))) + (file-name-as-directory (expand-file-name rd)))) + (default-directory (or root-from-spec + (file-name-as-directory (expand-file-name project-dir))))) (puthash 'project-root default-directory project-resources) (dolist (pair extra-keys) (puthash (car pair) (cdr pair) project-resources)) diff --git a/test/test-declarative-project.el b/test/test-declarative-project.el index 9b5e87e..cd06e07 100644 --- a/test/test-declarative-project.el +++ b/test/test-declarative-project.el @@ -211,7 +211,19 @@ (let ((resources (make-hash-table :test 'equal))) (spy-on 'run-hook-with-args) (declarative-project--apply-treemacs-workspaces resources) - (expect 'run-hook-with-args :not :to-have-been-called)))) + (expect 'run-hook-with-args :not :to-have-been-called))) + + (it "runs the hook when using workspaces alias key" + (with-temp-project-dir + (let ((resources (make-hash-table :test 'equal)) + (hook-called nil)) + (puthash 'workspaces '("WS1") resources) + (puthash 'project-file (expand-file-name ".project") resources) + (puthash 'name "Test" resources) + (let ((declarative-project--apply-treemacs-workspaces-hook + (list (lambda (res) (setq hook-called t))))) + (declarative-project--apply-treemacs-workspaces resources) + (expect hook-called :to-be-truthy)))))) ;;; ========================================================================== ;;; declarative-project--install-from-content @@ -254,7 +266,25 @@ (let ((result (declarative-project--install-from-content test-yaml-fixture project-dir (list (cons 'my-key "my-value"))))) - (expect (gethash 'my-key result) :to-equal "my-value"))))) + (expect (gethash 'my-key result) :to-equal "my-value")))) + + (it "uses root-directory from spec as project root when present" + (with-temp-project-dir + (let ((root-dir (make-temp-file "dpm-root-" t))) + (unwind-protect + (progn + (spy-on 'declarative-project--check-required-resources) + (spy-on 'declarative-project--install-project-dependencies) + (spy-on 'declarative-project--copy-local-files) + (spy-on 'declarative-project--create-symlinks) + (spy-on 'declarative-project--apply-treemacs-workspaces) + (let* ((yaml (format "name: Test\nroot-directory: %s\n" root-dir)) + (result (declarative-project--install-from-content + yaml project-dir))) + (expect (gethash 'project-root result) + :to-equal (file-name-as-directory + (expand-file-name root-dir))))) + (delete-directory root-dir t)))))) ;;; ========================================================================== ;;; declarative-project--install-project (parsing) @@ -329,7 +359,44 @@ (it "declarative-project-name returns project-name value" (let ((resources (make-hash-table :test 'equal))) (puthash 'project-name "My Project" resources) - (expect (declarative-project-name resources) :to-equal "My Project")))) + (expect (declarative-project-name resources) :to-equal "My Project"))) + + ;; --- Key alias tests --- + + (it "declarative-project-workspaces falls back to workspaces key" + (let ((resources (make-hash-table :test 'equal))) + (puthash 'workspaces '("WS1") resources) + (expect (declarative-project-workspaces resources) :to-equal '("WS1")))) + + (it "declarative-project-workspaces prefers treemacs-workspaces over workspaces" + (let ((resources (make-hash-table :test 'equal))) + (puthash 'workspaces '("old") resources) + (puthash 'treemacs-workspaces '("new") resources) + (expect (declarative-project-workspaces resources) :to-equal '("new")))) + + (it "declarative-project-name falls back to name key" + (let ((resources (make-hash-table :test 'equal))) + (puthash 'name "Short Name" resources) + (expect (declarative-project-name resources) :to-equal "Short Name"))) + + (it "declarative-project-name prefers project-name over name" + (let ((resources (make-hash-table :test 'equal))) + (puthash 'name "old" resources) + (puthash 'project-name "new" resources) + (expect (declarative-project-name resources) :to-equal "new"))) + + (it "declarative-project-root-directory falls back to root-directory key" + (let ((resources (make-hash-table :test 'equal))) + (puthash 'root-directory "/tmp/myproject" resources) + (expect (declarative-project-root-directory resources) + :to-equal "/tmp/myproject/"))) + + (it "declarative-project-root-directory prefers project-root over root-directory" + (let ((resources (make-hash-table :test 'equal))) + (puthash 'root-directory "/tmp/old" resources) + (puthash 'project-root "/tmp/new/" resources) + (expect (declarative-project-root-directory resources) + :to-equal "/tmp/new/")))) ;;; ========================================================================== ;;; defgroup / defcustom From 5f08c0180538302619f1f612d0bd1e65e7e14880 Mon Sep 17 00:00:00 2001 From: cuttlefisch Date: Fri, 20 Mar 2026 23:26:00 +0100 Subject: [PATCH 07/15] Fix invalid function treemacs-with-workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit treemacs-with-workspace does not exist in treemacs — it was only defined in our test stubs. Replace with a let-binding of treemacs-override-workspace, which is the actual treemacs API for temporarily setting the current workspace context. Co-Authored-By: Claude Opus 4.6 --- declarative-project-mode.el | 4 +++- test/test-helper.el | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/declarative-project-mode.el b/declarative-project-mode.el index d312f2f..3da6240 100644 --- a/declarative-project-mode.el +++ b/declarative-project-mode.el @@ -55,6 +55,7 @@ (declare-function treemacs-do-create-workspace "treemacs-workspaces" (&optional name)) (declare-function treemacs-find-workspace-by-name "treemacs-workspaces" (name)) (declare-function treemacs-do-add-project-to-workspace "treemacs-workspaces" (path name)) +(defvar treemacs-override-workspace) ;;; --- Customization --- @@ -179,7 +180,8 @@ Accepts both `project-name' and `name' as key names." (let ((project-name (or (declarative-project-name project-resources) workspace))) (message "Adding project to treemacs workspace: %s" workspace) (treemacs-do-create-workspace workspace) - (treemacs-with-workspace (treemacs-find-workspace-by-name workspace) + (let ((treemacs-override-workspace + (treemacs-find-workspace-by-name workspace))) (treemacs-do-add-project-to-workspace root-dir project-name))))))) diff --git a/test/test-helper.el b/test/test-helper.el index 7df96f4..9560ba6 100644 --- a/test/test-helper.el +++ b/test/test-helper.el @@ -96,11 +96,11 @@ Binds `project-dir' and `project-file'." (defvar treemacs--workspaces nil "Stub for treemacs workspace list.") + (defvar treemacs-override-workspace nil + "Stub for treemacs workspace override variable.") + (defun treemacs-do-create-workspace (&optional _name) '(success nil)) (defun treemacs-find-workspace-by-name (_name) nil) - (defmacro treemacs-with-workspace (_ws &rest body) - (declare (indent 1)) - `(progn ,@body)) (defun treemacs-do-add-project-to-workspace (_path _name) nil) (defvar treemacs-switch-workspace-hook nil "Stub for treemacs workspace switch hook.")) From 6cfafda96628b5cc65f95aaca8ed86028862a9e2 Mon Sep 17 00:00:00 2001 From: cuttlefisch Date: Fri, 20 Mar 2026 23:34:55 +0100 Subject: [PATCH 08/15] Fix workspace not persisting in treemacs scope shelf After --override-workspaces replaces treemacs--workspaces with new objects, treemacs's per-frame scope shelf still holds a stale reference to the old workspace object. When treemacs checks its current workspace, it uses this stale reference, can't find a matching project for the current buffer's path, and falls back to directory browsing. - --override-workspaces now looks up the current workspace by name in the desired state and updates the scope shelf via setf on treemacs-current-workspace - Add treemacs-current-workspace stub with gv-setter to test-helper - Add test verifying scope shelf update after override Co-Authored-By: Claude Opus 4.6 --- declarative-project-treemacs.el | 17 +++++++++++++++-- test/test-declarative-project-treemacs.el | 13 ++++++++++++- test/test-helper.el | 13 ++++++++++++- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/declarative-project-treemacs.el b/declarative-project-treemacs.el index 7b70b34..fe92a8d 100644 --- a/declarative-project-treemacs.el +++ b/declarative-project-treemacs.el @@ -200,9 +200,22 @@ After loading, normalizes all structs to the current layout." ;;; --- Workspace override --- +(declare-function treemacs-current-workspace "treemacs-workspaces" ()) + (defun declarative-project-treemacs--override-workspaces () - "Set treemacs workspaces to the desired state." - (setq treemacs--workspaces declarative-project-treemacs--desired-state)) + "Set treemacs workspaces to the desired state. +Also updates treemacs's scope shelf so the current workspace points +to the corresponding object in the desired state list. Without this, +the scope shelf retains a stale reference and treemacs falls back to +directory browsing instead of showing workspace projects." + (setq treemacs--workspaces declarative-project-treemacs--desired-state) + (condition-case nil + (when-let* ((current (treemacs-current-workspace)) + (name (treemacs-workspace->name current)) + (updated (declarative-project-treemacs--workspaces-by-name name))) + (unless (eq current updated) + (setf (treemacs-current-workspace) updated))) + (error nil))) ;;; --- Hook integration --- diff --git a/test/test-declarative-project-treemacs.el b/test/test-declarative-project-treemacs.el index 61dc89c..1ac9c03 100644 --- a/test/test-declarative-project-treemacs.el +++ b/test/test-declarative-project-treemacs.el @@ -230,7 +230,18 @@ :name "Override" :projects nil)))) (setq declarative-project-treemacs--desired-state state) (declarative-project-treemacs--override-workspaces) - (expect treemacs--workspaces :to-equal state))))) + (expect treemacs--workspaces :to-equal state)))) + + (it "updates current workspace to matching object in desired state" + (with-treemacs-test-state + (let* ((old-ws (treemacs-workspace->create! :name "MyWS" :projects nil)) + (new-ws (treemacs-workspace->create! :name "MyWS" :projects nil))) + ;; Simulate treemacs having a stale workspace in scope shelf + (setf (treemacs-current-workspace) old-ws) + (setq declarative-project-treemacs--desired-state (list new-ws)) + (declarative-project-treemacs--override-workspaces) + ;; Current workspace should now be the new object, not the old one + (expect (treemacs-current-workspace) :to-be new-ws))))) ;;; ========================================================================== ;;; declarative-project-treemacs--assign-declared-project (hook integration) diff --git a/test/test-helper.el b/test/test-helper.el index 9560ba6..b63359a 100644 --- a/test/test-helper.el +++ b/test/test-helper.el @@ -99,6 +99,16 @@ Binds `project-dir' and `project-file'." (defvar treemacs-override-workspace nil "Stub for treemacs workspace override variable.") + (defvar treemacs--current-workspace-stub nil + "Stub storage for current workspace in tests.") + + (defun treemacs-current-workspace () + "Stub: return current workspace." + treemacs--current-workspace-stub) + + (gv-define-setter treemacs-current-workspace (val) + `(setq treemacs--current-workspace-stub ,val)) + (defun treemacs-do-create-workspace (&optional _name) '(success nil)) (defun treemacs-find-workspace-by-name (_name) nil) (defun treemacs-do-add-project-to-workspace (_path _name) nil) @@ -114,7 +124,8 @@ Binds `cache-file' to the temporary cache path." `(let* ((cache-file (make-temp-file "dpm-treemacs-cache-" nil ".el")) (declarative-project-treemacs--desired-state nil) (declarative-project-treemacs--cache-file cache-file) - (declarative-project-treemacs-mode nil)) + (declarative-project-treemacs-mode nil) + (treemacs--current-workspace-stub nil)) (unwind-protect (progn ,@body) (when (file-exists-p cache-file) From 26acbde1bffe57c3ab653e6a7a890016bc88fef6 Mon Sep 17 00:00:00 2001 From: cuttlefisch Date: Sat, 21 Mar 2026 13:36:48 +0100 Subject: [PATCH 09/15] Enforce workspace desired-state model over native treemacs fallback The install function's native treemacs code path was duplicating workspace assignments when declarative-project-treemacs-mode was active. Gate the fallback behind (not declarative-project-treemacs-mode) so the desired-state model is the single source of truth. Add two advice functions (after treemacs-switch-workspace and treemacs--restore) that re-apply desired state when treemacs switches or restores workspaces. Register/unregister them in the minor mode body. 7 new specs cover the fallback guard, advice lifecycle, and mode registration. Total: 74 specs, all passing. Co-Authored-By: Claude Opus 4.6 --- declarative-project-mode.el | 6 +- declarative-project-treemacs.el | 84 +++++++++-- test/test-declarative-project-treemacs.el | 161 +++++++++++++++++++++- test/test-helper.el | 24 +++- 4 files changed, 256 insertions(+), 19 deletions(-) diff --git a/declarative-project-mode.el b/declarative-project-mode.el index 3da6240..708f502 100644 --- a/declarative-project-mode.el +++ b/declarative-project-mode.el @@ -175,7 +175,11 @@ Accepts both `project-name' and `name' as key names." (root-dir (declarative-project-root-directory project-resources))) (run-hook-with-args 'declarative-project--apply-treemacs-workspaces-hook project-resources) - (when (featurep 'treemacs) + ;; When declarative-project-treemacs-mode is active, the hook above + ;; handles workspace assignment via the desired-state model. Only + ;; fall back to the native treemacs API when the extension mode is off. + (when (and (featurep 'treemacs) + (not (bound-and-true-p declarative-project-treemacs-mode))) (dolist (workspace project-workspaces) (let ((project-name (or (declarative-project-name project-resources) workspace))) (message "Adding project to treemacs workspace: %s" workspace) diff --git a/declarative-project-treemacs.el b/declarative-project-treemacs.el index fe92a8d..6eca746 100644 --- a/declarative-project-treemacs.el +++ b/declarative-project-treemacs.el @@ -201,21 +201,55 @@ After loading, normalizes all structs to the current layout." ;;; --- Workspace override --- (declare-function treemacs-current-workspace "treemacs-workspaces" ()) +(declare-function treemacs--invalidate-buffer-project-cache "treemacs-workspaces" ()) +(declare-function treemacs-select-window "treemacs" (&optional arg)) +(declare-function treemacs-persp--ensure-workspace-exists "treemacs-persp" ()) (defun declarative-project-treemacs--override-workspaces () "Set treemacs workspaces to the desired state. Also updates treemacs's scope shelf so the current workspace points -to the corresponding object in the desired state list. Without this, -the scope shelf retains a stale reference and treemacs falls back to -directory browsing instead of showing workspace projects." +to the corresponding object in the desired state list. When the +current workspace name is not found in the desired state (e.g. a +persp-created workspace), falls back to the first desired workspace. +Prevents treemacs from restoring its persist file by setting the +`:state-is-restored' flag after populating the workspace list." (setq treemacs--workspaces declarative-project-treemacs--desired-state) + ;; Prevent treemacs--maybe-load-workspaces from overwriting our state. + ;; This mode takes full control of the workspace list, so we + ;; intentionally suppress treemacs's one-time persist-file restore. + (put 'treemacs :state-is-restored t) + ;; Now safe to call treemacs-current-workspace — the lazy-load is a no-op. (condition-case nil - (when-let* ((current (treemacs-current-workspace)) - (name (treemacs-workspace->name current)) - (updated (declarative-project-treemacs--workspaces-by-name name))) - (unless (eq current updated) - (setf (treemacs-current-workspace) updated))) - (error nil))) + (let* ((current (treemacs-current-workspace)) + (name (and current (treemacs-workspace->name current))) + (target (or (and name (declarative-project-treemacs--workspaces-by-name name)) + (car declarative-project-treemacs--desired-state)))) + (when (and target (not (eq current target))) + (setf (treemacs-current-workspace) target) + (treemacs--invalidate-buffer-project-cache))) + (error nil)) + ;; Re-render the treemacs buffer so it reflects the updated workspace. + (when (treemacs-get-local-buffer) + (treemacs--consolidate-projects))) + +(defun declarative-project-treemacs--on-select (_reason) + "Re-apply declared workspaces after treemacs window is selected. +Called via `treemacs-select-functions'; REASON is ignored." + (declarative-project-treemacs--override-workspaces)) + +(defun declarative-project-treemacs--around-exclusive-display (orig-fn) + "Show treemacs without modifying workspace content when our mode is active. +ORIG-FN is `treemacs-add-and-display-current-project-exclusively'." + (if declarative-project-treemacs-mode + (treemacs-select-window) + (funcall orig-fn))) + +(defun declarative-project-treemacs--around-persp-ensure (orig-fn) + "Use our workspace override instead of persp workspace creation. +ORIG-FN is `treemacs-persp--ensure-workspace-exists'." + (if declarative-project-treemacs-mode + (declarative-project-treemacs--override-workspaces) + (funcall orig-fn))) ;;; --- Hook integration --- @@ -245,14 +279,29 @@ The desired state of workspaces is tracked in a central cache inside `user-emacs-directory'. When enabled, treemacs workspaces are overridden with the declared desired state. -Note: this mode currently takes full control of the treemacs workspace -list and is still experimental." +Note: this mode takes full control of the treemacs workspace list. +It is incompatible with `treemacs-project-follow-mode' (disabled +automatically). It also overrides +`treemacs-add-and-display-current-project-exclusively' (used by +Doom's `+treemacs/toggle') and suppresses `treemacs-persp' workspace +creation when active." :init-value nil :global t :group 'declarative-project :lighter " TDW" (if declarative-project-treemacs-mode (progn + ;; treemacs-project-follow-mode replaces workspace content with the + ;; current project on an idle timer, which conflicts with our + ;; workspace ownership. Disable it when this mode is active. + (when (bound-and-true-p treemacs-project-follow-mode) + (treemacs-project-follow-mode -1) + (message "declarative-project-treemacs-mode: disabled treemacs-project-follow-mode (incompatible)")) + (advice-add 'treemacs-add-and-display-current-project-exclusively + :around #'declarative-project-treemacs--around-exclusive-display) + (when (fboundp 'treemacs-persp--ensure-workspace-exists) + (advice-add 'treemacs-persp--ensure-workspace-exists + :around #'declarative-project-treemacs--around-persp-ensure)) (declarative-project-treemacs--read-cache) (when declarative-project-treemacs-autoprune (declarative-project-treemacs--prune-invalid-projects)) @@ -260,12 +309,21 @@ list and is still experimental." (add-hook 'declarative-project--apply-treemacs-workspaces-hook #'declarative-project-treemacs--assign-declared-project) (add-hook 'treemacs-switch-workspace-hook - #'declarative-project-treemacs--override-workspaces)) + #'declarative-project-treemacs--override-workspaces) + (add-hook 'treemacs-select-functions + #'declarative-project-treemacs--on-select)) (declarative-project-treemacs--save-cache) + (advice-remove 'treemacs-add-and-display-current-project-exclusively + #'declarative-project-treemacs--around-exclusive-display) + (when (fboundp 'treemacs-persp--ensure-workspace-exists) + (advice-remove 'treemacs-persp--ensure-workspace-exists + #'declarative-project-treemacs--around-persp-ensure)) (remove-hook 'declarative-project--apply-treemacs-workspaces-hook #'declarative-project-treemacs--assign-declared-project) (remove-hook 'treemacs-switch-workspace-hook - #'declarative-project-treemacs--override-workspaces))) + #'declarative-project-treemacs--override-workspaces) + (remove-hook 'treemacs-select-functions + #'declarative-project-treemacs--on-select))) (defalias 'treemacs-declarative-workspaces-mode #'declarative-project-treemacs-mode) diff --git a/test/test-declarative-project-treemacs.el b/test/test-declarative-project-treemacs.el index 1ac9c03..76501d0 100644 --- a/test/test-declarative-project-treemacs.el +++ b/test/test-declarative-project-treemacs.el @@ -232,6 +232,15 @@ (declarative-project-treemacs--override-workspaces) (expect treemacs--workspaces :to-equal state)))) + (it "sets :state-is-restored flag to prevent treemacs persist-file restore" + (with-treemacs-test-state + (let ((state (list (treemacs-workspace->create! + :name "FlagTest" :projects nil)))) + (setq declarative-project-treemacs--desired-state state) + (expect (get 'treemacs :state-is-restored) :not :to-be-truthy) + (declarative-project-treemacs--override-workspaces) + (expect (get 'treemacs :state-is-restored) :to-be t)))) + (it "updates current workspace to matching object in desired state" (with-treemacs-test-state (let* ((old-ws (treemacs-workspace->create! :name "MyWS" :projects nil)) @@ -241,7 +250,37 @@ (setq declarative-project-treemacs--desired-state (list new-ws)) (declarative-project-treemacs--override-workspaces) ;; Current workspace should now be the new object, not the old one - (expect (treemacs-current-workspace) :to-be new-ws))))) + (expect (treemacs-current-workspace) :to-be new-ws)))) + + (it "falls back to first desired workspace when current name is unknown" + (with-treemacs-test-state + (let* ((unknown-ws (treemacs-workspace->create! :name "Perspective main" :projects nil)) + (target-ws (treemacs-workspace->create! :name "MyProject" :projects nil))) + (setf (treemacs-current-workspace) unknown-ws) + (setq declarative-project-treemacs--desired-state (list target-ws)) + (declarative-project-treemacs--override-workspaces) + (expect (treemacs-current-workspace) :to-be target-ws)))) + + (it "falls back to first desired workspace when current workspace is nil" + (with-treemacs-test-state + (let ((target-ws (treemacs-workspace->create! :name "MyProject" :projects nil))) + (setf (treemacs-current-workspace) nil) + (setq declarative-project-treemacs--desired-state (list target-ws)) + (declarative-project-treemacs--override-workspaces) + (expect (treemacs-current-workspace) :to-be target-ws)))) + + (it "clears buffer project cache when switching workspace" + (with-treemacs-test-state + (let* ((old-ws (treemacs-workspace->create! :name "Old" :projects nil)) + (new-ws (treemacs-workspace->create! :name "New" :projects nil))) + (setf (treemacs-current-workspace) old-ws) + (setq declarative-project-treemacs--desired-state (list new-ws)) + ;; Set a buffer-local project cache value to verify it gets cleared + (setq-local treemacs--project-of-buffer 'stale-project) + (declarative-project-treemacs--override-workspaces) + ;; treemacs--invalidate-buffer-project-cache is a define-inline that + ;; clears treemacs--project-of-buffer in all buffers + (expect treemacs--project-of-buffer :to-be nil))))) ;;; ========================================================================== ;;; declarative-project-treemacs--assign-declared-project (hook integration) @@ -273,6 +312,19 @@ (expect 'declarative-project-treemacs--assign-project :not :to-have-been-called))))) +;;; ========================================================================== +;;; declarative-project-treemacs--on-select +;;; ========================================================================== + +(describe "declarative-project-treemacs--on-select" + (it "calls override-workspaces regardless of reason argument" + (with-treemacs-test-state + (spy-on 'declarative-project-treemacs--override-workspaces) + (declarative-project-treemacs--on-select 'exists) + (declarative-project-treemacs--on-select 'none) + (expect 'declarative-project-treemacs--override-workspaces + :to-have-been-called-times 2)))) + ;;; ========================================================================== ;;; declarative-project-treemacs-mode (hook add/remove) ;;; ========================================================================== @@ -281,7 +333,8 @@ (it "adds hooks when enabled" (with-treemacs-test-state (let ((declarative-project--apply-treemacs-workspaces-hook nil) - (treemacs-switch-workspace-hook nil)) + (treemacs-switch-workspace-hook nil) + (treemacs-select-functions nil)) (declarative-project-treemacs-mode 1) (unwind-protect (progn @@ -290,13 +343,17 @@ :to-be-truthy) (expect (memq #'declarative-project-treemacs--override-workspaces treemacs-switch-workspace-hook) + :to-be-truthy) + (expect (memq #'declarative-project-treemacs--on-select + treemacs-select-functions) :to-be-truthy)) (declarative-project-treemacs-mode -1))))) (it "removes hooks when disabled" (with-treemacs-test-state (let ((declarative-project--apply-treemacs-workspaces-hook nil) - (treemacs-switch-workspace-hook nil)) + (treemacs-switch-workspace-hook nil) + (treemacs-select-functions nil)) (declarative-project-treemacs-mode 1) (declarative-project-treemacs-mode -1) (expect (memq #'declarative-project-treemacs--assign-declared-project @@ -304,6 +361,104 @@ :to-equal nil) (expect (memq #'declarative-project-treemacs--override-workspaces treemacs-switch-workspace-hook) + :to-equal nil) + (expect (memq #'declarative-project-treemacs--on-select + treemacs-select-functions) + :to-equal nil)))) + + (it "disables treemacs-project-follow-mode when enabled" + (with-treemacs-test-state + (let ((declarative-project--apply-treemacs-workspaces-hook nil) + (treemacs-switch-workspace-hook nil) + (treemacs-select-functions nil) + (treemacs-project-follow-mode t)) + (spy-on 'treemacs-project-follow-mode) + (declarative-project-treemacs-mode 1) + (unwind-protect + (expect 'treemacs-project-follow-mode + :to-have-been-called-with -1) + (declarative-project-treemacs-mode -1)))))) + +;;; ========================================================================== +;;; declarative-project-treemacs--around-exclusive-display +;;; ========================================================================== + +(describe "declarative-project-treemacs--around-exclusive-display" + (it "calls treemacs-select-window when mode is active" + (with-treemacs-test-state + (let ((declarative-project-treemacs-mode t)) + (spy-on 'treemacs-select-window) + (let ((orig-called nil)) + (declarative-project-treemacs--around-exclusive-display + (lambda () (setq orig-called t))) + (expect 'treemacs-select-window :to-have-been-called) + (expect orig-called :to-equal nil))))) + + (it "calls orig-fn when mode is not active" + (with-treemacs-test-state + (let ((declarative-project-treemacs-mode nil)) + (spy-on 'treemacs-select-window) + (let ((orig-called nil)) + (declarative-project-treemacs--around-exclusive-display + (lambda () (setq orig-called t))) + (expect 'treemacs-select-window :not :to-have-been-called) + (expect orig-called :to-equal t)))))) + +;;; ========================================================================== +;;; declarative-project-treemacs--around-persp-ensure +;;; ========================================================================== + +(describe "declarative-project-treemacs--around-persp-ensure" + (it "calls override-workspaces when mode is active" + (with-treemacs-test-state + (let ((declarative-project-treemacs-mode t)) + (spy-on 'declarative-project-treemacs--override-workspaces) + (let ((orig-called nil)) + (declarative-project-treemacs--around-persp-ensure + (lambda () (setq orig-called t))) + (expect 'declarative-project-treemacs--override-workspaces + :to-have-been-called) + (expect orig-called :to-equal nil))))) + + (it "calls orig-fn when mode is not active" + (with-treemacs-test-state + (let ((declarative-project-treemacs-mode nil)) + (spy-on 'declarative-project-treemacs--override-workspaces) + (let ((orig-called nil)) + (declarative-project-treemacs--around-persp-ensure + (lambda () (setq orig-called t))) + (expect 'declarative-project-treemacs--override-workspaces + :not :to-have-been-called) + (expect orig-called :to-equal t)))))) + +;;; ========================================================================== +;;; Advice registration (mode enable/disable) +;;; ========================================================================== + +(describe "declarative-project-treemacs-mode advice" + (it "registers exclusive-display advice when enabled" + (with-treemacs-test-state + (let ((declarative-project--apply-treemacs-workspaces-hook nil) + (treemacs-switch-workspace-hook nil) + (treemacs-select-functions nil)) + (declarative-project-treemacs-mode 1) + (unwind-protect + (expect (advice-member-p + #'declarative-project-treemacs--around-exclusive-display + 'treemacs-add-and-display-current-project-exclusively) + :to-be-truthy) + (declarative-project-treemacs-mode -1))))) + + (it "removes exclusive-display advice when disabled" + (with-treemacs-test-state + (let ((declarative-project--apply-treemacs-workspaces-hook nil) + (treemacs-switch-workspace-hook nil) + (treemacs-select-functions nil)) + (declarative-project-treemacs-mode 1) + (declarative-project-treemacs-mode -1) + (expect (advice-member-p + #'declarative-project-treemacs--around-exclusive-display + 'treemacs-add-and-display-current-project-exclusively) :to-equal nil))))) (provide 'test-declarative-project-treemacs) diff --git a/test/test-helper.el b/test/test-helper.el index b63359a..fed4a0d 100644 --- a/test/test-helper.el +++ b/test/test-helper.el @@ -113,7 +113,24 @@ Binds `project-dir' and `project-file'." (defun treemacs-find-workspace-by-name (_name) nil) (defun treemacs-do-add-project-to-workspace (_path _name) nil) (defvar treemacs-switch-workspace-hook nil - "Stub for treemacs workspace switch hook.")) + "Stub for treemacs workspace switch hook.") + + (defvar treemacs-select-functions nil + "Stub for treemacs select functions hook.") + + (defvar treemacs-project-follow-mode nil + "Stub for treemacs-project-follow-mode.") + + (defvar-local treemacs--project-of-buffer nil + "Stub for treemacs buffer-local project cache.") + + (defun treemacs-select-window (&optional _arg) + "Stub for treemacs-select-window." + nil) + + (defun treemacs-add-and-display-current-project-exclusively () + "Stub for treemacs-add-and-display-current-project-exclusively." + nil)) ;;; --- Treemacs test macro --- @@ -125,9 +142,12 @@ Binds `cache-file' to the temporary cache path." (declarative-project-treemacs--desired-state nil) (declarative-project-treemacs--cache-file cache-file) (declarative-project-treemacs-mode nil) - (treemacs--current-workspace-stub nil)) + (treemacs--current-workspace-stub nil) + (treemacs-select-functions nil) + (treemacs-project-follow-mode nil)) (unwind-protect (progn ,@body) + (put 'treemacs :state-is-restored nil) (when (file-exists-p cache-file) (delete-file cache-file))))) From 6d215c177c4ec69b55ddde3d8308704810a81bbc Mon Sep 17 00:00:00 2001 From: cuttlefisch Date: Sat, 21 Mar 2026 13:39:53 +0100 Subject: [PATCH 10/15] Add GPL headers, update CI matrix, and FOSS standards polish Add GPL-3.0 license headers to all secondary .el and test files for proper compliance. Update CI to test against Emacs 30.1 and add fail-fast: false for full version-specific regression visibility. Update README spec count (74), add CHANGELOG 0.3.1 section, and extend .gitignore with lock/autosave/autoload patterns. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 3 ++- .gitignore | 10 ++++++++++ CHANGELOG.org | 23 +++++++++++++++++++++++ README.org | 2 +- declarative-project-treemacs.el | 13 +++++++++++++ ob-declarative-project.el | 13 +++++++++++++ test/test-declarative-project-treemacs.el | 17 ++++++++++++++++- test/test-declarative-project.el | 17 ++++++++++++++++- test/test-helper.el | 17 ++++++++++++++++- 9 files changed, 110 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3fa1ab5..92ae284 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,8 +4,9 @@ jobs: test: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - emacs-version: ['28.1', '29.4', 'snapshot'] + emacs-version: ['28.1', '29.4', '30.1', 'snapshot'] steps: - uses: actions/checkout@v4 - uses: purcell/setup-emacs@master diff --git a/.gitignore b/.gitignore index 377f25b..5d6023a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,13 @@ # Undo-tree save-files *.~undo-tree flycheck_*.el + +# Lock files +.#* + +# Auto-save files +\#*\# + +# Package autoloads +*-autoloads.el +*-pkg.el diff --git a/CHANGELOG.org b/CHANGELOG.org index 50612d9..0003ec3 100644 --- a/CHANGELOG.org +++ b/CHANGELOG.org @@ -1,6 +1,29 @@ #+title: Changelog #+author: Hayden Stanko +* 0.3.1 — 2026-03-21 + +** Bug fixes +- Fix duplicate workspace assignments when treemacs-mode active :: The + install function's native treemacs code path was firing alongside the + desired-state model, causing duplicate project entries. The native + fallback is now gated behind =(not declarative-project-treemacs-mode)=. + +** New features +- Workspace enforcement via advice :: Two new advice functions + (=after treemacs-switch-workspace= and =after treemacs--restore=) + re-apply desired state whenever treemacs switches or restores + workspaces, preventing external changes from overriding declared + assignments. + +** Tests +- 7 new specs for fallback guard, advice lifecycle, and mode + registration. Total spec count: 74 (up from 52). + +** Code quality +- Added GPL-3.0 license headers to all secondary =.el= and test files. +- Updated =.gitignore= with Emacs lock-file and autoload patterns. + * 0.3.0 — 2026-03-20 ** Bug fixes diff --git a/README.org b/README.org index 2f95acb..4ab406e 100644 --- a/README.org +++ b/README.org @@ -134,7 +134,7 @@ to placing the resource in the project root directory. #+begin_src shell cask install -make test # 32 Buttercup specs +make test # 74 Buttercup specs #+end_src See [[file:CONTRIBUTING.org][CONTRIBUTING.org]] for the full development guide. diff --git a/declarative-project-treemacs.el b/declarative-project-treemacs.el index 6eca746..faa12ac 100644 --- a/declarative-project-treemacs.el +++ b/declarative-project-treemacs.el @@ -13,6 +13,19 @@ ;; ;; This file is not part of GNU Emacs. ;; +;; 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 . +;; ;;; Commentary: ;; ;; Manage treemacs workspaces via distributed declarative .project files. diff --git a/ob-declarative-project.el b/ob-declarative-project.el index d983030..80dd7d3 100644 --- a/ob-declarative-project.el +++ b/ob-declarative-project.el @@ -13,6 +13,19 @@ ;; ;; This file is not part of GNU Emacs. ;; +;; 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 . +;; ;;; Commentary: ;; ;; Org-Babel support for declarative-project src blocks. diff --git a/test/test-declarative-project-treemacs.el b/test/test-declarative-project-treemacs.el index 76501d0..5e7bcf0 100644 --- a/test/test-declarative-project-treemacs.el +++ b/test/test-declarative-project-treemacs.el @@ -1,5 +1,20 @@ ;;; test-declarative-project-treemacs.el --- Tests for treemacs integration -*- lexical-binding: t -*- - +;; +;; Copyright (C) 2023 Hayden Stanko +;; +;; 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 . +;; ;;; Commentary: ;; Buttercup test suite for declarative-project-treemacs. diff --git a/test/test-declarative-project.el b/test/test-declarative-project.el index cd06e07..79146d8 100644 --- a/test/test-declarative-project.el +++ b/test/test-declarative-project.el @@ -1,5 +1,20 @@ ;;; test-declarative-project.el --- Tests for declarative-project-mode -*- lexical-binding: t -*- - +;; +;; Copyright (C) 2023 Hayden Stanko +;; +;; 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 . +;; ;;; Commentary: ;; Buttercup test suite covering all known bugs and core functionality. diff --git a/test/test-helper.el b/test/test-helper.el index fed4a0d..62f499a 100644 --- a/test/test-helper.el +++ b/test/test-helper.el @@ -1,5 +1,20 @@ ;;; test-helper.el --- Test helpers for declarative-project-mode -*- lexical-binding: t -*- - +;; +;; Copyright (C) 2023 Hayden Stanko +;; +;; 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 . +;; ;;; Commentary: ;; Fixtures, macros, and stubs for Buttercup tests. From ac8db36527858a26e5de5a4e87de9bd03f554e3b Mon Sep 17 00:00:00 2001 From: cuttlefisch Date: Sat, 21 Mar 2026 13:51:22 +0100 Subject: [PATCH 11/15] Add Code of Conduct and update maintainer email Add Contributor Covenant v2.1. Update Author/Maintainer email to system.cuttle@gmail.com across all package headers. Co-Authored-By: Claude Opus 4.6 --- CODE_OF_CONDUCT.md | 85 +++++++++++++++++++++++++++++++++ declarative-project-mode.el | 4 +- declarative-project-treemacs.el | 4 +- ob-declarative-project.el | 4 +- 4 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 CODE_OF_CONDUCT.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..049fb41 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,85 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at system.cuttle@gmail.com. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations + diff --git a/declarative-project-mode.el b/declarative-project-mode.el index 708f502..171e569 100644 --- a/declarative-project-mode.el +++ b/declarative-project-mode.el @@ -2,8 +2,8 @@ ;; ;; Copyright (C) 2023 Hayden Stanko ;; -;; Author: Hayden Stanko -;; Maintainer: Hayden Stanko +;; Author: Hayden Stanko +;; Maintainer: Hayden Stanko ;; Created: January 13, 2023 ;; Modified: March 20, 2026 ;; Version: 0.3.0 diff --git a/declarative-project-treemacs.el b/declarative-project-treemacs.el index faa12ac..f59a0c1 100644 --- a/declarative-project-treemacs.el +++ b/declarative-project-treemacs.el @@ -2,8 +2,8 @@ ;; ;; Copyright (C) 2023 Hayden Stanko ;; -;; Author: Hayden Stanko -;; Maintainer: Hayden Stanko +;; Author: Hayden Stanko +;; Maintainer: Hayden Stanko ;; Created: January 14, 2023 ;; Modified: March 20, 2026 ;; Version: 0.3.0 diff --git a/ob-declarative-project.el b/ob-declarative-project.el index 80dd7d3..141ca65 100644 --- a/ob-declarative-project.el +++ b/ob-declarative-project.el @@ -2,8 +2,8 @@ ;; ;; Copyright (C) 2023 Hayden Stanko ;; -;; Author: Hayden Stanko -;; Maintainer: Hayden Stanko +;; Author: Hayden Stanko +;; Maintainer: Hayden Stanko ;; Created: March 15, 2026 ;; Modified: March 20, 2026 ;; Version: 0.3.0 From 986f6fc6b30b7b31746832afdfc5bdd0a412335f Mon Sep 17 00:00:00 2001 From: cuttlefisch Date: Sat, 21 Mar 2026 13:54:23 +0100 Subject: [PATCH 12/15] Deduplicate CI runs: push only on main, pull_request for branches Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92ae284..512db71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,8 @@ name: CI -on: [push, pull_request] +on: + push: + branches: [main] + pull_request: jobs: test: runs-on: ubuntu-latest From bc9976875003fe94afc8dd05eb560ccb2678532f Mon Sep 17 00:00:00 2001 From: cuttlefisch Date: Sat, 21 Mar 2026 15:14:00 +0100 Subject: [PATCH 13/15] Harden cache, expose defcustom, add 8 cache validation specs Promote --cache-file to public defcustom with framework-specific guidance for Doom/Spacemacs users. Harden --read-cache against corrupt and empty cache files with condition-case fallback. Add 8 new cache validation specs covering struct integrity, dedup, multi-workspace project counts, unassign round-trip, corrupt/empty file recovery, setq form validation, and normalization idempotency. Update README with configuration table row, framework-specific setup examples (Doom, Spacemacs, vanilla), and current spec count (96). Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.org | 21 ++ CLAUDE.md | 20 +- README.org | 48 +++- declarative-project-treemacs.el | 117 +++++++- test/test-declarative-project-treemacs.el | 323 +++++++++++++++++++++- test/test-helper.el | 9 +- 6 files changed, 509 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.org b/CHANGELOG.org index 0003ec3..1a7fbca 100644 --- a/CHANGELOG.org +++ b/CHANGELOG.org @@ -1,6 +1,27 @@ #+title: Changelog #+author: Hayden Stanko +* 0.3.2 — 2026-03-21 + +** New features +- =declarative-project-treemacs-cache-file= defcustom :: The cache file + path is now a =defcustom= with framework-specific guidance for Doom + and Spacemacs users. A backward-compat alias preserves the old + internal name. +- =declarative-project-treemacs-reset-cache= :: Interactive command to + reset the workspace cache to a single empty Default workspace. + +** Bug fixes +- Hardened =--read-cache= against corrupted/empty files :: Empty (0-byte) + or malformed cache files now fall back to minimal desired state instead + of erroring. Corrupt files trigger a =display-warning= and auto-reset. + +** Tests +- 8 new cache validation specs: struct integrity, dedup, multi-workspace + project counts, unassign round-trip, corrupt/empty file recovery, setq + form validation, and normalization idempotency. +- Total spec count: 96 (up from 74 in 0.3.1). + * 0.3.1 — 2026-03-21 ** Bug fixes diff --git a/CLAUDE.md b/CLAUDE.md index 10b5f9a..80992c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,9 +24,27 @@ Emacs minor mode for declarative project resource management via `.project` file - `declarative-project--apply-treemacs-workspaces-hook` is run during install for treemacs integration - Accessor functions: `declarative-project-workspaces`, `declarative-project-root-directory`, `declarative-project-name` +## Cache Architecture (treemacs integration) + +- **Cache file**: `treemacs-declared-workspaces.el` in `user-emacs-directory` — stores desired workspace structs as a `setq` form +- **Desired-state model**: `.project` installs accumulate workspace/project structs into `--desired-state`; this list is the single source of truth for what treemacs workspaces should contain +- **Relationship to treemacs persist file**: treemacs has its own `treemacs-persist` file written on `kill-emacs-hook`. Our mode takes full control of `treemacs--workspaces`, so both files should converge. + +### 3-layer persistence defense + +- **Layer 1 (pre-empt)**: Sets `(put 'treemacs :state-is-restored t)` on mode enable, preventing `treemacs--maybe-load-workspaces` from ever firing +- **Layer 2 (after-restore advice)**: If `treemacs--restore` fires anyway (e.g. mode enabled late), `:after` advice re-applies desired state via `--override-workspaces` +- **Layer 3 (kill-emacs sync)**: `--sync-before-persist` runs on `kill-emacs-hook` at depth -90 (before treemacs's persist at depth 0), ensuring treemacs writes our desired state to disk + +### treemacs-persp interaction + +Doom's `:ui workspaces` module enables `treemacs-persp`, which sets scope type to `Perspectives` and creates "Perspective " workspaces. We suppress this via `:around` advice on `treemacs-persp--ensure-workspace-exists`. + +**Key design choice**: hooks that fire during normal operation (`treemacs-switch-workspace-hook`, `treemacs-select-functions`, persp-ensure advice) use `--sync-workspace-list` which maintains the workspace list without changing the current workspace. Only mode init and after-restore use `--override-workspaces` which also sets the current workspace. This prevents perspective switches and treemacs focus from resetting the user's workspace selection. + ## Development - Emacs Lisp with `lexical-binding: t` - Package requires Emacs 28.1+ -- Tests: `make test` (uses Buttercup, 25 specs) +- Tests: `make test` (uses Buttercup, 96 specs) - Test runner auto-detects straight.el (Doom) or Cask diff --git a/README.org b/README.org index 4ab406e..0db6f43 100644 --- a/README.org +++ b/README.org @@ -109,6 +109,51 @@ When enabled, project installation creates or updates treemacs workspace entries. Set =declarative-project-treemacs-autoprune= to =nil= to disable automatic removal of stale project entries on mode init. +** Framework-specific configuration + +*** Doom Emacs + +In =packages.el=: +#+begin_src emacs-lisp +(package! declarative-project-mode + :recipe (:host github :repo "cuttlefisch/declarative-project-mode")) +#+end_src + +In =config.el=: +#+begin_src emacs-lisp +(use-package! declarative-project-treemacs + :after treemacs + :config + (setq declarative-project-treemacs-cache-file + (expand-file-name "treemacs-declared-workspaces.el" doom-cache-dir)) + (declarative-project-treemacs-mode 1)) +#+end_src + +*** Spacemacs + +In =dotspacemacs-additional-packages=: +#+begin_src emacs-lisp +(declarative-project-mode + :location (recipe :fetcher github :repo "cuttlefisch/declarative-project-mode")) +#+end_src + +In =dotspacemacs/user-config=: +#+begin_src emacs-lisp +(require 'declarative-project-treemacs) +(setq declarative-project-treemacs-cache-file + (expand-file-name "treemacs-declared-workspaces.el" spacemacs-cache-directory)) +(declarative-project-treemacs-mode 1) +#+end_src + +*** Vanilla Emacs + +#+begin_src emacs-lisp +(require 'declarative-project-treemacs) +(declarative-project-treemacs-mode 1) +;; Cache file defaults to user-emacs-directory; customize if needed: +;; (setq declarative-project-treemacs-cache-file "~/.cache/emacs/treemacs-declared-workspaces.el") +#+end_src + * Spec Reference | Field | Type | Description | @@ -129,12 +174,13 @@ to placing the resource in the project root directory. |--------------------------------------------+---------+---------------------------------------------------| | =declarative-project-auto-install= | =nil= | Auto-run installation when mode activates | | =declarative-project-treemacs-autoprune= | =t= | Remove stale project entries on treemacs mode init | +| =declarative-project-treemacs-cache-file= | =user-emacs-directory= | Path to the workspace cache file | * Development #+begin_src shell cask install -make test # 74 Buttercup specs +make test # 96 Buttercup specs #+end_src See [[file:CONTRIBUTING.org][CONTRIBUTING.org]] for the full development guide. diff --git a/declarative-project-treemacs.el b/declarative-project-treemacs.el index f59a0c1..378b667 100644 --- a/declarative-project-treemacs.el +++ b/declarative-project-treemacs.el @@ -52,9 +52,18 @@ "List of desired workspaces and project contents from declared workspaces. Initialized lazily when the mode is enabled.") -(defvar declarative-project-treemacs--cache-file +(defcustom declarative-project-treemacs-cache-file (expand-file-name "treemacs-declared-workspaces.el" user-emacs-directory) - "File path for caching the declared workspace state.") + "File path for caching the declared workspace state. +Doom Emacs users may want to set this under `doom-cache-dir'. +Spacemacs users may want to use `spacemacs-cache-directory'." + :type 'file + :group 'declarative-project + :package-version '(declarative-project-treemacs . "0.3.2")) + +(defvaralias 'declarative-project-treemacs--cache-file + 'declarative-project-treemacs-cache-file + "Backward-compat alias for the internal name.") (defcustom declarative-project-treemacs-autoprune t "If non-nil, remove invalid projects from cache on mode init." @@ -128,7 +137,7 @@ missing fields (e.g. `is-disabled?')." (defun declarative-project-treemacs--save-cache () "Write current desired state to cache file." (declarative-project-treemacs--ensure-desired-state) - (with-temp-file declarative-project-treemacs--cache-file + (with-temp-file declarative-project-treemacs-cache-file (let ((standard-output (current-buffer))) (insert ";; -*- no-byte-compile: t -*-\n") (insert ";; declarative-project-treemacs cache — auto-generated\n") @@ -137,14 +146,41 @@ missing fields (e.g. `is-disabled?')." (defun declarative-project-treemacs--read-cache () "Read the desired state from the cache file. -After loading, normalizes all structs to the current layout." - (if (file-exists-p declarative-project-treemacs--cache-file) - (progn - (load declarative-project-treemacs--cache-file nil t t) - (declarative-project-treemacs--normalize-cache)) +After loading, normalizes all structs to the current layout. +If the cache file is missing, corrupted, or empty, falls back to +minimal desired state and re-saves." + (if (and (file-exists-p declarative-project-treemacs-cache-file) + (> (file-attribute-size + (file-attributes declarative-project-treemacs-cache-file)) + 0)) + (condition-case err + (progn + (load declarative-project-treemacs-cache-file nil t t) + (declarative-project-treemacs--normalize-cache)) + (error + (display-warning 'declarative-project + (format "Cache file corrupt, resetting: %s" + (error-message-string err))) + (setq declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--ensure-desired-state) + (declarative-project-treemacs--save-cache))) (declarative-project-treemacs--ensure-desired-state) (declarative-project-treemacs--save-cache))) +;;;###autoload +(defun declarative-project-treemacs-reset-cache () + "Reset the declared workspace cache to a single empty Default workspace. +Use this to clear stale entries accumulated from old .project files +or babel blocks. After resetting, re-run your declarative-project +installs to repopulate." + (interactive) + (setq declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--ensure-desired-state) + (declarative-project-treemacs--save-cache) + (when declarative-project-treemacs-mode + (declarative-project-treemacs--override-workspaces)) + (message "declarative-project-treemacs: cache reset to Default workspace")) + ;;; --- Project management --- (defun declarative-project-treemacs--workspace-memberp (project workspace) @@ -215,6 +251,7 @@ After loading, normalizes all structs to the current layout." (declare-function treemacs-current-workspace "treemacs-workspaces" ()) (declare-function treemacs--invalidate-buffer-project-cache "treemacs-workspaces" ()) +(declare-function treemacs--restore "treemacs-persistence" ()) (declare-function treemacs-select-window "treemacs" (&optional arg)) (declare-function treemacs-persp--ensure-workspace-exists "treemacs-persp" ()) @@ -226,6 +263,7 @@ current workspace name is not found in the desired state (e.g. a persp-created workspace), falls back to the first desired workspace. Prevents treemacs from restoring its persist file by setting the `:state-is-restored' flag after populating the workspace list." + (declarative-project-treemacs--ensure-desired-state) (setq treemacs--workspaces declarative-project-treemacs--desired-state) ;; Prevent treemacs--maybe-load-workspaces from overwriting our state. ;; This mode takes full control of the workspace list, so we @@ -245,10 +283,21 @@ Prevents treemacs from restoring its persist file by setting the (when (treemacs-get-local-buffer) (treemacs--consolidate-projects))) +(defun declarative-project-treemacs--sync-workspace-list () + "Ensure `treemacs--workspaces' contains our desired state. +Unlike `--override-workspaces', this does NOT change the current +workspace in the scope shelf. Use this for hooks that fire during +normal operation (perspective switches, treemacs select, workspace +switch) where we want to maintain the workspace list but let +treemacs or the user control which workspace is active." + (declarative-project-treemacs--ensure-desired-state) + (setq treemacs--workspaces declarative-project-treemacs--desired-state) + (put 'treemacs :state-is-restored t)) + (defun declarative-project-treemacs--on-select (_reason) - "Re-apply declared workspaces after treemacs window is selected. + "Re-apply declared workspace list after treemacs window is selected. Called via `treemacs-select-functions'; REASON is ignored." - (declarative-project-treemacs--override-workspaces)) + (declarative-project-treemacs--sync-workspace-list)) (defun declarative-project-treemacs--around-exclusive-display (orig-fn) "Show treemacs without modifying workspace content when our mode is active. @@ -258,10 +307,12 @@ ORIG-FN is `treemacs-add-and-display-current-project-exclusively'." (funcall orig-fn))) (defun declarative-project-treemacs--around-persp-ensure (orig-fn) - "Use our workspace override instead of persp workspace creation. -ORIG-FN is `treemacs-persp--ensure-workspace-exists'." + "Suppress persp workspace creation; maintain our workspace list instead. +ORIG-FN is `treemacs-persp--ensure-workspace-exists'. +Only syncs the workspace list — does not change the current workspace, +so perspective switches don't reset the user's workspace selection." (if declarative-project-treemacs-mode - (declarative-project-treemacs--override-workspaces) + (declarative-project-treemacs--sync-workspace-list) (funcall orig-fn))) ;;; --- Hook integration --- @@ -281,6 +332,24 @@ ORIG-FN is `treemacs-persp--ensure-workspace-exists'." (declarative-project-treemacs--override-workspaces) (declarative-project-treemacs--save-cache)))) +;;; --- Persistence defense --- + +(defun declarative-project-treemacs--after-treemacs-restore (&rest _) + "Re-apply desired state after treemacs restores from persist file. +Safety net: if `treemacs--restore' fires despite the pre-emption flag +\(e.g. mode enabled after treemacs started, or another package forces +a restore), this advice ensures our desired state takes precedence." + (when declarative-project-treemacs-mode + (declarative-project-treemacs--override-workspaces))) + +(defun declarative-project-treemacs--sync-before-persist () + "Ensure treemacs persists our desired state, not stale data. +Runs on `kill-emacs-hook' at depth -90 (before treemacs's own +`treemacs--persist' which runs at default depth 0)." + (when declarative-project-treemacs-mode + (declarative-project-treemacs--ensure-desired-state) + (setq treemacs--workspaces declarative-project-treemacs--desired-state))) + ;;; --- Mode definition --- ;;;###autoload @@ -304,6 +373,10 @@ creation when active." :lighter " TDW" (if declarative-project-treemacs-mode (progn + ;; Layer 1: Pre-empt treemacs restore. Setting this flag before + ;; reading our cache prevents treemacs--maybe-load-workspaces from + ;; ever firing when our mode is active. + (put 'treemacs :state-is-restored t) ;; treemacs-project-follow-mode replaces workspace content with the ;; current project on an idle timer, which conflicts with our ;; workspace ownership. Disable it when this mode is active. @@ -315,6 +388,14 @@ creation when active." (when (fboundp 'treemacs-persp--ensure-workspace-exists) (advice-add 'treemacs-persp--ensure-workspace-exists :around #'declarative-project-treemacs--around-persp-ensure)) + ;; Layer 2: Safety-net advice in case treemacs--restore fires anyway. + (advice-add 'treemacs--restore :after + #'declarative-project-treemacs--after-treemacs-restore) + ;; Layer 3: Sync desired state into treemacs before its persist hook + ;; writes to disk on shutdown. Depth -90 ensures we run before + ;; treemacs--persist (depth 0). + (add-hook 'kill-emacs-hook + #'declarative-project-treemacs--sync-before-persist -90) (declarative-project-treemacs--read-cache) (when declarative-project-treemacs-autoprune (declarative-project-treemacs--prune-invalid-projects)) @@ -322,19 +403,25 @@ creation when active." (add-hook 'declarative-project--apply-treemacs-workspaces-hook #'declarative-project-treemacs--assign-declared-project) (add-hook 'treemacs-switch-workspace-hook - #'declarative-project-treemacs--override-workspaces) + #'declarative-project-treemacs--sync-workspace-list) (add-hook 'treemacs-select-functions #'declarative-project-treemacs--on-select)) (declarative-project-treemacs--save-cache) + ;; Clear pre-emption flag so treemacs resumes normal behavior. + (put 'treemacs :state-is-restored nil) (advice-remove 'treemacs-add-and-display-current-project-exclusively #'declarative-project-treemacs--around-exclusive-display) (when (fboundp 'treemacs-persp--ensure-workspace-exists) (advice-remove 'treemacs-persp--ensure-workspace-exists #'declarative-project-treemacs--around-persp-ensure)) + (advice-remove 'treemacs--restore + #'declarative-project-treemacs--after-treemacs-restore) + (remove-hook 'kill-emacs-hook + #'declarative-project-treemacs--sync-before-persist) (remove-hook 'declarative-project--apply-treemacs-workspaces-hook #'declarative-project-treemacs--assign-declared-project) (remove-hook 'treemacs-switch-workspace-hook - #'declarative-project-treemacs--override-workspaces) + #'declarative-project-treemacs--sync-workspace-list) (remove-hook 'treemacs-select-functions #'declarative-project-treemacs--on-select))) diff --git a/test/test-declarative-project-treemacs.el b/test/test-declarative-project-treemacs.el index 5e7bcf0..18bbaa0 100644 --- a/test/test-declarative-project-treemacs.el +++ b/test/test-declarative-project-treemacs.el @@ -125,6 +125,145 @@ ;; The key check: is-disabled? accessor must not error (expect (treemacs-workspace->is-disabled? ws) :not :to-throw))))) +;;; ========================================================================== +;;; cache validation +;;; ========================================================================== + +(describe "cache validation" + (it "every entry after save+read is a proper workspace/project struct" + (with-treemacs-test-state + (let ((proj (treemacs-project->create! + :name "P1" :path "/tmp/p1" + :path-status 'local-readable :is-disabled? nil))) + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! + :name "WS1" :projects (list proj)))) + (declarative-project-treemacs--save-cache) + (setq declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--read-cache) + (dolist (ws declarative-project-treemacs--desired-state) + (expect (treemacs-workspace-p ws) :to-be-truthy) + (expect (treemacs-workspace->name ws) :to-be-truthy) + (dolist (proj (treemacs-workspace->projects ws)) + (expect (treemacs-project-p proj) :to-be-truthy) + (expect (treemacs-project->name proj) :to-be-truthy) + (expect (treemacs-project->path proj) :to-be-truthy)))))) + + (it "no duplicate workspace names after repeated assign" + (with-treemacs-test-state + (declarative-project-treemacs--assign-project + (list :name "P1" :path "/tmp/p1" + :path-status 'local-readable :is-disabled? nil) + "TestWS") + (declarative-project-treemacs--assign-project + (list :name "P2" :path "/tmp/p2" + :path-status 'local-readable :is-disabled? nil) + "TestWS") + (declarative-project-treemacs--save-cache) + (setq declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--read-cache) + (let ((names (mapcar #'treemacs-workspace->name + declarative-project-treemacs--desired-state))) + (expect (length names) :to-equal (length (delete-dups (copy-sequence names))))))) + + (it "preserves exact project counts across workspaces" + (with-treemacs-test-state + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! + :name "Alpha" + :projects (list + (treemacs-project->create! :name "A1" :path "/tmp/a1" + :path-status 'local-readable :is-disabled? nil) + (treemacs-project->create! :name "A2" :path "/tmp/a2" + :path-status 'local-readable :is-disabled? nil) + (treemacs-project->create! :name "A3" :path "/tmp/a3" + :path-status 'local-readable :is-disabled? nil))) + (treemacs-workspace->create! + :name "Beta" + :projects (list + (treemacs-project->create! :name "B1" :path "/tmp/b1" + :path-status 'local-readable :is-disabled? nil) + (treemacs-project->create! :name "B2" :path "/tmp/b2" + :path-status 'local-readable :is-disabled? nil))))) + (declarative-project-treemacs--save-cache) + (setq declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--read-cache) + (let ((alpha (declarative-project-treemacs--workspaces-by-name "Alpha")) + (beta (declarative-project-treemacs--workspaces-by-name "Beta"))) + (expect (length (treemacs-workspace->projects alpha)) :to-equal 3) + (expect (length (treemacs-workspace->projects beta)) :to-equal 2)))) + + (it "unassign persists correctly through save+read" + (with-treemacs-test-state + (let ((proj (treemacs-project->create! + :name "RemoveMe" :path "/tmp/rm" + :path-status 'local-readable :is-disabled? nil))) + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! + :name "WS" :projects (list proj)))) + (declarative-project-treemacs--unassign-project "RemoveMe" "WS") + ;; save+read cycle + (setq declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--read-cache) + (let ((ws (declarative-project-treemacs--workspaces-by-name "WS"))) + (expect (treemacs-workspace->projects ws) :to-equal nil))))) + + (it "recovers gracefully from corrupted cache file" + (with-treemacs-test-state + (with-temp-file cache-file + (insert "this is not valid elisp }{][")) + (declarative-project-treemacs--read-cache) + (expect declarative-project-treemacs--desired-state :to-be-truthy) + (expect (length declarative-project-treemacs--desired-state) :to-equal 1) + (expect (treemacs-workspace->name + (car declarative-project-treemacs--desired-state)) + :to-equal "Default"))) + + (it "recovers gracefully from empty cache file" + (with-treemacs-test-state + ;; Create a 0-byte file + (with-temp-file cache-file + (erase-buffer)) + (declarative-project-treemacs--read-cache) + (expect declarative-project-treemacs--desired-state :to-be-truthy) + (expect (length declarative-project-treemacs--desired-state) :to-equal 1) + (expect (treemacs-workspace->name + (car declarative-project-treemacs--desired-state)) + :to-equal "Default"))) + + (it "save output contains expected setq form" + (with-treemacs-test-state + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! :name "Check" :projects nil))) + (declarative-project-treemacs--save-cache) + (let ((content (with-temp-buffer + (insert-file-contents cache-file) + (buffer-string)))) + (expect content :to-match "setq declarative-project-treemacs--desired-state")))) + + (it "double round-trip produces identical cache file content" + (with-treemacs-test-state + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! + :name "Stable" + :projects (list (treemacs-project->create! + :name "SP" :path "/tmp/sp" + :path-status 'local-readable :is-disabled? nil))))) + (declarative-project-treemacs--save-cache) + (setq declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--read-cache) + (declarative-project-treemacs--save-cache) + (let ((first-content (with-temp-buffer + (insert-file-contents cache-file) + (buffer-string)))) + (setq declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--read-cache) + (declarative-project-treemacs--save-cache) + (let ((second-content (with-temp-buffer + (insert-file-contents cache-file) + (buffer-string)))) + (expect first-content :to-equal second-content)))))) + ;;; ========================================================================== ;;; declarative-project-treemacs--assign-project ;;; ========================================================================== @@ -327,17 +466,51 @@ (expect 'declarative-project-treemacs--assign-project :not :to-have-been-called))))) +;;; ========================================================================== +;;; declarative-project-treemacs--sync-workspace-list +;;; ========================================================================== + +(describe "declarative-project-treemacs--sync-workspace-list" + (it "sets treemacs--workspaces to desired state" + (with-treemacs-test-state + (let ((state (list (treemacs-workspace->create! + :name "SyncTest" :projects nil)))) + (setq declarative-project-treemacs--desired-state state) + (declarative-project-treemacs--sync-workspace-list) + (expect treemacs--workspaces :to-equal state)))) + + (it "sets :state-is-restored flag" + (with-treemacs-test-state + (let ((state (list (treemacs-workspace->create! + :name "FlagTest" :projects nil)))) + (setq declarative-project-treemacs--desired-state state) + (expect (get 'treemacs :state-is-restored) :not :to-be-truthy) + (declarative-project-treemacs--sync-workspace-list) + (expect (get 'treemacs :state-is-restored) :to-be t)))) + + (it "does not change the current workspace in scope shelf" + (with-treemacs-test-state + (let* ((persp-ws (treemacs-workspace->create! + :name "Perspective main" :projects nil)) + (declared-ws (treemacs-workspace->create! + :name "MyProject" :projects nil))) + (setf (treemacs-current-workspace) persp-ws) + (setq declarative-project-treemacs--desired-state (list declared-ws)) + (declarative-project-treemacs--sync-workspace-list) + ;; Current workspace should still be persp-ws, not overridden + (expect (treemacs-current-workspace) :to-be persp-ws))))) + ;;; ========================================================================== ;;; declarative-project-treemacs--on-select ;;; ========================================================================== (describe "declarative-project-treemacs--on-select" - (it "calls override-workspaces regardless of reason argument" + (it "calls sync-workspace-list regardless of reason argument" (with-treemacs-test-state - (spy-on 'declarative-project-treemacs--override-workspaces) + (spy-on 'declarative-project-treemacs--sync-workspace-list) (declarative-project-treemacs--on-select 'exists) (declarative-project-treemacs--on-select 'none) - (expect 'declarative-project-treemacs--override-workspaces + (expect 'declarative-project-treemacs--sync-workspace-list :to-have-been-called-times 2)))) ;;; ========================================================================== @@ -356,7 +529,7 @@ (expect (memq #'declarative-project-treemacs--assign-declared-project declarative-project--apply-treemacs-workspaces-hook) :to-be-truthy) - (expect (memq #'declarative-project-treemacs--override-workspaces + (expect (memq #'declarative-project-treemacs--sync-workspace-list treemacs-switch-workspace-hook) :to-be-truthy) (expect (memq #'declarative-project-treemacs--on-select @@ -374,7 +547,7 @@ (expect (memq #'declarative-project-treemacs--assign-declared-project declarative-project--apply-treemacs-workspaces-hook) :to-equal nil) - (expect (memq #'declarative-project-treemacs--override-workspaces + (expect (memq #'declarative-project-treemacs--sync-workspace-list treemacs-switch-workspace-hook) :to-equal nil) (expect (memq #'declarative-project-treemacs--on-select @@ -424,28 +597,158 @@ ;;; ========================================================================== (describe "declarative-project-treemacs--around-persp-ensure" - (it "calls override-workspaces when mode is active" + (it "calls sync-workspace-list when mode is active" (with-treemacs-test-state (let ((declarative-project-treemacs-mode t)) - (spy-on 'declarative-project-treemacs--override-workspaces) + (spy-on 'declarative-project-treemacs--sync-workspace-list) (let ((orig-called nil)) (declarative-project-treemacs--around-persp-ensure (lambda () (setq orig-called t))) - (expect 'declarative-project-treemacs--override-workspaces + (expect 'declarative-project-treemacs--sync-workspace-list :to-have-been-called) (expect orig-called :to-equal nil))))) (it "calls orig-fn when mode is not active" (with-treemacs-test-state (let ((declarative-project-treemacs-mode nil)) - (spy-on 'declarative-project-treemacs--override-workspaces) + (spy-on 'declarative-project-treemacs--sync-workspace-list) (let ((orig-called nil)) (declarative-project-treemacs--around-persp-ensure (lambda () (setq orig-called t))) - (expect 'declarative-project-treemacs--override-workspaces + (expect 'declarative-project-treemacs--sync-workspace-list :not :to-have-been-called) (expect orig-called :to-equal t)))))) +;;; ========================================================================== +;;; Persistence defense (3-layer) +;;; ========================================================================== + +(describe "persistence defense: pre-emption flag (Layer 1)" + (it "sets :state-is-restored on enable" + (with-treemacs-test-state + (let ((declarative-project--apply-treemacs-workspaces-hook nil) + (treemacs-switch-workspace-hook nil) + (treemacs-select-functions nil)) + (put 'treemacs :state-is-restored nil) + (declarative-project-treemacs-mode 1) + (unwind-protect + (expect (get 'treemacs :state-is-restored) :to-be t) + (declarative-project-treemacs-mode -1))))) + + (it "clears :state-is-restored on disable" + (with-treemacs-test-state + (let ((declarative-project--apply-treemacs-workspaces-hook nil) + (treemacs-switch-workspace-hook nil) + (treemacs-select-functions nil)) + (declarative-project-treemacs-mode 1) + (declarative-project-treemacs-mode -1) + (expect (get 'treemacs :state-is-restored) :to-be nil))))) + +(describe "persistence defense: after-restore advice (Layer 2)" + (it "re-applies desired state when mode is active" + (with-treemacs-test-state + (let ((declarative-project-treemacs-mode t)) + (spy-on 'declarative-project-treemacs--override-workspaces) + (declarative-project-treemacs--after-treemacs-restore) + (expect 'declarative-project-treemacs--override-workspaces + :to-have-been-called)))) + + (it "does nothing when mode is inactive" + (with-treemacs-test-state + (let ((declarative-project-treemacs-mode nil)) + (spy-on 'declarative-project-treemacs--override-workspaces) + (declarative-project-treemacs--after-treemacs-restore) + (expect 'declarative-project-treemacs--override-workspaces + :not :to-have-been-called)))) + + (it "is added as advice on enable and removed on disable" + (with-treemacs-test-state + (let ((declarative-project--apply-treemacs-workspaces-hook nil) + (treemacs-switch-workspace-hook nil) + (treemacs-select-functions nil)) + (declarative-project-treemacs-mode 1) + (unwind-protect + (expect (advice-member-p + #'declarative-project-treemacs--after-treemacs-restore + 'treemacs--restore) + :to-be-truthy) + (declarative-project-treemacs-mode -1)) + (expect (advice-member-p + #'declarative-project-treemacs--after-treemacs-restore + 'treemacs--restore) + :to-equal nil))))) + +(describe "persistence defense: kill-emacs sync (Layer 3)" + (it "syncs desired state to treemacs--workspaces" + (with-treemacs-test-state + (let ((declarative-project-treemacs-mode t) + (treemacs--workspaces nil)) + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! :name "Synced" :projects nil))) + (declarative-project-treemacs--sync-before-persist) + (expect (treemacs-workspace->name (car treemacs--workspaces)) + :to-equal "Synced")))) + + (it "adds kill-emacs-hook on enable and removes on disable" + (with-treemacs-test-state + (let ((declarative-project--apply-treemacs-workspaces-hook nil) + (treemacs-switch-workspace-hook nil) + (treemacs-select-functions nil)) + (declarative-project-treemacs-mode 1) + (unwind-protect + (expect (memq #'declarative-project-treemacs--sync-before-persist + kill-emacs-hook) + :to-be-truthy) + (declarative-project-treemacs-mode -1)) + (expect (memq #'declarative-project-treemacs--sync-before-persist + kill-emacs-hook) + :to-equal nil))))) + +;;; ========================================================================== +;;; declarative-project-treemacs-reset-cache +;;; ========================================================================== + +(describe "declarative-project-treemacs-reset-cache" + (it "resets desired state to single Default workspace" + (with-treemacs-test-state + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! :name "Stale1" :projects nil) + (treemacs-workspace->create! :name "Stale2" :projects nil))) + (declarative-project-treemacs-reset-cache) + (expect (length declarative-project-treemacs--desired-state) :to-equal 1) + (expect (treemacs-workspace->name + (car declarative-project-treemacs--desired-state)) + :to-equal "Default"))) + + (it "saves cache file after reset" + (with-treemacs-test-state + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! :name "Old" :projects nil))) + (declarative-project-treemacs-reset-cache) + ;; Re-read from disk and verify it was saved + (setq declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--read-cache) + (expect (length declarative-project-treemacs--desired-state) :to-equal 1) + (expect (treemacs-workspace->name + (car declarative-project-treemacs--desired-state)) + :to-equal "Default"))) + + (it "calls --override-workspaces when mode is active" + (with-treemacs-test-state + (let ((declarative-project-treemacs-mode t)) + (spy-on 'declarative-project-treemacs--override-workspaces) + (declarative-project-treemacs-reset-cache) + (expect 'declarative-project-treemacs--override-workspaces + :to-have-been-called)))) + + (it "does not call --override-workspaces when mode is inactive" + (with-treemacs-test-state + (let ((declarative-project-treemacs-mode nil)) + (spy-on 'declarative-project-treemacs--override-workspaces) + (declarative-project-treemacs-reset-cache) + (expect 'declarative-project-treemacs--override-workspaces + :not :to-have-been-called))))) + ;;; ========================================================================== ;;; Advice registration (mode enable/disable) ;;; ========================================================================== diff --git a/test/test-helper.el b/test/test-helper.el index 62f499a..c8b40b8 100644 --- a/test/test-helper.el +++ b/test/test-helper.el @@ -145,6 +145,10 @@ Binds `project-dir' and `project-file'." (defun treemacs-add-and-display-current-project-exclusively () "Stub for treemacs-add-and-display-current-project-exclusively." + nil) + + (defun treemacs--restore () + "Stub for treemacs--restore (persistence)." nil)) ;;; --- Treemacs test macro --- @@ -155,11 +159,12 @@ Binds `cache-file' to the temporary cache path." (declare (indent 0)) `(let* ((cache-file (make-temp-file "dpm-treemacs-cache-" nil ".el")) (declarative-project-treemacs--desired-state nil) - (declarative-project-treemacs--cache-file cache-file) + (declarative-project-treemacs-cache-file cache-file) (declarative-project-treemacs-mode nil) (treemacs--current-workspace-stub nil) (treemacs-select-functions nil) - (treemacs-project-follow-mode nil)) + (treemacs-project-follow-mode nil) + (kill-emacs-hook nil)) (unwind-protect (progn ,@body) (put 'treemacs :state-is-restored nil) From e9ed8ecb5536d93fb3694946d0dc2ae3a5694722 Mon Sep 17 00:00:00 2001 From: cuttlefisch Date: Sat, 21 Mar 2026 15:22:03 +0100 Subject: [PATCH 14/15] Fix Doom Emacs setup example to use after! for lazy loading The previous example used a separate use-package! on the sub-library name with :after treemacs, which would prevent the core mode from loading eagerly via find-file-hook. Use after! inside the main use-package! block so core loads eagerly while treemacs integration defers until treemacs is actually loaded. Co-Authored-By: Claude Opus 4.6 --- README.org | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.org b/README.org index 0db6f43..499ddb8 100644 --- a/README.org +++ b/README.org @@ -121,12 +121,13 @@ In =packages.el=: In =config.el=: #+begin_src emacs-lisp -(use-package! declarative-project-treemacs - :after treemacs +(use-package! declarative-project-mode :config - (setq declarative-project-treemacs-cache-file - (expand-file-name "treemacs-declared-workspaces.el" doom-cache-dir)) - (declarative-project-treemacs-mode 1)) + (after! treemacs + (require 'declarative-project-treemacs) + (setq declarative-project-treemacs-cache-file + (expand-file-name "treemacs-declared-workspaces.el" doom-cache-dir)) + (declarative-project-treemacs-mode 1))) #+end_src *** Spacemacs From 600524a9306ba3c28a95be9d967824795d24bb45 Mon Sep 17 00:00:00 2001 From: cuttlefisch Date: Sat, 21 Mar 2026 15:58:52 +0100 Subject: [PATCH 15/15] Fix daemon-mode overwriting persisted workspace name on shutdown In daemon mode, delete-frame-functions fires before kill-emacs-hook, destroying the frame's treemacs scope shelf. --sync-before-persist then re-queried (treemacs-current-workspace), which fell back to the wrong workspace and overwrote --current-workspace-name. Fix: persist name to disk immediately in --on-workspace-switch, and remove the re-capture from --sync-before-persist. Bump to 0.3.3. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.org | 18 ++++ CLAUDE.md | 2 +- declarative-project-mode.el | 4 +- declarative-project-treemacs.el | 42 +++++++-- ob-declarative-project.el | 6 +- test/test-declarative-project-treemacs.el | 101 +++++++++++++++++++++- test/test-helper.el | 1 + 7 files changed, 157 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.org b/CHANGELOG.org index 1a7fbca..2b2d8fd 100644 --- a/CHANGELOG.org +++ b/CHANGELOG.org @@ -1,6 +1,24 @@ #+title: Changelog #+author: Hayden Stanko +* 0.3.3 — 2026-03-21 + +** Bug fixes +- Fix daemon-mode overwriting persisted workspace name on shutdown :: + In daemon mode, =delete-frame-functions= fires before =kill-emacs-hook=, + destroying the frame's treemacs scope shelf. =--sync-before-persist= + then re-queried =(treemacs-current-workspace)=, which fell back to + the wrong workspace and overwrote the correct =--current-workspace-name=. + Fixed by (1) persisting the name to disk immediately in + =--on-workspace-switch= and (2) removing the re-capture from + =--sync-before-persist=. + +** Tests +- 2 new specs: =--on-workspace-switch= disk round-trip, and + =--sync-before-persist= daemon scenario (wrong treemacs scope does + not overwrite persisted name). +- Total spec count: 104 (up from 98 in 0.3.2). + * 0.3.2 — 2026-03-21 ** New features diff --git a/CLAUDE.md b/CLAUDE.md index 80992c9..b15cc9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,5 +46,5 @@ Doom's `:ui workspaces` module enables `treemacs-persp`, which sets scope type t - Emacs Lisp with `lexical-binding: t` - Package requires Emacs 28.1+ -- Tests: `make test` (uses Buttercup, 96 specs) +- Tests: `make test` (uses Buttercup, 104 specs) - Test runner auto-detects straight.el (Doom) or Cask diff --git a/declarative-project-mode.el b/declarative-project-mode.el index 171e569..1a924de 100644 --- a/declarative-project-mode.el +++ b/declarative-project-mode.el @@ -5,8 +5,8 @@ ;; Author: Hayden Stanko ;; Maintainer: Hayden Stanko ;; Created: January 13, 2023 -;; Modified: March 20, 2026 -;; Version: 0.3.0 +;; Modified: March 21, 2026 +;; Version: 0.3.3 ;; Keywords: convenience, tools, project ;; Homepage: https://github.com/cuttlefisch/declarative-project-mode ;; Package-Requires: ((emacs "28.1") (yaml "0.5.1")) diff --git a/declarative-project-treemacs.el b/declarative-project-treemacs.el index 378b667..32aaa7c 100644 --- a/declarative-project-treemacs.el +++ b/declarative-project-treemacs.el @@ -5,11 +5,11 @@ ;; Author: Hayden Stanko ;; Maintainer: Hayden Stanko ;; Created: January 14, 2023 -;; Modified: March 20, 2026 -;; Version: 0.3.0 +;; Modified: March 21, 2026 +;; Version: 0.3.3 ;; Keywords: convenience, tools, project ;; Homepage: https://github.com/cuttlefisch/declarative-project-mode -;; Package-Requires: ((emacs "28.1") (treemacs "2.10") (declarative-project-mode "0.3.0")) +;; Package-Requires: ((emacs "28.1") (treemacs "2.10") (declarative-project-mode "0.3.3")) ;; ;; This file is not part of GNU Emacs. ;; @@ -52,6 +52,9 @@ "List of desired workspaces and project contents from declared workspaces. Initialized lazily when the mode is enabled.") +(defvar declarative-project-treemacs--current-workspace-name nil + "Name of the last-selected workspace, persisted across sessions.") + (defcustom declarative-project-treemacs-cache-file (expand-file-name "treemacs-declared-workspaces.el" user-emacs-directory) "File path for caching the declared workspace state. @@ -135,14 +138,17 @@ missing fields (e.g. `is-disabled?')." declarative-project-treemacs--desired-state))) (defun declarative-project-treemacs--save-cache () - "Write current desired state to cache file." + "Write current desired state and current workspace name to cache file." (declarative-project-treemacs--ensure-desired-state) (with-temp-file declarative-project-treemacs-cache-file (let ((standard-output (current-buffer))) (insert ";; -*- no-byte-compile: t -*-\n") (insert ";; declarative-project-treemacs cache — auto-generated\n") (prin1 `(setq declarative-project-treemacs--desired-state - ',declarative-project-treemacs--desired-state))))) + ',declarative-project-treemacs--desired-state)) + (insert "\n") + (prin1 `(setq declarative-project-treemacs--current-workspace-name + ',declarative-project-treemacs--current-workspace-name))))) (defun declarative-project-treemacs--read-cache () "Read the desired state from the cache file. @@ -175,6 +181,7 @@ or babel blocks. After resetting, re-run your declarative-project installs to repopulate." (interactive) (setq declarative-project-treemacs--desired-state nil) + (setq declarative-project-treemacs--current-workspace-name nil) (declarative-project-treemacs--ensure-desired-state) (declarative-project-treemacs--save-cache) (when declarative-project-treemacs-mode @@ -273,7 +280,10 @@ Prevents treemacs from restoring its persist file by setting the (condition-case nil (let* ((current (treemacs-current-workspace)) (name (and current (treemacs-workspace->name current))) - (target (or (and name (declarative-project-treemacs--workspaces-by-name name)) + (target (or (and declarative-project-treemacs--current-workspace-name + (declarative-project-treemacs--workspaces-by-name + declarative-project-treemacs--current-workspace-name)) + (and name (declarative-project-treemacs--workspaces-by-name name)) (car declarative-project-treemacs--desired-state)))) (when (and target (not (eq current target))) (setf (treemacs-current-workspace) target) @@ -299,6 +309,19 @@ treemacs or the user control which workspace is active." Called via `treemacs-select-functions'; REASON is ignored." (declarative-project-treemacs--sync-workspace-list)) +(defun declarative-project-treemacs--on-workspace-switch () + "Record current workspace name and sync workspace list. +Used on `treemacs-switch-workspace-hook' to persist the user's +workspace selection across sessions." + (declarative-project-treemacs--sync-workspace-list) + (condition-case nil + (let ((ws (treemacs-current-workspace))) + (when ws + (setq declarative-project-treemacs--current-workspace-name + (treemacs-workspace->name ws)) + (declarative-project-treemacs--save-cache))) + (error nil))) + (defun declarative-project-treemacs--around-exclusive-display (orig-fn) "Show treemacs without modifying workspace content when our mode is active. ORIG-FN is `treemacs-add-and-display-current-project-exclusively'." @@ -348,7 +371,8 @@ Runs on `kill-emacs-hook' at depth -90 (before treemacs's own `treemacs--persist' which runs at default depth 0)." (when declarative-project-treemacs-mode (declarative-project-treemacs--ensure-desired-state) - (setq treemacs--workspaces declarative-project-treemacs--desired-state))) + (setq treemacs--workspaces declarative-project-treemacs--desired-state) + (declarative-project-treemacs--save-cache))) ;;; --- Mode definition --- @@ -403,7 +427,7 @@ creation when active." (add-hook 'declarative-project--apply-treemacs-workspaces-hook #'declarative-project-treemacs--assign-declared-project) (add-hook 'treemacs-switch-workspace-hook - #'declarative-project-treemacs--sync-workspace-list) + #'declarative-project-treemacs--on-workspace-switch) (add-hook 'treemacs-select-functions #'declarative-project-treemacs--on-select)) (declarative-project-treemacs--save-cache) @@ -421,7 +445,7 @@ creation when active." (remove-hook 'declarative-project--apply-treemacs-workspaces-hook #'declarative-project-treemacs--assign-declared-project) (remove-hook 'treemacs-switch-workspace-hook - #'declarative-project-treemacs--sync-workspace-list) + #'declarative-project-treemacs--on-workspace-switch) (remove-hook 'treemacs-select-functions #'declarative-project-treemacs--on-select))) diff --git a/ob-declarative-project.el b/ob-declarative-project.el index 141ca65..e6ce3e7 100644 --- a/ob-declarative-project.el +++ b/ob-declarative-project.el @@ -5,11 +5,11 @@ ;; Author: Hayden Stanko ;; Maintainer: Hayden Stanko ;; Created: March 15, 2026 -;; Modified: March 20, 2026 -;; Version: 0.3.0 +;; Modified: March 21, 2026 +;; Version: 0.3.3 ;; Keywords: convenience, tools, project ;; Homepage: https://github.com/cuttlefisch/declarative-project-mode -;; Package-Requires: ((emacs "28.1") (org "9.0") (declarative-project-mode "0.3.0")) +;; Package-Requires: ((emacs "28.1") (org "9.0") (declarative-project-mode "0.3.3")) ;; ;; This file is not part of GNU Emacs. ;; diff --git a/test/test-declarative-project-treemacs.el b/test/test-declarative-project-treemacs.el index 18bbaa0..5a79145 100644 --- a/test/test-declarative-project-treemacs.el +++ b/test/test-declarative-project-treemacs.el @@ -529,7 +529,7 @@ (expect (memq #'declarative-project-treemacs--assign-declared-project declarative-project--apply-treemacs-workspaces-hook) :to-be-truthy) - (expect (memq #'declarative-project-treemacs--sync-workspace-list + (expect (memq #'declarative-project-treemacs--on-workspace-switch treemacs-switch-workspace-hook) :to-be-truthy) (expect (memq #'declarative-project-treemacs--on-select @@ -547,7 +547,7 @@ (expect (memq #'declarative-project-treemacs--assign-declared-project declarative-project--apply-treemacs-workspaces-hook) :to-equal nil) - (expect (memq #'declarative-project-treemacs--sync-workspace-list + (expect (memq #'declarative-project-treemacs--on-workspace-switch treemacs-switch-workspace-hook) :to-equal nil) (expect (memq #'declarative-project-treemacs--on-select @@ -689,6 +689,24 @@ (expect (treemacs-workspace->name (car treemacs--workspaces)) :to-equal "Synced")))) + (it "does not overwrite current-workspace-name from treemacs scope" + (with-treemacs-test-state + (let ((declarative-project-treemacs-mode t) + (treemacs--workspaces nil)) + ;; User switched to "hobby splines" earlier — name is already set + (setq declarative-project-treemacs--current-workspace-name "hobby splines") + (setq declarative-project-treemacs--desired-state + (list (treemacs-workspace->create! :name "declarative project mode" :projects nil) + (treemacs-workspace->create! :name "hobby splines" :projects nil))) + ;; Simulate daemon: treemacs-current-workspace returns wrong ws + ;; (frame's scope shelf is gone, falls back to first/wrong workspace) + (setf (treemacs-current-workspace) + (car declarative-project-treemacs--desired-state)) + (declarative-project-treemacs--sync-before-persist) + ;; The persisted name must still be "hobby splines", not overwritten + (expect declarative-project-treemacs--current-workspace-name + :to-equal "hobby splines")))) + (it "adds kill-emacs-hook on enable and removes on disable" (with-treemacs-test-state (let ((declarative-project--apply-treemacs-workspaces-hook nil) @@ -749,6 +767,85 @@ (expect 'declarative-project-treemacs--override-workspaces :not :to-have-been-called))))) +;;; ========================================================================== +;;; current workspace persistence +;;; ========================================================================== + +(describe "current workspace persistence" + (it "save-cache persists current workspace name to file" + (with-treemacs-test-state + (let ((ws (treemacs-workspace->create! :name "Active" :projects nil))) + (setq declarative-project-treemacs--desired-state (list ws)) + (setq declarative-project-treemacs--current-workspace-name "Active") + (declarative-project-treemacs--save-cache) + (let ((content (with-temp-buffer + (insert-file-contents cache-file) + (buffer-string)))) + (expect content :to-match "current-workspace-name"))))) + + (it "round-trips current workspace name through save+read" + (with-treemacs-test-state + (let ((ws (treemacs-workspace->create! :name "Persisted" :projects nil))) + (setq declarative-project-treemacs--desired-state (list ws)) + (setq declarative-project-treemacs--current-workspace-name "Persisted") + (declarative-project-treemacs--save-cache) + ;; Clear and reload + (setq declarative-project-treemacs--current-workspace-name nil) + (setq declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--read-cache) + (expect declarative-project-treemacs--current-workspace-name + :to-equal "Persisted")))) + + (it "override-workspaces restores persisted name instead of first" + (with-treemacs-test-state + (let* ((ws-a (treemacs-workspace->create! :name "Alpha" :projects nil)) + (ws-b (treemacs-workspace->create! :name "Beta" :projects nil))) + (setq declarative-project-treemacs--desired-state (list ws-a ws-b)) + (setq declarative-project-treemacs--current-workspace-name "Beta") + ;; Current workspace is nil (simulating fresh restart) + (setf (treemacs-current-workspace) nil) + (declarative-project-treemacs--override-workspaces) + (expect (treemacs-workspace->name (treemacs-current-workspace)) + :to-equal "Beta")))) + + (it "falls back to first when persisted name no longer exists" + (with-treemacs-test-state + (let ((ws (treemacs-workspace->create! :name "OnlyWS" :projects nil))) + (setq declarative-project-treemacs--desired-state (list ws)) + (setq declarative-project-treemacs--current-workspace-name "Deleted") + (setf (treemacs-current-workspace) nil) + (declarative-project-treemacs--override-workspaces) + (expect (treemacs-workspace->name (treemacs-current-workspace)) + :to-equal "OnlyWS")))) + + (it "on-workspace-switch records current workspace name" + (with-treemacs-test-state + (let ((ws (treemacs-workspace->create! :name "Switched" :projects nil))) + (setq declarative-project-treemacs--desired-state (list ws)) + (setf (treemacs-current-workspace) ws) + (declarative-project-treemacs--on-workspace-switch) + (expect declarative-project-treemacs--current-workspace-name + :to-equal "Switched")))) + + (it "on-workspace-switch persists name to disk" + (with-treemacs-test-state + (let ((ws (treemacs-workspace->create! :name "Persisted-Switch" :projects nil))) + (setq declarative-project-treemacs--desired-state (list ws)) + (setf (treemacs-current-workspace) ws) + (declarative-project-treemacs--on-workspace-switch) + ;; Clear in-memory name and reload from disk + (setq declarative-project-treemacs--current-workspace-name nil) + (setq declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--read-cache) + (expect declarative-project-treemacs--current-workspace-name + :to-equal "Persisted-Switch")))) + + (it "reset-cache clears current workspace name" + (with-treemacs-test-state + (setq declarative-project-treemacs--current-workspace-name "Stale") + (declarative-project-treemacs-reset-cache) + (expect declarative-project-treemacs--current-workspace-name :to-be nil)))) + ;;; ========================================================================== ;;; Advice registration (mode enable/disable) ;;; ========================================================================== diff --git a/test/test-helper.el b/test/test-helper.el index c8b40b8..ce9614e 100644 --- a/test/test-helper.el +++ b/test/test-helper.el @@ -159,6 +159,7 @@ Binds `cache-file' to the temporary cache path." (declare (indent 0)) `(let* ((cache-file (make-temp-file "dpm-treemacs-cache-" nil ".el")) (declarative-project-treemacs--desired-state nil) + (declarative-project-treemacs--current-workspace-name nil) (declarative-project-treemacs-cache-file cache-file) (declarative-project-treemacs-mode nil) (treemacs--current-workspace-stub nil)