aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-07 06:43:17 -0500
committerCraig Jennings <c@cjennings.net>2026-06-07 06:43:17 -0500
commite2208276021def42dadd5e422aa96e9004040dd9 (patch)
tree1352c74e299276dce750a3e6beddf87118899b3f
parentc740e270721581120cc6e9317ab70dba40156820 (diff)
downloadpearl-e2208276021def42dadd5e422aa96e9004040dd9.tar.gz
pearl-e2208276021def42dadd5e422aa96e9004040dd9.zip
feat(render): highlight unsaved comments and update the cues live
Completes the modified-ticket indicator (docs/modified-ticket-indicator-spec.org) on top of the field highlight and count. A changed comment now gets its own highlight by ownership: your own comment (pushable) takes pearl-modified-highlight and bubbles up to the ticket heading; a non-own comment, which save skips, takes the muted pearl-modified-local; a comment seen before the viewer resolves takes the neutral pearl-modified-unknown until classification lands. The viewer is resolved only when a comment is actually dirty, so opening or editing a clean or field-only buffer makes no network call. The cues update live: a buffer-local after-change hook debounces a redecorate on an idle timer (pearl-modified-idle-delay), so an edit shows within a fraction of a second instead of waiting for a save or refresh. The timer is cancelled when pearl-mode turns off or the buffer is killed. One pearl--redecorate-modified now drives the field highlights, the comment classification, and the count from a single scan, called on mode enable, fetch/refresh, and the idle tick.
-rw-r--r--pearl.el160
-rw-r--r--tests/test-pearl-modified.el60
2 files changed, 185 insertions, 35 deletions
diff --git a/pearl.el b/pearl.el
index 63e12ef..205374b 100644
--- a/pearl.el
+++ b/pearl.el
@@ -667,11 +667,20 @@ Takes two arguments: the ticket count (%d) and a label (%s) -- the
:type 'string
:group 'pearl)
+(defcustom pearl-modified-idle-delay 0.4
+ "Idle seconds before the unsaved-ticket cues are recomputed after an edit.
+A short debounce so live typing doesn't rescan the buffer on every keystroke."
+ :type 'number
+ :group 'pearl)
+
(defvar-local pearl--modified-count 0
"Cached count of tickets in this buffer with unsaved pushable work.
Refreshed by the redecorate pass (`pearl--refresh-modified-count'); read by
`pearl--mode-line-lighter' so mode-line redisplay never rescans.")
+(defvar-local pearl--modified-timer nil
+ "Pending idle timer that redecorates this buffer's unsaved cues, or nil.")
+
(defun pearl--mode-line-lighter ()
"Return the `pearl-mode' mode-line lighter, naming the active account.
\" Pearl[work]\" when accounts are configured and one is active, plain
@@ -6630,23 +6639,6 @@ save agree."
caller (resolved once via `pearl--viewer-async')."
(pearl--count-pushable-tickets (pearl--scan-all-dirty) viewer-id viewer-failed))
-(defun pearl--refresh-modified-count (&optional buffer)
- "Recompute BUFFER's cached unsaved-ticket count and refresh the mode line.
-Resolves the viewer once (cached) so own comments are classified; a failed
-lookup counts only field-dirty tickets. Best-effort: a viewer failure must
-never abort the operation that triggered the redecorate."
- (let ((buffer (or buffer (current-buffer))))
- (ignore-errors
- (pearl--viewer-async
- (lambda (viewer)
- (when (buffer-live-p buffer)
- (with-current-buffer buffer
- (setq pearl--modified-count
- (pearl--buffer-dirty-issue-count
- (and viewer (plist-get viewer :id))
- (null viewer)))
- (force-mode-line-update))))))))
-
(defun pearl--issue-description-region ()
"Return (BEG . END) of the description body of the issue at point, or nil.
The body runs from after the property drawer to the first child heading
@@ -6684,6 +6676,23 @@ dirty or an own comment is (VIEWER-ID classifies, VIEWER-FAILED forces unknown);
(when (plist-get dirty :description) (push 'description regions))
(nreverse regions)))
+(defun pearl--comment-region ()
+ "Return (BEG . END) spanning the comment subtree at point (heading + body)."
+ (save-excursion
+ (org-back-to-heading t)
+ (cons (line-beginning-position)
+ (save-excursion (org-end-of-subtree t t) (point)))))
+
+(defun pearl--comment-highlight-face (candidate viewer-id &optional viewer-failed)
+ "Highlight face for a changed comment CANDIDATE given VIEWER-ID.
+Own (pushable) gets `pearl-modified-highlight'; a non-own comment gets the muted
+`pearl-modified-local'; an unresolved viewer (nil VIEWER-ID or VIEWER-FAILED)
+gets the neutral `pearl-modified-unknown' until classification succeeds."
+ (cond ((or viewer-failed (null viewer-id)) 'pearl-modified-unknown)
+ ((pearl--comment-editable-p (plist-get candidate :author-id) viewer-id)
+ 'pearl-modified-highlight)
+ (t 'pearl-modified-local)))
+
(defun pearl--modified-overlay (beg end face)
"Lay a `pearl-modified' overlay with FACE over BEG..END and return it."
(let ((ov (make-overlay beg end)))
@@ -6696,17 +6705,18 @@ dirty or an own comment is (VIEWER-ID classifies, VIEWER-FAILED forces unknown);
"Overlay `pearl-modified-highlight' on the unsaved fields of each issue.
Clears prior `pearl-modified' overlays first, so it's idempotent and a saved
field's highlight drops on the next pass. Highlights the heading line of any
-pushable-dirty ticket (fold-visible bubble-up) and the description body when the
-description changed. VIEWER-ID classifies own comments for the bubble-up;
-per-comment highlighting is a later phase. No-op when
-`pearl-show-modified-indicator' is nil."
+pushable-dirty ticket (fold-visible bubble-up), the description body when the
+description changed, and each changed comment with a face by ownership
+(`pearl--comment-highlight-face'). VIEWER-ID classifies own comments (for the
+bubble-up and the comment face); an unresolved viewer leaves comments neutral.
+No-op when `pearl-show-modified-indicator' is nil."
(remove-overlays (point-min) (point-max) 'pearl-modified t)
(when pearl-show-modified-indicator
(save-excursion
(dolist (cell (pearl--issue-subtree-markers))
(org-with-point-at (cdr cell)
- (let ((regions (pearl--modified-highlight-regions
- (pearl--issue-dirty-fields) viewer-id viewer-failed)))
+ (let* ((dirty (pearl--issue-dirty-fields))
+ (regions (pearl--modified-highlight-regions dirty viewer-id viewer-failed)))
(when (memq 'heading regions)
(pearl--modified-overlay (line-beginning-position) (line-end-position)
'pearl-modified-highlight))
@@ -6714,7 +6724,67 @@ per-comment highlighting is a later phase. No-op when
(let ((r (pearl--issue-description-region)))
(when r
(pearl--modified-overlay (car r) (cdr r)
- 'pearl-modified-highlight))))))))))
+ 'pearl-modified-highlight))))
+ (dolist (cand (plist-get dirty :comment-candidates))
+ (let ((m (plist-get cand :marker)))
+ (when (markerp m)
+ (org-with-point-at m
+ (let ((cr (pearl--comment-region)))
+ (pearl--modified-overlay
+ (car cr) (cdr cr)
+ (pearl--comment-highlight-face cand viewer-id viewer-failed)))))))))))))
+
+(defun pearl--redecorate-modified (&optional buffer)
+ "Re-lay BUFFER's unsaved-field highlights and refresh its count.
+Lays the field highlights immediately (no viewer needed). The viewer is
+resolved only when the buffer actually has a dirty comment to classify -- a
+clean or field-only buffer never makes a network call, so merely opening a
+Linear file costs nothing. When a comment is dirty, the viewer (cached)
+classifies it and own-comment bubble-up, refreshing the count and mode line.
+Overlays don't change buffer text, so this never re-triggers the after-change
+hook."
+ (let ((buffer (or buffer (current-buffer))))
+ (when (buffer-live-p buffer)
+ (with-current-buffer buffer
+ (let* ((scan (pearl--scan-all-dirty))
+ (has-comments (cl-some (lambda (c) (plist-get (cdr c) :comment-candidates))
+ scan)))
+ (pearl--apply-modified-highlights) ; fields now; comments -> unknown
+ (if (not has-comments)
+ (progn
+ (setq pearl--modified-count (pearl--count-pushable-tickets scan nil))
+ (force-mode-line-update))
+ (ignore-errors
+ (pearl--viewer-async
+ (lambda (viewer)
+ (when (buffer-live-p buffer)
+ (with-current-buffer buffer
+ (let ((vid (and viewer (plist-get viewer :id))))
+ (pearl--apply-modified-highlights vid (null viewer))
+ (setq pearl--modified-count
+ (pearl--count-pushable-tickets scan vid (null viewer)))
+ (force-mode-line-update)))))))))))))
+
+(defun pearl--modified-after-change (&rest _)
+ "Debounce a redecorate of the unsaved cues after a buffer edit.
+Scheduled buffer-locally on `after-change-functions' by `pearl-mode'."
+ (when pearl-show-modified-indicator
+ (when (timerp pearl--modified-timer) (cancel-timer pearl--modified-timer))
+ (let ((buffer (current-buffer)))
+ (setq pearl--modified-timer
+ (run-with-idle-timer
+ pearl-modified-idle-delay nil
+ (lambda ()
+ (when (buffer-live-p buffer)
+ (with-current-buffer buffer
+ (setq pearl--modified-timer nil)
+ (pearl--redecorate-modified)))))))))
+
+(defun pearl--modified-cleanup ()
+ "Cancel this buffer's pending unsaved-cue idle timer (on mode off / kill)."
+ (when (timerp pearl--modified-timer)
+ (cancel-timer pearl--modified-timer)
+ (setq pearl--modified-timer nil)))
(defun pearl--save-all-counts (scan viewer-id &optional viewer-failed)
"Tally the dirty fields in SCAN, classifying comments with VIEWER-ID.
@@ -7938,6 +8008,19 @@ stays legible as a background under the field's own text. Chosen over
`diff-refine-changed', which some themes render too bright to read against."
:group 'pearl)
+(defface pearl-modified-local
+ '((t :inherit secondary-selection :extend t))
+ "Highlight for a locally-edited comment that cannot be pushed.
+A non-own (bot / external) comment edit is local drift `pearl-save-*' will skip,
+so it reads differently from the pushable `pearl-modified-highlight'."
+ :group 'pearl)
+
+(defface pearl-modified-unknown
+ '((t :inherit pearl-modified-local))
+ "Highlight for a changed comment whose ownership isn't resolved yet.
+Shown until the async viewer lookup classifies it as own or read-only."
+ :group 'pearl)
+
(defface pearl-editable-comment
'((t :inherit success))
"Face for comment headings the current user can edit."
@@ -8115,9 +8198,9 @@ invoke by hand."
;; coloring but must show even when the viewer can't be resolved (offline,
;; missing key).
(pearl--apply-heading-glyphs buffer)
- ;; Field-level unsaved highlights need no viewer, so lay them synchronously
- ;; (own-comment bubble-up and per-comment highlights come with the viewer).
- (with-current-buffer buffer (pearl--apply-modified-highlights))
+ ;; Unsaved-field highlights + the mode-line count; resolves the viewer only
+ ;; when a comment is actually dirty (no network on a clean buffer).
+ (pearl--redecorate-modified buffer)
;; Best-effort: highlighting is a display nicety and must never abort the
;; operation that triggered it (e.g. a missing API key errors in the
;; request layer), so a failure to resolve the viewer just skips coloring.
@@ -8126,8 +8209,7 @@ invoke by hand."
(lambda (viewer)
(when (and viewer (buffer-live-p buffer))
(with-current-buffer buffer
- (pearl--apply-comment-highlights (plist-get viewer :id)))))))
- (pearl--refresh-modified-count buffer)))
+ (pearl--apply-comment-highlights (plist-get viewer :id)))))))))
;;;###autoload
(defun pearl-edit-current-comment ()
@@ -8452,14 +8534,22 @@ reachable from the keyboard. Turns on automatically in any buffer Pearl
rendered (one carrying a `#+LINEAR-SOURCE' header); see
`pearl--maybe-enable-mode' on `org-mode-hook'.
On enable it also collapses the `#+' keyword preamble (see
-`pearl--hide-preamble') and overlays the heading content glyphs (see
-`pearl--apply-heading-glyphs'), so opening a Linear file decorates it the same
-way a fresh fetch does."
+`pearl--hide-preamble'), overlays the heading content glyphs (see
+`pearl--apply-heading-glyphs'), and lays the unsaved-ticket cues (see
+`pearl--redecorate-modified'), so opening a Linear file decorates it the same
+way a fresh fetch does. A buffer-local `after-change-functions' hook keeps the
+cues live during editing; it and its idle timer are torn down on disable / kill."
:lighter (:eval (pearl--mode-line-lighter))
:keymap pearl-mode-map
- (when pearl-mode
- (pearl--hide-preamble)
- (pearl--apply-heading-glyphs)))
+ (if pearl-mode
+ (progn
+ (pearl--hide-preamble)
+ (pearl--apply-heading-glyphs)
+ (pearl--redecorate-modified)
+ (add-hook 'after-change-functions #'pearl--modified-after-change nil t)
+ (add-hook 'kill-buffer-hook #'pearl--modified-cleanup nil t))
+ (pearl--modified-cleanup)
+ (remove-hook 'after-change-functions #'pearl--modified-after-change t)))
(defun pearl--buffer-is-pearl-p ()
"Non-nil when the current buffer is a Pearl-rendered Linear file.
diff --git a/tests/test-pearl-modified.el b/tests/test-pearl-modified.el
index cfef923..8529b07 100644
--- a/tests/test-pearl-modified.el
+++ b/tests/test-pearl-modified.el
@@ -171,5 +171,65 @@
(should (null (seq-filter (lambda (o) (overlay-get o 'pearl-modified))
(overlays-in (point-min) (point-max)))))))
+;;; phase 4 -- per-comment highlight by ownership
+
+(ert-deftest test-pearl-comment-highlight-face-own-is-pushable ()
+ (should (eq 'pearl-modified-highlight
+ (pearl--comment-highlight-face '(:author-id "v") "v"))))
+
+(ert-deftest test-pearl-comment-highlight-face-non-own-is-local ()
+ (should (eq 'pearl-modified-local
+ (pearl--comment-highlight-face '(:author-id "other") "v"))))
+
+(ert-deftest test-pearl-comment-highlight-face-unresolved-viewer-is-unknown ()
+ (should (eq 'pearl-modified-unknown
+ (pearl--comment-highlight-face '(:author-id "v") nil)))
+ (should (eq 'pearl-modified-unknown
+ (pearl--comment-highlight-face '(:author-id "v") "v" t))))
+
+(ert-deftest test-pearl-comment-region-spans-the-comment-subtree ()
+ (with-temp-buffer
+ (insert "* F\n** TODO PEARL-1 Alpha\n:PROPERTIES:\n:LINEAR-ID: i1\n:END:\n"
+ "*** Comments\n**** Comment by X\n:PROPERTIES:\n:LINEAR-COMMENT-ID: c1\n:END:\n"
+ "the comment body\n")
+ (org-mode)
+ (goto-char (point-min)) (re-search-forward "Comment by X") (org-back-to-heading t)
+ (let ((text (let ((r (pearl--comment-region)))
+ (buffer-substring-no-properties (car r) (cdr r)))))
+ (should (string-match-p "Comment by X" text))
+ (should (string-match-p "the comment body" text)))))
+
+;;; phase 3 -- live after-change trigger
+
+(ert-deftest test-pearl-modified-after-change-schedules-timer ()
+ "An edit schedules a debounced redecorate timer when the indicator is on."
+ (with-temp-buffer
+ (let ((pearl-show-modified-indicator t)
+ (pearl--modified-timer nil))
+ (pearl--modified-after-change)
+ (should (timerp pearl--modified-timer))
+ (pearl--modified-cleanup)
+ (should (null pearl--modified-timer)))))
+
+(ert-deftest test-pearl-modified-after-change-off-schedules-nothing ()
+ "With the indicator disabled, an edit schedules no timer."
+ (with-temp-buffer
+ (let ((pearl-show-modified-indicator nil)
+ (pearl--modified-timer nil))
+ (pearl--modified-after-change)
+ (should (null pearl--modified-timer)))))
+
+(ert-deftest test-pearl-modified-after-change-cancels-prior-timer ()
+ "A second edit replaces the pending timer rather than leaking it."
+ (with-temp-buffer
+ (let ((pearl-show-modified-indicator t)
+ (pearl--modified-timer nil))
+ (pearl--modified-after-change)
+ (let ((first pearl--modified-timer))
+ (pearl--modified-after-change)
+ (should-not (eq first pearl--modified-timer))
+ (should (timerp pearl--modified-timer))
+ (pearl--modified-cleanup)))))
+
(provide 'test-pearl-modified)
;;; test-pearl-modified.el ends here