diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-26 19:28:05 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-26 19:28:05 -0500 |
| commit | 899c55f230d96649bdd3a3f4a8a0302ecf2df994 (patch) | |
| tree | d32e11d1a016fc2200f11fbb8beef39314d0982c | |
| parent | 269ed3c2b1a94ed8ebad5fdce110efb671aa70f6 (diff) | |
| download | pearl-899c55f230d96649bdd3a3f4a8a0302ecf2df994.tar.gz pearl-899c55f230d96649bdd3a3f4a8a0302ecf2df994.zip | |
feat(refresh): prompt instead of silently deferring a dirty refresh
Running pearl-list-issues on a buffer with unsaved edits used to print a passive "refresh deferred" message and do nothing visible, so it read as a no-op. Now it prompts: (d)iscard and rebuild, (m)erge by LINEAR-ID keeping the edits, or (q) cancel. Branch C of pearl--update-org-from-issues dispatches on the choice: discard rebuilds in place, merge hands off to the same by-id merge that pearl-refresh-current-view uses, and cancel leaves the buffer untouched.
| -rw-r--r-- | pearl.el | 58 | ||||
| -rw-r--r-- | tests/test-pearl-org-write.el | 10 | ||||
| -rw-r--r-- | tests/test-pearl-output.el | 59 |
3 files changed, 113 insertions, 14 deletions
@@ -2838,6 +2838,16 @@ yields the hardcoded default. Pure function, no side effects." (buffer-string)))) +(defun pearl--dirty-refresh-choice (path) + "Ask how to refresh PATH when its buffer has unsaved changes. +Returns one of ?d (discard the edits and rebuild), ?m (merge by LINEAR-ID, +keeping unpushed edits), or ?q (cancel and leave the buffer untouched)." + (read-char-choice + (format (concat "%s has unsaved changes — " + "(d)iscard & refresh, (m)erge keeping edits, (q) cancel? ") + (file-name-nondirectory path)) + '(?d ?m ?q))) + (defun pearl--update-org-from-issues (issues &optional source truncated) "Update `pearl-org-file-path' with rendered ISSUES, buffer-aware. SOURCE and TRUNCATED are threaded into the header (see @@ -2849,8 +2859,9 @@ Behavior depends on whether the file is currently visited in a buffer: then visit it so there is a buffer to show. - Buffer exists and is unmodified: replace its contents in place and save, preserving point and avoiding a modtime mismatch warning. -- Buffer exists and has unsaved edits: do not overwrite. Log and emit a - message asking the user to save or revert before re-running. +- Buffer exists and has unsaved edits: a full rebuild would discard them, so + prompt (see `pearl--dirty-refresh-choice') to discard and rebuild, merge by + LINEAR-ID keeping the edits, or cancel. In every case the resulting buffer is surfaced (see `pearl--surface-buffer'), so a fetch run from a menu or dashboard actually shows the issues instead of @@ -2892,15 +2903,42 @@ just writing the file and leaving it off-screen." org-file-path (length issues)) (pearl--surface-buffer existing-buf)) - ;; Branch C: buffer is dirty -- defer, do not overwrite, but show it. + ;; Branch C: buffer is dirty -- a full rebuild would discard unsaved work, + ;; so ask before clobbering rather than silently deferring. (t - (pearl--log - "Linear refresh deferred: %s has unsaved changes (%d issues not written)" - org-file-path (length issues)) - (message - "Linear refresh deferred: %s has unsaved changes. Save or revert, then re-run M-x pearl-list-issues." - (file-name-nondirectory org-file-path)) - (pearl--surface-buffer existing-buf))))) + (pcase (pearl--dirty-refresh-choice org-file-path) + (?d ; discard the edits, rebuild in place + (with-current-buffer existing-buf + (let ((inhibit-read-only t)) + (set-buffer-modified-p nil) + (erase-buffer) + (insert new-content) + (save-buffer) + (goto-char (point-min)) + (pearl--restore-page-visibility) + (pearl-highlight-comments))) + (message "Updated Linear issues in %s with %d active issues" + org-file-path (length issues)) + (pearl--surface-buffer existing-buf)) + (?m ; merge by LINEAR-ID, keeping edits + (with-current-buffer existing-buf + (let ((counts (pearl--merge-issues-into-buffer issues))) + (pearl--update-source-header (length issues) truncated) + (pearl--update-derived-todo-header (pearl--gather-header-states issues)) + (pearl-highlight-comments) + (pearl--restore-page-visibility) + (message "Merged into %s: %d updated, %d added, %d dropped%s" + (file-name-nondirectory org-file-path) + (plist-get counts :updated) + (plist-get counts :added) + (plist-get counts :dropped) + (let ((s (plist-get counts :skipped))) + (if (> s 0) (format ", %d kept (unpushed edits)" s) ""))))) + (pearl--surface-buffer existing-buf)) + (_ ; cancel + (message "Linear refresh cancelled; %s left unchanged" + (file-name-nondirectory org-file-path)) + (pearl--surface-buffer existing-buf))))))) (defcustom pearl-saved-queries nil "Named local issue queries, run with `pearl-run-saved-query'. diff --git a/tests/test-pearl-org-write.el b/tests/test-pearl-org-write.el index 6c30380..e467436 100644 --- a/tests/test-pearl-org-write.el +++ b/tests/test-pearl-org-write.el @@ -69,19 +69,20 @@ (should-not (string-match-p "old content" (buffer-string))))))) (ert-deftest test-pearl-update-org-dirty-buffer-not-overwritten () - "A buffer with unsaved edits is left untouched, not clobbered." + "Cancelling the dirty-refresh prompt leaves a buffer with unsaved edits untouched." (test-pearl--with-org-file tmp (let ((buf (find-file-noselect tmp))) (with-current-buffer buf (insert "unsaved edits")) - (pearl--update-org-from-issues test-pearl--sample-issues) + (cl-letf (((symbol-function 'pearl--dirty-refresh-choice) (lambda (_p) ?q))) + (pearl--update-org-from-issues test-pearl--sample-issues)) (with-current-buffer buf (should (buffer-modified-p)) (should (string-match-p "unsaved edits" (buffer-string))) (should-not (string-match-p "ENG-1" (buffer-string))))))) (ert-deftest test-pearl-update-org-dirty-buffer-preserves-disk-and-surfaces () - "A dirty buffer is left as-is, the on-disk file is untouched, and the buffer is surfaced." + "Cancelling the prompt leaves the dirty buffer and on-disk file as-is, and surfaces it." (test-pearl--with-org-file tmp (let ((buf (find-file-noselect tmp)) (surfaced nil)) @@ -92,7 +93,8 @@ (goto-char (point-max)) (insert "UNSAVED EDIT\n")) (cl-letf (((symbol-function 'pearl--surface-buffer) - (lambda (b) (setq surfaced b)))) + (lambda (b) (setq surfaced b))) + ((symbol-function 'pearl--dirty-refresh-choice) (lambda (_p) ?q))) (pearl--update-org-from-issues test-pearl--sample-issues)) ;; buffer keeps the unsaved edit and stays modified (with-current-buffer buf diff --git a/tests/test-pearl-output.el b/tests/test-pearl-output.el index eb5c671..58cd446 100644 --- a/tests/test-pearl-output.el +++ b/tests/test-pearl-output.el @@ -184,5 +184,64 @@ (kill-buffer buf)) (ignore-errors (delete-file tmp))))) +;;; --update-org-from-issues on a dirty buffer (prompted: discard / merge / cancel) + +(defconst test-pearl-output--issue + '(:id "u" :identifier "ENG-1" :title "Hello" :priority 2 :state (:name "Todo")) + "A minimal normalized issue for the dirty-buffer branch tests.") + +(defmacro test-pearl-output--with-dirty-buffer (choice &rest body) + "Run BODY with a modified buffer visiting a temp active file and the dirty +refresh prompt stubbed to return CHOICE. BUF and TMP are bound in BODY." + (declare (indent 1)) + `(let* ((tmp (make-temp-file "pearl-dirty" nil ".org")) + (pearl-org-file-path tmp) + (buf (find-file-noselect tmp))) + (unwind-protect + (with-current-buffer buf + (insert "DIRTYMARKER not yet saved\n") ; buffer is now modified + (cl-letf (((symbol-function 'pearl--surface-buffer) #'ignore) + ((symbol-function 'pearl--dirty-refresh-choice) + (lambda (_path) ,choice))) + ,@body)) + (when (buffer-live-p buf) + (with-current-buffer buf (set-buffer-modified-p nil)) + (kill-buffer buf)) + (ignore-errors (delete-file tmp))))) + +(ert-deftest test-pearl-update-org-dirty-discard-rebuilds () + "Choosing discard rebuilds the buffer from the fetch and clears the edits." + (test-pearl-output--with-dirty-buffer ?d + (pearl--update-org-from-issues (list test-pearl-output--issue) + '(:type filter :name "X" :filter nil) nil) + (should-not (buffer-modified-p)) + (should-not (string-match-p "DIRTYMARKER" (buffer-string))) + (should (string-match-p "ENG-1" (buffer-string))))) + +(ert-deftest test-pearl-update-org-dirty-cancel-leaves-buffer () + "Choosing cancel leaves the dirty buffer untouched." + (test-pearl-output--with-dirty-buffer ?q + (pearl--update-org-from-issues (list test-pearl-output--issue) + '(:type filter :name "X" :filter nil) nil) + (should (buffer-modified-p)) + (should (string-match-p "DIRTYMARKER" (buffer-string))) + (should-not (string-match-p "ENG-1" (buffer-string))))) + +(ert-deftest test-pearl-update-org-dirty-merge-routes-to-merge () + "Choosing merge routes to the by-LINEAR-ID merge instead of a rebuild." + (let ((merged nil)) + (test-pearl-output--with-dirty-buffer ?m + (cl-letf (((symbol-function 'pearl--merge-issues-into-buffer) + (lambda (_issues) (setq merged t) '(:updated 1 :added 0 :dropped 0 :skipped 0))) + ((symbol-function 'pearl--update-source-header) #'ignore) + ((symbol-function 'pearl--update-derived-todo-header) #'ignore) + ((symbol-function 'pearl-highlight-comments) #'ignore) + ((symbol-function 'pearl--restore-page-visibility) #'ignore)) + (pearl--update-org-from-issues (list test-pearl-output--issue) + '(:type filter :name "X" :filter nil) nil)) + (should merged) + ;; merge does not rebuild, so the marker is not wiped by a full replace + (should (string-match-p "DIRTYMARKER" (buffer-string)))))) + (provide 'test-pearl-output) ;;; test-pearl-output.el ends here |
