diff --git a/Makefile b/Makefile index 6e963a8..020677e 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,107 @@ -.PHONY: test -test: - emacs -batch -l ert -l undo-propose.el -l undo-propose-test.el -f ert-run-tests-batch-and-exit +.PHONY: help compile test checkdoc lint ci clean + +.DEFAULT_GOAL := help + +# Package information +PACKAGE := undo-propose +VERSION := $(shell perl -ne 'if (/^;;\s*Version:\s*(\S+)/) {print $$1; last}' $(PACKAGE).el) +TEST_COUNT := $(shell grep -c 'ert-deftest' tests/$(PACKAGE)-tests.el 2>/dev/null || echo 0) + +# Emacs command +EMACS ?= emacs +BATCH := $(EMACS) --batch + +# All Elisp source files +EL_FILES := $(PACKAGE).el $(wildcard tests/*.el) + +help: + @echo "$(PACKAGE) v$(VERSION) - Makefile targets" + @echo "" + @echo "Development:" + @echo " make compile Byte-compile the package" + @echo " make clean Remove generated files" + @echo "" + @echo "Quality checks:" + @echo " make test Run ERT unit tests ($(TEST_COUNT) tests)" + @echo " make checkdoc Check documentation strings" + @echo " make lint Run package-lint (requires network)" + @echo "" + @echo "CI:" + @echo " make ci Run all" + @echo "" + +$(PACKAGE).elc: $(PACKAGE).el + @echo "Byte-compiling $(PACKAGE)..." + @$(BATCH) --eval "(setq byte-compile-error-on-warn t)" \ + -f batch-byte-compile $(PACKAGE).el + @echo "✓ Compilation successful" + +compile: $(PACKAGE).elc + +test: $(PACKAGE).elc + @if [ -f tests/$(PACKAGE)-tests.el ]; then \ + $(BATCH) -l ert -l $(PACKAGE).el \ + -l tests/$(PACKAGE)-tests.el \ + -f ert-run-tests-batch-and-exit; \ + else \ + echo "No tests found."; \ + fi + +checkdoc: $(PACKAGE).elc + @echo "Running checkdoc on $(PACKAGE).el..." + @$(BATCH) --eval "\ + (progn \ + (require 'checkdoc) \ + (let ((checkdoc-diagnostic-buffer \"*chk*\") \ + (issues 0) (output \"\")) \ + (checkdoc-file \"$(PACKAGE).el\") \ + (when (get-buffer \"*chk*\") \ + (with-current-buffer \"*chk*\" \ + (unless (zerop (buffer-size)) \ + (setq issues (count-lines (point-min) (point-max))) \ + (setq output (buffer-string))))) \ + (when (get-buffer \"*Warnings*\") \ + (with-current-buffer \"*Warnings*\" \ + (unless (zerop (buffer-size)) \ + (setq issues (+ issues (count-lines (point-min) (point-max)))) \ + (setq output (concat output (buffer-string)))))) \ + (if (> issues 0) \ + (progn \ + (message \"checkdoc: %d issue(s) found in $(PACKAGE).el:\" issues) \ + (message \"%s\" output) \ + (kill-emacs 1)) \ + (message \"checkdoc: $(PACKAGE).el OK (no issues)\"))))" + +lint: $(PACKAGE).elc + @echo "Running package-lint checks..." + @$(BATCH) \ + --eval "(require 'package)" \ + --eval "(push '(\"melpa\" . \"https://melpa.org/packages/\") package-archives)" \ + --eval "(package-initialize)" \ + --eval "(condition-case err \ + (unless (package-installed-p 'package-lint) \ + (package-refresh-contents) \ + (package-install 'package-lint)) \ + (error \ + (message \"ERROR: Failed to install package-lint: %s\" (error-message-string err)) \ + (message \"Hint: check your network connection and MELPA availability.\") \ + (kill-emacs 1)))" \ + --eval "(require 'package-lint)" \ + -f package-lint-batch-and-exit $(PACKAGE).el + @echo "✓ package-lint passed" + + +ci: clean compile checkdoc lint test + @echo "" + @echo "✓ All CI checks passed!" + @echo " - Byte compilation: OK" + @echo " - Documentation (checkdoc): OK" + @echo " - Lint (package-lint): OK" + @echo " - Tests: $(TEST_COUNT)/$(TEST_COUNT) passing" + @echo "" + @echo "Package $(PACKAGE) v$(VERSION) - ready for release" + +clean: + @echo "Cleaning generated files..." + @rm -f *.elc *~ + @echo "✓ Clean complete" diff --git a/Readme.org b/README.org similarity index 92% rename from Readme.org rename to README.org index 443c024..ec77419 100644 --- a/Readme.org +++ b/README.org @@ -53,6 +53,13 @@ individual undo events (squashing them into a single edit event in the undo history). To cancel, type ~C-c C-k~. You can also ediff the proposed chain of undo's by typing ~C-c C-d~. +*** Read-only buffers + +The ~undo-propose~ also works on read-only buffers (e.g., =magit-diff=, +=help-mode=). This provides a handy way to create a temporary +reference copy of any buffer. The snapshot can be kept open as a +reference while continuing to work in other buffers. + ** Configuration *** Adding commands (e.g. redo) @@ -135,6 +142,13 @@ bugs that corrupt the undo history. - [[https://www.reddit.com/r/emacs/comments/6yzwic/how_emacs_undo_works/][reddit.com/r/emacs: How Emacs undo works]] ** Changes +*** 5.0.0 + +Requires Emacs 29.1+ and adds a new dependency on =transient= (0.3.0+). + +A transient help menu is now available in the undo-propose buffer via +~?~ or ~h~, listing all available commands. + *** 4.0.0 Switched to using the ~display-buffer~ framework to configure window diff --git a/tests/undo-propose-tests.el b/tests/undo-propose-tests.el new file mode 100644 index 0000000..698f3e4 --- /dev/null +++ b/tests/undo-propose-tests.el @@ -0,0 +1,211 @@ +;;; undo-propose-tests.el --- Tests for undo-propose -*- lexical-binding: t -*- + +(require 'ert) +(require 'undo-propose) + +;;; Code: + +;;; Helpers + +(defmacro with-undoable-temp-buffer (&rest body) + "Like `with-temp-buffer', but doesn't disable `undo'. +Executes BODY in the temporary buffer." + `(let ((temp-buffer (generate-new-buffer + ;; this must NOT start with a space, otherwise + ;; undo won't work. See `get-buffer-create' + "*temp*"))) + (unwind-protect + (with-current-buffer temp-buffer + ,@body) + (kill-buffer temp-buffer)))) + +(defmacro with-undo-propose-test (initial &rest body) + "Set up a temp buffer with INITIAL text, open undo-propose, run BODY. +BODY runs inside the undo-propose buffer. The original buffer is +bound to `orig-buf'." + (declare (indent 1)) + `(with-undoable-temp-buffer + (insert ,initial) + (undo-boundary) + (let ((orig-buf (current-buffer))) + (undo-propose) + ,@body))) + +;;; commit + +(ert-deftest undo-propose-commit-copies-buffer () + "Committing copies the proposed buffer content back to the parent." + (with-undo-propose-test "hello" + (let ((inhibit-read-only t)) + (erase-buffer) + (insert "world")) + (undo-propose-commit) + (should (equal (buffer-string) "world")))) + +(ert-deftest undo-propose-commit-copies-undo-ring () + "Committing copies the undo-ring back so changes can be undone." + (with-undo-propose-test "first" + (call-interactively (command-remapping 'undo)) + (undo-propose-commit) + ;; after commit, parent should be undoable back to empty + (undo) + (should (equal (buffer-string) "")))) + +(ert-deftest undo-propose-commit-restores-point () + "Committing restores point to the position in the propose buffer." + (with-undo-propose-test "hello world" + (goto-char 6) + (undo-propose-commit) + (should (= (point) 6)))) + +(ert-deftest undo-propose-commit-kills-propose-buffer () + "Committing kills the temporary undo-propose buffer." + (with-undo-propose-test "hello" + (let ((propose-buf (current-buffer))) + (undo-propose-commit) + (should-not (buffer-live-p propose-buf))))) + +;;; squash-commit + +(ert-deftest undo-propose-squash-commit-copies-buffer () + "Squash-committing copies buffer content to the parent." + (with-undo-propose-test "hello" + (call-interactively (command-remapping 'undo)) + (undo-propose-squash-commit) + (should (equal (buffer-string) "")))) + +(ert-deftest undo-propose-squash-commit-does-not-copy-undo-ring () + "Squash-committing does NOT copy the propose buffer's undo-ring to the parent." + (with-undo-propose-test "first" + (call-interactively (command-remapping 'undo)) + ;; capture propose buffer's undo-list after undoing + (let ((propose-undo-list buffer-undo-list)) + (undo-propose-squash-commit) + ;; parent undo-list must differ from propose buffer's undo-list + (with-current-buffer orig-buf + (should-not (equal buffer-undo-list propose-undo-list)))))) + +(ert-deftest undo-propose-squash-commit-noop-when-unchanged () + "Squash-committing without undoing leaves the parent buffer unchanged." + (with-undo-propose-test "hello" + (let ((undo-list-before buffer-undo-list)) + (undo-propose-squash-commit) + (should (equal (buffer-string) "hello")) + (should (equal buffer-undo-list undo-list-before))))) + +;;; cancel + +(ert-deftest undo-propose-cancel-leaves-parent-unchanged () + "Cancelling does not modify the parent buffer content." + (with-undo-propose-test "original" + (let ((inhibit-read-only t)) + (erase-buffer) + (insert "discarded")) + (undo-propose-cancel) + (with-current-buffer orig-buf + (should (equal (buffer-string) "original"))))) + +(ert-deftest undo-propose-cancel-kills-propose-buffer () + "Cancelling kills the temporary undo-propose buffer." + (with-undo-propose-test "hello" + (let ((propose-buf (current-buffer))) + (undo-propose-cancel) + (should-not (buffer-live-p propose-buf))))) + +;;; nested call + +(ert-deftest undo-propose-nested-call-runs-undo () + "Calling undo-propose inside an undo-propose buffer runs undo instead." + (with-undo-propose-test "hello" + (let ((inhibit-read-only t)) + (insert " world") + (undo-boundary)) + ;; second call should undo the " world" insertion + (undo-propose) + (should (equal (buffer-string) "hello")) + (undo-propose-cancel))) + +;;; hooks + +(ert-deftest undo-propose-entry-hook-runs-on-entry () + "`undo-propose-entry-hook' fires when entering the propose buffer." + (with-undoable-temp-buffer + (let ((fired nil)) + (add-hook 'undo-propose-entry-hook (lambda () (setq fired t))) + (unwind-protect + (progn + (undo-propose) + (should fired) + (undo-propose-cancel)) + (remove-hook 'undo-propose-entry-hook (lambda () (setq fired t))))))) + +(ert-deftest undo-propose-done-hook-runs-on-commit () + "`undo-propose-done-hook' fires after committing." + (with-undo-propose-test "hello" + (let ((fired nil)) + (add-hook 'undo-propose-done-hook (lambda () (setq fired t))) + (unwind-protect + (progn + (undo-propose-commit) + (should fired)) + (remove-hook 'undo-propose-done-hook (lambda () (setq fired t))))))) + +(ert-deftest undo-propose-done-hook-runs-on-cancel () + "`undo-propose-done-hook' fires even when cancelling." + (with-undo-propose-test "hello" + (let ((fired nil)) + (add-hook 'undo-propose-done-hook (lambda () (setq fired t))) + (unwind-protect + (progn + (undo-propose-cancel) + (should fired)) + (remove-hook 'undo-propose-done-hook (lambda () (setq fired t))))))) + +;;; read-only source (reference snapshot) + +(ert-deftest undo-propose-readonly-source-sets-flag () + "Opening undo-propose on a buffer with no undo history sets the snapshot flag." + (let ((buf (generate-new-buffer "*ro-test*"))) + (unwind-protect + (with-current-buffer buf + (setq buffer-undo-list t) + (undo-propose) + (should undo-propose-read-only-source) + (undo-propose-cancel)) + (when (buffer-live-p buf) (kill-buffer buf))))) + +(ert-deftest undo-propose-readonly-source-allows-undo-in-propose () + "In snapshot mode the propose buffer has its own undo history." + (let ((buf (generate-new-buffer "*ro-test*"))) + (unwind-protect + (with-current-buffer buf + (setq buffer-undo-list t) + (undo-propose) + (let ((inhibit-read-only t)) + (insert "typed in propose") + (undo-boundary)) + (call-interactively (command-remapping 'undo)) + (should (equal (buffer-string) "")) + (undo-propose-cancel)) + (when (buffer-live-p buf) (kill-buffer buf))))) + +;;; org-clock marker integration (existing test, kept for regression) + +(ert-deftest undo-propose-test-org-clock () + (with-undoable-temp-buffer + (org-mode) + (insert "* test\n") + (undo-boundary) + (org-clock-in) + (undo-boundary) + (goto-char (point-max)) + (insert "\nfoobar") + (undo-boundary) + (undo-propose) + (call-interactively (command-remapping 'undo)) + (undo-propose-commit) + (org-clock-out))) + +(provide 'undo-propose-tests) + +;;; undo-propose-tests.el ends here diff --git a/undo-propose-test.el b/undo-propose-test.el deleted file mode 100644 index 4519ae5..0000000 --- a/undo-propose-test.el +++ /dev/null @@ -1,31 +0,0 @@ -;;; undo-propose-test.el --- Tests for undo-propose - -(require 'undo-propose) - -(defmacro with-undoable-temp-buffer (&rest body) - "Like `with-temp-buffer', but doesn't disable `undo'." - `(let ((temp-buffer (generate-new-buffer - ;; this must NOT start with a space, otherwise - ;; undo won't work. See `get-buffer-create' - "*temp*"))) - (unwind-protect - (with-current-buffer temp-buffer - ,@body) - (kill-buffer temp-buffer)))) - -(ert-deftest undo-propose-test-org-clock () - (with-undoable-temp-buffer - (org-mode) - (insert "* test\n") - (undo-boundary) - (org-clock-in) - (undo-boundary) - (goto-char (point-max)) - (insert "\nfoobar") - (undo-boundary) - (undo-propose) - (call-interactively (command-remapping 'undo)) - (undo-propose-commit) - (org-clock-out))) - -;;; undo-propose-test.el ends here diff --git a/undo-propose.el b/undo-propose.el index 07f3a26..18d90f2 100644 --- a/undo-propose.el +++ b/undo-propose.el @@ -2,8 +2,8 @@ ;; Author: Jack Kamm ;; Maintainer: Jack Kamm -;; Version: 4.0.0 -;; Package-Requires: ((emacs "24.3")) +;; Version: 5.0.0 +;; Package-Requires: ((emacs "29.1") (transient "0.3.0")) ;; Homepage: https://github.com/jackkamm/undo-propose.el ;; Keywords: convenience, files, undo, redo, history @@ -43,13 +43,14 @@ ;;; Code: (require 'cl-lib) +(require 'transient) (defgroup undo-propose nil - "Simple and safe undo navigation" + "Simple and safe undo navigation." :group 'convenience) (defcustom undo-propose-done-hook nil - "Hook runs when leaving the temporal buffer." + "Hook runs when leaving the temporal buffer, including on cancel." :type 'hook :group 'undo-propose) @@ -67,10 +68,11 @@ The default window behavior has also changed. Use (defcustom undo-propose-marker-list '(org-clock-marker org-clock-hd-marker) "List of quoted markers to update after running undo-propose." - :type 'list + :type '(repeat symbol) :group 'undo-propose) -(defvar undo-propose-parent nil "Parent buffer of ‘undo-propose’ buffer.") +(defvar undo-propose-parent nil "Parent buffer of `undo-propose' buffer.") +(defvar undo-propose-read-only-source nil "Non-nil if source buffer had no undo history.") (defun undo-propose--message (content) "Message CONTENT, possibly with prefix \"undo-propose: \"." @@ -85,11 +87,12 @@ The default window behavior has also changed. Use (defun undo-propose () "Navigate undo history in a new temporary buffer. \\ -Copies 'current-buffer' and 'buffer-undo-list' to a new temporary buffer, +Copies `current-buffer' and `buffer-undo-list' to a new temporary buffer, which is read-only except for undo commands. After finished undoing, type \\[undo-propose-commit] to accept the chain of undos, -or \\[undo-propose-squash-commit] to copy the buffer but squash the undo's into a single edit event event. To cancel, type \\[undo-propose-cancel], and -to view an ediff type \\[undo-propose-diff]. +or \\[undo-propose-squash-commit] to copy the buffer but squash the undo's into a +single edit event. To cancel, type \\[undo-propose-cancel], and to view an +ediff type \\[undo-propose-diff]. If already inside an `undo-propose' buffer, this will simply call `undo'." (interactive) @@ -97,35 +100,41 @@ If already inside an `undo-propose' buffer, this will simply call `undo'." (undo) (let ((mode major-mode) (orig-buffer (current-buffer)) - (list-copy (undo-copy-list buffer-undo-list)) + (no-undo-history (eq buffer-undo-list t)) + (list-copy (if (eq buffer-undo-list t) + nil + (undo-copy-list buffer-undo-list))) (pos (point)) (win-start (window-start)) (tmp-buffer (generate-new-buffer (concat "*Undo Propose: " (buffer-name) "*")))) (pop-to-buffer tmp-buffer) - (funcall mode) - (insert-buffer-substring orig-buffer 1 (1+ (buffer-size orig-buffer))) - (goto-char pos) + (if no-undo-history + (special-mode) + (funcall mode)) + (let ((inhibit-read-only t)) + (insert-buffer-substring orig-buffer 1 (1+ (buffer-size orig-buffer))) + (goto-char pos)) (set-window-start (selected-window) win-start) (setq-local buffer-undo-list list-copy) (setq-local buffer-read-only t) (setq-local undo-propose-parent orig-buffer) + (setq-local undo-propose-read-only-source no-undo-history) (undo-propose-mode 1) - (undo-propose-copy-markers) + (unless no-undo-history + (undo-propose-copy-markers)) (run-hooks 'undo-propose-entry-hook) - (undo-propose--message "C-c C-c to commit, C-c C-s to squash commit, C-c C-k to cancel, C-c C-d to diff")))) + (undo-propose--message + (if no-undo-history + "Reference snapshot. Type ? for help" + "Undo propose. Type ? for help"))))) -(define-minor-mode undo-propose-mode - "Minor mode for `undo-propose'." - nil " UndoP" (make-sparse-keymap)) -(define-key undo-propose-mode-map (kbd "C-c C-c") 'undo-propose-commit) -(define-key undo-propose-mode-map (kbd "C-c C-s") 'undo-propose-squash-commit) -(define-key undo-propose-mode-map (kbd "C-c C-d") 'undo-propose-diff) -(define-key undo-propose-mode-map (kbd "C-c C-k") 'undo-propose-cancel) +(defvar undo-propose-mode-map (make-sparse-keymap) + "Keymap for `undo-propose-mode'.") (defmacro undo-propose-wrap (command) - "Wrap COMMAND so it is useable within the ‘undo-propose’ buffer." + "Wrap COMMAND so it is useable within the `undo-propose' buffer." `(define-key undo-propose-mode-map [remap ,command] (lambda () (interactive) @@ -136,7 +145,7 @@ If already inside an `undo-propose' buffer, this will simply call `undo'." (undo-propose-wrap undo-only) (defun undo-propose-commit () - "Quit and copy ‘undo-propose’ buffer and undo-ring back to the parent buffer." + "Quit and copy `undo-propose' buffer and undo-ring back to the parent buffer." (interactive) (let ((win (selected-window)) (orig-buffer undo-propose-parent) @@ -185,16 +194,23 @@ buffer contents are copied." 'undo-propose-squash-commit "3.0.0") (defun undo-propose-cancel () - "Kill ‘undo-propose’ buffer without copying back to its parent." + "Kill `undo-propose' buffer without copying back to its parent." (interactive) (quit-restore-window (selected-window) 'kill) (undo-propose--message "cancel") (run-hooks 'undo-propose-done-hook)) (defun undo-propose-diff () - "View differences between ‘undo-propose’ buffer and its parent using `ediff'." + "View differences between `undo-propose' buffer and another buffer using `ediff'. +For reference snapshots, compares with the buffer in the other window. +Otherwise, compares with the parent buffer." (interactive) - (ediff-buffers undo-propose-parent (current-buffer))) + (let ((compare-buffer (if undo-propose-read-only-source + (window-buffer (next-window)) + undo-propose-parent))) + (if (or (null compare-buffer) (eq compare-buffer (current-buffer))) + (undo-propose--message "No buffer to compare with") + (ediff-buffers compare-buffer (current-buffer))))) (defvar-local undo-propose-marker-map nil) @@ -224,6 +240,48 @@ buffer contents are copied." (move-marker orig-marker (marker-position new-marker) undo-propose-parent))))) +;;;; Help mode + +;;;###autoload (autoload 'git-manager-dispatch "git-manager-mode" nil t) +(transient-define-prefix undo-propose-dispatch () + "Transient menu for undo-propose operations." + ["undo-propose Actions commands" + [("c" "Commit" undo-propose-commit) + ("s" "Squash commit" undo-propose-squash-commit) + ("k" "Cancel" undo-propose-cancel)]] + [["View" + ("d" "Diff/Ediff" undo-propose-diff) + ("?" "Help" undo-propose-dispatch :transient t)]] + [["Exit" + ("q" "Quit menu" transient-quit-one)]]) + +;;;; Keymap + +(defvar undo-propose-mode-map (make-sparse-keymap) + "Keymap for `undo-propose-mode'.") + +(let ((map undo-propose-mode-map)) + ;; -- Actions -- + (define-key map (kbd "C-c C-c") #'undo-propose-commit) + (define-key map (kbd "C-c C-s") #'undo-propose-squash-commit) + (define-key map (kbd "C-c C-d") #'undo-propose-diff) + (define-key map (kbd "C-c C-k") #'undo-propose-cancel) + ;; -- Suppress special-mode's q binding -- + (define-key map (kbd "q") #'undo-propose-dispatch) + ;; -- Transient / Help -- + (define-key map (kbd "?") #'undo-propose-dispatch) + (define-key map (kbd "h") #'undo-propose-dispatch) + (define-key map [remap self-insert-command] #'undo-propose-dispatch)) + +;;; Minor mode + +;;;###autoload +(define-minor-mode undo-propose-mode + "Minor mode for `undo-propose'." + :lighter " UndoP" + :keymap (make-sparse-keymap)) + + (provide 'undo-propose) ;;; undo-propose.el ends here