diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-07 06:43:17 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-07 06:43:17 -0500 |
| commit | e2208276021def42dadd5e422aa96e9004040dd9 (patch) | |
| tree | 1352c74e299276dce750a3e6beddf87118899b3f | |
| parent | c740e270721581120cc6e9317ab70dba40156820 (diff) | |
| download | pearl-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.el | 160 | ||||
| -rw-r--r-- | tests/test-pearl-modified.el | 60 |
2 files changed, 185 insertions, 35 deletions
@@ -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 |
