summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md8
-rw-r--r--docs/plans/2026-04-10-navigation-history-implementation.md118
-rw-r--r--lisp/ss-keys.el4
-rw-r--r--lisp/ss-org.el175
-rw-r--r--tests/ss-capture-tests.el145
5 files changed, 409 insertions, 41 deletions
diff --git a/README.md b/README.md
index 5a4bede..ee5c610 100644
--- a/README.md
+++ b/README.md
@@ -174,14 +174,22 @@ The configured capture templates cover:
The people CRM remains outside `org-capture`: `M-x ss-crm-add` writes directly
to `~/org/areas/people/people.org`.
+Navigation history adds a small browser-style back and forward layer on top of
+mark-based movement. It records significant note jumps from the custom journal
+and MOC commands, CRM find/open commands, `org-open-at-point`, common agenda
+jumps, and `denote-open-or-create`, while ignoring ordinary cursor motion and
+same-location no-ops.
+
## Keybindings
The main bindings are:
- `C-c a` for the agenda
+- `C-c b` to move back through recorded note and mark navigation history
- `c` inside the agenda dispatcher to show the custom `Clarify items` view for
`CLARIFY` tasks
- `C-c c` for capture
+- `C-c f` to move forward again after using navigation back
- `C-c n n` to open or create a Denote note
- `C-c n l` to insert a Denote link
- `C-c n j` to open the full `~/org/journal.org` buffer
diff --git a/docs/plans/2026-04-10-navigation-history-implementation.md b/docs/plans/2026-04-10-navigation-history-implementation.md
new file mode 100644
index 0000000..0d58756
--- /dev/null
+++ b/docs/plans/2026-04-10-navigation-history-implementation.md
@@ -0,0 +1,118 @@
+# Navigation History Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Add small, browser-style back and forward navigation commands for note and mark-based movement across Org, agenda, CRM, and Denote workflows.
+
+**Architecture:** Keep the navigation state and restoration helpers in `lisp/ss-org.el`, using marker-based location records plus a simple back stack and forward stack. Record only significant jumps by wiring the repo's custom note commands and advising common built-in jump commands after they move, while skipping same-location noise and clearing forward history on fresh navigation.
+
+**Tech Stack:** Emacs Lisp, ERT, batch Emacs verification, interactive `emacs -nw` sanity check
+
+---
+
+### Task 1: Add failing navigation tests
+
+**Files:**
+- Modify: `tests/ss-capture-tests.el`
+- Modify: `lisp/ss-org.el`
+
+**Step 1: Write the failing tests**
+
+```elisp
+(ert-deftest ss-jump-back-restores-previous-location-and-enables-forward ()
+ ...)
+
+(ert-deftest ss-navigation-push-current-location-clears-forward-on-fresh-jump ()
+ ...)
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `emacs --batch -Q -L . -L lisp -l tests/ss-capture-tests.el -f ert-run-tests-batch-and-exit`
+Expected: FAIL because the navigation stack commands and helper functions do not exist yet.
+
+**Step 3: Write minimal implementation**
+
+```elisp
+(defvar ss-navigation-back-stack nil)
+(defvar ss-navigation-forward-stack nil)
+...
+```
+
+**Step 4: Run test to verify it passes**
+
+Run: `emacs --batch -Q -L . -L lisp -l tests/ss-capture-tests.el -f ert-run-tests-batch-and-exit`
+Expected: PASS for the new stack behavior tests.
+
+**Step 5: Commit**
+
+```bash
+git add tests/ss-capture-tests.el lisp/ss-org.el
+git commit -m "Add navigation history stack"
+```
+
+### Task 2: Wire note and jump commands into history
+
+**Files:**
+- Modify: `lisp/ss-org.el`
+- Modify: `lisp/ss-keys.el`
+
+**Step 1: Write the failing test**
+
+```elisp
+(ert-deftest ss-navigation-jump-wrapper-records-pre-jump-location ()
+ ...)
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `emacs --batch -Q -L . -L lisp -l tests/ss-capture-tests.el -f ert-run-tests-batch-and-exit`
+Expected: FAIL because custom navigation commands and advised jump commands do not record history yet.
+
+**Step 3: Write minimal implementation**
+
+```elisp
+(defun ss-navigation-record-before-command (&rest _)
+ ...)
+
+(advice-add 'org-open-at-point :before #'ss-navigation-record-before-command)
+```
+
+**Step 4: Run test to verify it passes**
+
+Run: `emacs --batch -Q -L . -L lisp -l tests/ss-capture-tests.el -l tests/ss-crm-tests.el -f ert-run-tests-batch-and-exit`
+Expected: PASS, with the navigation helpers loaded cleanly beside the existing CRM and capture tests.
+
+**Step 5: Commit**
+
+```bash
+git add lisp/ss-org.el lisp/ss-keys.el tests/ss-capture-tests.el
+git commit -m "Wire note jumps into navigation history"
+```
+
+### Task 3: Update docs and verify startup behavior
+
+**Files:**
+- Modify: `README.md`
+- Review: `AGENTS.md`
+
+**Step 1: Update docs**
+
+Add the new `C-c b` and `C-c f` bindings plus a short explanation of what participates in navigation history.
+
+**Step 2: Run verification**
+
+Run: `emacs --batch -Q --load ./init.el`
+Expected: PASS with the updated navigation code loaded through the normal startup path.
+
+**Step 3: Run interactive sanity check**
+
+Run: `emacs -nw`
+Expected: manual verification that MOC, journal, and note jumps can go back and forward, and that a fresh jump clears forward history.
+
+**Step 4: Commit**
+
+```bash
+git add README.md
+git commit -m "Document navigation history bindings"
+```
diff --git a/lisp/ss-keys.el b/lisp/ss-keys.el
index c83c1e5..4816eea 100644
--- a/lisp/ss-keys.el
+++ b/lisp/ss-keys.el
@@ -16,6 +16,10 @@
(when (ss-feature-enabled-p 'capture)
(global-set-key (kbd "C-c c") #'org-capture))
+ (when (ss-feature-enabled-p 'org)
+ (global-set-key (kbd "C-c b") #'ss-jump-back)
+ (global-set-key (kbd "C-c f") #'ss-jump-forward))
+
(when (and (ss-feature-enabled-p 'denote)
(fboundp 'denote-open-or-create)
(fboundp 'denote-link))
diff --git a/lisp/ss-org.el b/lisp/ss-org.el
index 793ee1d..f1a2274 100644
--- a/lisp/ss-org.el
+++ b/lisp/ss-org.el
@@ -9,6 +9,177 @@
(require 'org)
(require 'ss-core)
+(defvar ss-navigation-back-stack nil
+ "Stack of older locations for `ss-jump-back'.")
+
+(defvar ss-navigation-forward-stack nil
+ "Stack of newer locations for `ss-jump-forward'.")
+
+(defvar ss-navigation--inhibit-recording nil
+ "When non-nil, suppress navigation history recording.")
+
+(defun ss-navigation--current-location ()
+ "Return the current location as a marker-backed plist."
+ (unless (minibufferp (current-buffer))
+ (list :marker (copy-marker (point-marker))
+ :window-start
+ (when (window-live-p (selected-window))
+ (copy-marker (window-start (selected-window)))))))
+
+(defun ss-navigation--location-valid-p (location)
+ "Return non-nil when LOCATION still points to a live buffer position."
+ (when-let ((marker (plist-get location :marker)))
+ (and (markerp marker)
+ (marker-buffer marker)
+ (marker-position marker))))
+
+(defun ss-navigation--same-location-p (left right)
+ "Return non-nil when LEFT and RIGHT identify the same buffer position."
+ (and (ss-navigation--location-valid-p left)
+ (ss-navigation--location-valid-p right)
+ (eq (marker-buffer (plist-get left :marker))
+ (marker-buffer (plist-get right :marker)))
+ (= (marker-position (plist-get left :marker))
+ (marker-position (plist-get right :marker)))))
+
+(defun ss-navigation--prune-stack (stack)
+ "Return STACK with dead or duplicate locations removed."
+ (let (pruned previous)
+ (dolist (location stack (nreverse pruned))
+ (when (and (ss-navigation--location-valid-p location)
+ (not (ss-navigation--same-location-p location previous)))
+ (push location pruned)
+ (setq previous location)))))
+
+(defun ss-navigation--push-location (stack-symbol location &optional clear-forward)
+ "Push LOCATION onto STACK-SYMBOL unless it duplicates the top entry.
+When CLEAR-FORWARD is non-nil, reset `ss-navigation-forward-stack'."
+ (when (ss-navigation--location-valid-p location)
+ (set stack-symbol (ss-navigation--prune-stack (symbol-value stack-symbol)))
+ (unless (ss-navigation--same-location-p location (car (symbol-value stack-symbol)))
+ (set stack-symbol (cons location (symbol-value stack-symbol))))
+ (when clear-forward
+ (setq ss-navigation-forward-stack nil))
+ t))
+
+(defun ss-navigation-push-current-location ()
+ "Push the current location onto the back stack.
+This is for significant navigation points only and clears forward history."
+ (interactive)
+ (ss-navigation--push-location
+ 'ss-navigation-back-stack
+ (ss-navigation--current-location)
+ 'clear-forward))
+
+(defun ss-navigation--restore-location (location)
+ "Restore LOCATION, returning non-nil when successful."
+ (when (ss-navigation--location-valid-p location)
+ (let* ((marker (plist-get location :marker))
+ (buffer (marker-buffer marker))
+ (window-start (plist-get location :window-start)))
+ (switch-to-buffer buffer)
+ (goto-char marker)
+ (when (and (markerp window-start)
+ (eq (marker-buffer window-start) buffer)
+ (window-live-p (selected-window)))
+ (set-window-start (selected-window)
+ (marker-position window-start)
+ t))
+ t)))
+
+(defun ss-navigation--jump-from-stack (stack-symbol target-symbol)
+ "Restore the next location from STACK-SYMBOL and push current onto TARGET-SYMBOL."
+ (set stack-symbol (ss-navigation--prune-stack (symbol-value stack-symbol)))
+ (when-let ((target (car (symbol-value stack-symbol))))
+ (let ((current (ss-navigation--current-location)))
+ (set stack-symbol (cdr (symbol-value stack-symbol)))
+ (when (ss-navigation--location-valid-p current)
+ (ss-navigation--push-location target-symbol current))
+ (let ((ss-navigation--inhibit-recording t))
+ (ss-navigation--restore-location target)))))
+
+(defun ss-navigation--jump-via-command (command)
+ "Use COMMAND as a mark-ring fallback jump, recording forward history."
+ (when (fboundp command)
+ (let ((before (ss-navigation--current-location))
+ after)
+ (let ((ss-navigation--inhibit-recording t))
+ (condition-case nil
+ (call-interactively command)
+ (error nil)))
+ (setq after (ss-navigation--current-location))
+ (when (and (ss-navigation--location-valid-p before)
+ (ss-navigation--location-valid-p after)
+ (not (ss-navigation--same-location-p before after)))
+ (ss-navigation--push-location 'ss-navigation-forward-stack before)
+ t))))
+
+(defun ss-jump-back ()
+ "Move backward through navigation history."
+ (interactive)
+ (or (ss-navigation--jump-from-stack
+ 'ss-navigation-back-stack
+ 'ss-navigation-forward-stack)
+ (ss-navigation--jump-via-command 'pop-global-mark)
+ (ss-navigation--jump-via-command 'pop-to-mark-command)
+ (progn
+ (message "No back location available")
+ nil)))
+
+(defun ss-jump-forward ()
+ "Move forward through navigation history."
+ (interactive)
+ (or (ss-navigation--jump-from-stack
+ 'ss-navigation-forward-stack
+ 'ss-navigation-back-stack)
+ (progn
+ (message "No forward location available")
+ nil)))
+
+(defun ss-navigation--record-jump (original &rest args)
+ "Record the pre-jump location around ORIGINAL with ARGS."
+ (if ss-navigation--inhibit-recording
+ (apply original args)
+ (let ((before (ss-navigation--current-location))
+ result)
+ (setq result (apply original args))
+ (let ((after (ss-navigation--current-location)))
+ (when (and (ss-navigation--location-valid-p before)
+ (ss-navigation--location-valid-p after)
+ (not (ss-navigation--same-location-p before after)))
+ (ss-navigation--push-location
+ 'ss-navigation-back-stack
+ before
+ 'clear-forward)))
+ result)))
+
+(defun ss-navigation--advise-command (command)
+ "Wrap COMMAND so significant jumps record navigation history."
+ (unless (advice-member-p #'ss-navigation--record-jump command)
+ (advice-add command :around #'ss-navigation--record-jump)))
+
+(defun ss-navigation-setup ()
+ "Install navigation history advice for note-related jump commands."
+ (dolist (command '(ss-open-journal
+ ss-open-journal-today-session
+ ss-open-journal-full
+ ss-open-moc))
+ (ss-navigation--advise-command command))
+ (with-eval-after-load 'org
+ (ss-navigation--advise-command 'org-open-at-point))
+ (with-eval-after-load 'org-agenda
+ (dolist (command '(org-agenda-goto
+ org-agenda-switch-to
+ org-agenda-open-link))
+ (ss-navigation--advise-command command)))
+ (with-eval-after-load 'denote
+ (ss-navigation--advise-command 'denote-open-or-create))
+ (with-eval-after-load 'ss-crm
+ (dolist (command '(ss-crm-find
+ ss-crm-open
+ ss-crm-overview))
+ (ss-navigation--advise-command command))))
+
(defvar ss-journal-session-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "C-c C-c") #'ss-journal-session-save-and-dismiss)
@@ -105,7 +276,9 @@
(add-hook 'emacs-startup-hook
(lambda ()
- (find-file (ss-require-existing-file ss-moc-file)))))
+ (find-file (ss-require-existing-file ss-moc-file))))
+
+ (ss-navigation-setup))
(provide 'ss-org)
diff --git a/tests/ss-capture-tests.el b/tests/ss-capture-tests.el
index b91d4ef..006c90d 100644
--- a/tests/ss-capture-tests.el
+++ b/tests/ss-capture-tests.el
@@ -13,6 +13,65 @@
(require 'ss-capture)
(require 'ss-org)
+(ert-deftest ss-jump-back-restores-previous-location-and-enables-forward ()
+ (let ((ss-navigation-back-stack nil)
+ (ss-navigation-forward-stack nil)
+ (buffer-a (generate-new-buffer " *ss-nav-a*"))
+ (buffer-b (generate-new-buffer " *ss-nav-b*")))
+ (unwind-protect
+ (save-window-excursion
+ (with-current-buffer buffer-a
+ (insert "alpha")
+ (goto-char 3))
+ (with-current-buffer buffer-b
+ (insert "bravo")
+ (goto-char 5))
+ (switch-to-buffer buffer-a)
+ (ss-navigation-push-current-location)
+ (switch-to-buffer buffer-b)
+ (should (ss-jump-back))
+ (should (eq (current-buffer) buffer-a))
+ (should (= (point) 3))
+ (should (ss-jump-forward))
+ (should (eq (current-buffer) buffer-b))
+ (should (= (point) 5)))
+ (kill-buffer buffer-a)
+ (kill-buffer buffer-b))))
+
+(ert-deftest ss-navigation-push-current-location-clears-forward-after-back ()
+ (let ((ss-navigation-back-stack nil)
+ (ss-navigation-forward-stack nil)
+ (buffer-a (generate-new-buffer " *ss-nav-a*"))
+ (buffer-b (generate-new-buffer " *ss-nav-b*")))
+ (unwind-protect
+ (save-window-excursion
+ (with-current-buffer buffer-a
+ (insert "alpha")
+ (goto-char 2))
+ (with-current-buffer buffer-b
+ (insert "bravo")
+ (goto-char 4))
+ (switch-to-buffer buffer-a)
+ (ss-navigation-push-current-location)
+ (switch-to-buffer buffer-b)
+ (should (ss-jump-back))
+ (should ss-navigation-forward-stack)
+ (ss-navigation-push-current-location)
+ (should-not ss-navigation-forward-stack))
+ (kill-buffer buffer-a)
+ (kill-buffer buffer-b))))
+
+(ert-deftest ss-navigation-record-jump-skips-noop-movements ()
+ (let ((ss-navigation-back-stack nil)
+ (ss-navigation-forward-stack nil))
+ (with-temp-buffer
+ (switch-to-buffer (current-buffer))
+ (insert "alpha")
+ (goto-char 2)
+ (ss-navigation--record-jump (lambda () nil))
+ (should-not ss-navigation-back-stack)
+ (should-not ss-navigation-forward-stack))))
+
(ert-deftest ss-journal-ensure-day-sections-adds-all-standard-sections ()
(with-temp-buffer
(org-mode)
@@ -34,30 +93,35 @@
(ert-deftest ss-open-journal-narrows-to-today-when-entry-exists ()
(let* ((file (make-temp-file "ss-journal" nil ".org"))
- (ss-journal-file file))
+ (ss-journal-file file)
+ (test-time (encode-time 0 0 12 9 4 2026)))
(unwind-protect
- (progn
+ (cl-letf (((symbol-function 'current-time)
+ (lambda ()
+ test-time)))
(with-temp-file file
(insert "#+title: Journal\n"
"* 2026\n"
"** 2026-04-09 Thursday\n"
"*** Notes\n"))
- (let ((org-overriding-default-time (encode-time 0 0 12 9 4 2026)))
- (ss-open-journal)
- (should (buffer-narrowed-p))
- (should (equal (org-get-outline-path t)
- '("2026" "2026-04-09 Thursday")))
- (should (looking-at-p "^\\*\\* 2026-04-09 Thursday$"))))
- (when (buffer-live-p (current-buffer))
- (kill-buffer (current-buffer)))
+ (ss-open-journal)
+ (should (buffer-narrowed-p))
+ (should (equal (org-get-outline-path t)
+ '("2026" "2026-04-09 Thursday")))
+ (should (looking-at-p "^\\*\\* 2026-04-09 Thursday$")))
+ (when-let ((buffer (get-file-buffer file)))
+ (kill-buffer buffer))
(when (file-exists-p file)
(delete-file file)))))
(ert-deftest ss-open-journal-reveals-folded-today-subtree ()
(let* ((file (make-temp-file "ss-journal" nil ".org"))
- (ss-journal-file file))
+ (ss-journal-file file)
+ (test-time (encode-time 0 0 12 9 4 2026)))
(unwind-protect
- (progn
+ (cl-letf (((symbol-function 'current-time)
+ (lambda ()
+ test-time)))
(with-temp-file file
(insert "#+title: Journal\n"
"* 2026\n"
@@ -66,45 +130,46 @@
"Body\n"))
(with-current-buffer (find-file-noselect file)
(org-overview))
- (let ((org-overriding-default-time (encode-time 0 0 12 9 4 2026)))
- (ss-open-journal)
- (should (buffer-narrowed-p))
- (should-not
- (save-excursion
- (goto-char (point-min))
- (re-search-forward "^\\*\\*\\* Notes$" nil t)
- (invisible-p (point))))
- (should-not
- (save-excursion
- (goto-char (point-min))
- (re-search-forward "^Body$" nil t)
- (invisible-p (point))))))
- (when (buffer-live-p (current-buffer))
- (kill-buffer (current-buffer)))
+ (ss-open-journal)
+ (should (buffer-narrowed-p))
+ (should-not
+ (save-excursion
+ (goto-char (point-min))
+ (re-search-forward "^\\*\\*\\* Notes$" nil t)
+ (invisible-p (point))))
+ (should-not
+ (save-excursion
+ (goto-char (point-min))
+ (re-search-forward "^Body$" nil t)
+ (invisible-p (point)))))
+ (when-let ((buffer (get-file-buffer file)))
+ (kill-buffer buffer))
(when (file-exists-p file)
(delete-file file)))))
(ert-deftest ss-open-journal-creates-missing-today-entry-with-standard-sections ()
(let* ((file (make-temp-file "ss-journal" nil ".org"))
- (ss-journal-file file))
+ (ss-journal-file file)
+ (test-time (encode-time 0 0 12 9 4 2026)))
(unwind-protect
- (progn
+ (cl-letf (((symbol-function 'current-time)
+ (lambda ()
+ test-time)))
(with-temp-file file
(insert "#+title: Journal\n"
"* 2026\n"
"** 2026-04-08 Wednesday\n"
"*** Notes\n"))
- (let ((org-overriding-default-time (encode-time 0 0 12 9 4 2026)))
- (ss-open-journal)
- (should (buffer-narrowed-p))
- (should (equal (org-get-outline-path t)
- '("2026" "2026-04-09 Thursday")))
- (should (string-match-p
- (regexp-quote
- "** 2026-04-09 Thursday\n*** Tasks\n*** Notes\n*** Meetings\n")
- (buffer-string)))))
- (when (buffer-live-p (current-buffer))
- (kill-buffer (current-buffer)))
+ (ss-open-journal)
+ (should (buffer-narrowed-p))
+ (should (equal (org-get-outline-path t)
+ '("2026" "2026-04-09 Thursday")))
+ (should (string-match-p
+ (regexp-quote
+ "** 2026-04-09 Thursday\n*** Tasks\n*** Notes\n*** Meetings\n")
+ (buffer-string))))
+ (when-let ((buffer (get-file-buffer file)))
+ (kill-buffer buffer))
(when (file-exists-p file)
(delete-file file)))))