aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-24 17:12:52 -0500
committerCraig Jennings <c@cjennings.net>2026-05-24 17:12:52 -0500
commitbb30b0f9146722b279832a991540917c3fa3cb81 (patch)
tree01ebe73400e3aa28136764c180fe174db9900839
parent424d7048d5450131283f6bdb99822aa6bccd6b16 (diff)
downloadpearl-bb30b0f9146722b279832a991540917c3fa3cb81.tar.gz
pearl-bb30b0f9146722b279832a991540917c3fa3cb81.zip
feat: compose comments and descriptions in an Org buffer
The minibuffer is too cramped to write a real comment or description. I added a shared compose buffer: a focused Org buffer with a read-only instructional header at the top (like a git commit template) and an editable body below, where C-c C-c submits and C-c C-k cancels. It's the sibling of the smerge conflict buffer, built the same way. Two commands use it. pearl-add-comment, run interactively, now opens the composer and converts the Org body to Markdown before sending. Called with an explicit body (tests, programmatic callers), it still sends that directly. pearl-compose-current-description is new: it pops the issue's current description into the composer and, on submit, writes it back into the body and syncs through the existing conflict gate. Both work from anywhere inside an issue subtree. The header is genuinely uneditable: read-only text properties, with only the last character rear-nonsticky so the body stays editable while edits inside the header are refused. The body is everything below it, extracted by a marker. I left pearl-new-issue on the minibuffer for now. Wiring its description into the composer means restructuring that long, untested interactive flow to defer the create into the submit callback, which is worth doing on its own rather than riding along here. Filed as a follow-up.
-rw-r--r--pearl.el156
-rw-r--r--tests/test-pearl-compose.el153
2 files changed, 291 insertions, 18 deletions
diff --git a/pearl.el b/pearl.el
index b939ab3..56b6d44 100644
--- a/pearl.el
+++ b/pearl.el
@@ -2013,6 +2013,82 @@ nothing is lost."
(message "Synced merged %s to Linear" label))
(message "Failed to push merged %s" label)))))))))
+;;; Compose Buffer
+;;
+;; A focused Org buffer for composing multi-line text (comments, descriptions)
+;; that's awkward in the one-line minibuffer. A read-only instructional header
+;; sits at the top, like a git commit template; the editable body is below it.
+;; C-c C-c hands the body to an armed callback, C-c C-k aborts. The shared
+;; sibling of the smerge conflict buffer below.
+
+(defvar-local pearl--compose-on-finish nil
+ "Callback invoked with the composed Org body when a compose buffer submits.")
+
+(defvar-local pearl--compose-body-start nil
+ "Marker at the start of the editable body, just past the read-only header.")
+
+(defconst pearl--compose-comment-instructions
+ "# Write a comment below, then C-c C-c to send or C-c C-k to cancel.
+# This is Org markup; it is converted to Markdown for Linear.
+"
+ "Read-only header shown atop the comment compose buffer.")
+
+(defconst pearl--compose-description-instructions
+ "# Edit the description below, then C-c C-c to sync or C-c C-k to cancel.
+# This is Org markup; it is converted to Markdown for Linear.
+# The usual conflict check still applies on sync.
+"
+ "Read-only header shown atop the description compose buffer.")
+
+(defun pearl--compose-body ()
+ "Return the trimmed editable body of the current compose buffer.
+The text below the read-only header, from `pearl--compose-body-start' on."
+ (string-trim
+ (buffer-substring-no-properties pearl--compose-body-start (point-max))))
+
+(defun pearl--compose-submit ()
+ "Submit the compose buffer: hand the body to the armed callback, kill the buffer."
+ (interactive)
+ (let ((body (pearl--compose-body))
+ (callback pearl--compose-on-finish))
+ (kill-buffer (current-buffer))
+ (when callback (funcall callback body))))
+
+(defun pearl--compose-abort ()
+ "Abort the compose buffer without submitting."
+ (interactive)
+ (kill-buffer (current-buffer))
+ (message "Compose canceled"))
+
+(defun pearl--compose-in-buffer (label instructions initial on-finish)
+ "Pop an Org compose buffer for LABEL with a read-only INSTRUCTIONS header.
+INITIAL is the editable body (Org markup, may be empty). \\<global-map>C-c C-c
+\(`pearl--compose-submit') hands the body to ON-FINISH and kills the buffer;
+C-c C-k (`pearl--compose-abort') cancels. ON-FINISH receives the Org body and
+is responsible for any markdown conversion. The shared multi-line composer,
+sibling of `pearl--resolve-conflict-in-smerge'."
+ (let ((buf (get-buffer-create (format "*pearl-compose: %s*" label))))
+ (with-current-buffer buf
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ ;; `org-mode' clears buffer-local vars, so set ours after it
+ (org-mode)
+ (insert instructions)
+ (let ((end (point)))
+ ;; the whole header is read-only; only its last char is rear-nonsticky
+ ;; so the body inserted just after stays editable (the interior is not,
+ ;; so edits inside the header are refused)
+ (add-text-properties (point-min) end '(read-only t))
+ (add-text-properties (1- end) end '(rear-nonsticky t))
+ (setq pearl--compose-body-start (copy-marker end nil)))
+ (insert (or initial "")))
+ (setq-local pearl--compose-on-finish on-finish)
+ (local-set-key (kbd "C-c C-c") #'pearl--compose-submit)
+ (local-set-key (kbd "C-c C-k") #'pearl--compose-abort)
+ (goto-char (point-max)))
+ (pop-to-buffer buf)
+ buf))
+
(defvar-local pearl--conflict-on-finish nil
"Callback invoked with the reconciled text when a conflict buffer commits.")
@@ -2430,30 +2506,73 @@ that subtree at the end of the issue when it does not exist yet."
(insert "*** Comments\n" (pearl--format-comment comment))))))
;;;###autoload
-(defun pearl-add-comment (body)
- "Add a comment with BODY to the Linear issue at point and insert it.
-Works from anywhere inside an issue subtree. The new comment is the viewer's
-own, so it renders editable; edit it later with
-`pearl-edit-current-comment'."
- (interactive "sComment: ")
+(defun pearl--create-and-append-comment (issue-id marker body)
+ "Create a comment with BODY on ISSUE-ID, appending it at MARKER on success.
+MARKER points at the issue heading; the append, comment highlighting, and
+buffer surfacing all run in the marker's buffer, so it works even when the
+create callback fires with another buffer current (the compose path)."
+ (pearl--progress "Adding comment to %s..." issue-id)
+ (pearl--create-comment-async
+ issue-id body
+ (lambda (comment)
+ (if (null comment)
+ (message "Failed to add comment to %s" issue-id)
+ (let ((buf (marker-buffer marker)))
+ (when (buffer-live-p buf)
+ (with-current-buffer buf
+ (save-excursion
+ (goto-char marker)
+ (pearl--append-comment-to-issue comment))
+ (pearl-highlight-comments))
+ (pearl--surface-buffer buf)))
+ (message "Added comment to %s" issue-id)))))
+
+;;;###autoload
+(defun pearl-add-comment (&optional body)
+ "Add a comment to the Linear issue at point.
+Interactively, opens an Org compose buffer (C-c C-c sends, C-c C-k cancels) and
+converts the composed Org to Markdown before sending -- room to write a real
+comment instead of the one-line minibuffer. BODY, when supplied
+non-interactively, is sent as-is. Works from anywhere inside an issue subtree;
+the new comment is the viewer's own, so it renders editable."
+ (interactive (list nil))
(save-excursion
(pearl--goto-heading-or-error)
(let ((issue-id (org-entry-get nil "LINEAR-ID"))
(marker (point-marker)))
(unless issue-id
(user-error "Not on a Linear issue heading"))
- (pearl--progress "Adding comment to %s..." issue-id)
- (pearl--create-comment-async
- issue-id body
- (lambda (comment)
- (if (null comment)
- (message "Failed to add comment to %s" issue-id)
- (save-excursion
- (goto-char marker)
- (pearl--append-comment-to-issue comment))
- (pearl-highlight-comments)
- (pearl--surface-buffer (marker-buffer marker))
- (message "Added comment to %s" issue-id)))))))
+ (if body
+ (pearl--create-and-append-comment issue-id marker body)
+ (pearl--compose-in-buffer
+ (format "comment on %s" issue-id)
+ pearl--compose-comment-instructions ""
+ (lambda (org)
+ (pearl--create-and-append-comment
+ issue-id marker (pearl--org-to-md org))))))))
+
+;;;###autoload
+(defun pearl-compose-current-description ()
+ "Edit the description of the Linear issue at point in an Org compose buffer.
+Pops the current description into a focused buffer; C-c C-c writes it back into
+the issue body and syncs to Linear through the usual conflict gate, C-c C-k
+cancels. An alternative to editing the description inline for anyone who wants
+a dedicated buffer. Works from anywhere inside an issue subtree."
+ (interactive)
+ (save-excursion
+ (pearl--goto-heading-or-error)
+ (let ((issue-id (org-entry-get nil "LINEAR-ID"))
+ (marker (point-marker)))
+ (unless issue-id
+ (user-error "Not on a Linear issue heading"))
+ (pearl--compose-in-buffer
+ (format "description for %s" issue-id)
+ pearl--compose-description-instructions
+ (org-with-point-at marker (pearl--issue-body-at-point))
+ (lambda (org)
+ (org-with-point-at marker
+ (pearl--set-entry-body-at-point org)
+ (pearl-sync-current-issue)))))))
;;;###autoload
(defun pearl-open-current-issue ()
@@ -3674,6 +3793,7 @@ reported (refresh to reconcile)."
("b" "Open view in Linear" pearl-open-current-view-in-linear)]
["Issue at point"
("e" "Edit desc -> push" pearl-sync-current-issue)
+ ("D" "Compose desc -> push" pearl-compose-current-description)
("t" "Edit title -> push" pearl-sync-current-issue-title)
("s" "Set state" pearl-set-state)
("a" "Set assignee" pearl-set-assignee)
diff --git a/tests/test-pearl-compose.el b/tests/test-pearl-compose.el
new file mode 100644
index 0000000..bda75cb
--- /dev/null
+++ b/tests/test-pearl-compose.el
@@ -0,0 +1,153 @@
+;;; test-pearl-compose.el --- Tests for the compose buffer -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;; This program is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; Tests for the shared compose buffer (`pearl--compose-in-buffer' and its
+;; submit/abort/body helpers) and the two commands wired onto it:
+;; `pearl-add-comment' (interactive composer path) and
+;; `pearl-compose-current-description'. The body is extracted below a read-only
+;; header and converted Org->Markdown by the callers.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'cl-lib)
+
+(defmacro test-pearl--with-compose (initial &rest body)
+ "Open a compose buffer holding INITIAL, run BODY in it, capturing the submit.
+Binds `captured' to the value the on-finish receives (or `:none'). Stubs
+`pop-to-buffer' so the helper does not touch windows in batch."
+ (declare (indent 1))
+ `(let ((captured :none))
+ (cl-letf (((symbol-function 'pop-to-buffer) (lambda (b &rest _) b)))
+ (let ((buf (pearl--compose-in-buffer
+ "test" "# header line\n" ,initial
+ (lambda (text) (setq captured text)))))
+ (with-current-buffer buf ,@body)
+ (when (buffer-live-p buf) (kill-buffer buf))))))
+
+;;; --compose-in-buffer setup
+
+(ert-deftest test-pearl-compose-sets-up-org-buffer ()
+ "The compose buffer is an Org buffer with the header and the initial body."
+ (test-pearl--with-compose "draft text"
+ (should (eq major-mode 'org-mode))
+ (should (markerp pearl--compose-body-start))
+ (should (string-match-p "# header line" (buffer-string)))
+ (should (string= "draft text" (pearl--compose-body)))))
+
+(ert-deftest test-pearl-compose-body-excludes-header ()
+ "The body is only the text below the read-only header."
+ (test-pearl--with-compose ""
+ (goto-char (point-max))
+ (insert "the new body")
+ (should (string= "the new body" (pearl--compose-body)))))
+
+(ert-deftest test-pearl-compose-header-is-read-only ()
+ "Editing inside the header is refused."
+ (test-pearl--with-compose "body"
+ (goto-char (+ (point-min) 3))
+ (should-error (insert "x") :type 'text-read-only)))
+
+;;; submit / abort
+
+(ert-deftest test-pearl-compose-submit-hands-body-to-callback ()
+ "C-c C-c hands the trimmed body to the callback and kills the buffer."
+ (let ((captured :none) buf)
+ (cl-letf (((symbol-function 'pop-to-buffer) (lambda (b &rest _) b)))
+ (setq buf (pearl--compose-in-buffer
+ "t" "# h\n" " hello "
+ (lambda (text) (setq captured text))))
+ (with-current-buffer buf (pearl--compose-submit))
+ (should (string= "hello" captured))
+ (should-not (buffer-live-p buf)))))
+
+(ert-deftest test-pearl-compose-abort-skips-callback ()
+ "C-c C-k kills the buffer without invoking the callback."
+ (let ((called nil) buf)
+ (cl-letf (((symbol-function 'pop-to-buffer) (lambda (b &rest _) b)))
+ (setq buf (pearl--compose-in-buffer
+ "t" "# h\n" "body" (lambda (_) (setq called t))))
+ (with-current-buffer buf (pearl--compose-abort))
+ (should-not called)
+ (should-not (buffer-live-p buf)))))
+
+;;; add-comment composer wiring
+
+(defmacro test-pearl--in-issue (&rest body)
+ "Run BODY in an org buffer with point inside a Linear issue subtree."
+ (declare (indent 0))
+ `(let ((pearl-state-to-todo-mapping '(("Todo" . "TODO"))))
+ (with-temp-buffer
+ (insert "** TODO [#B] ENG-1: Issue\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\nBody.\n")
+ (org-mode)
+ (goto-char (point-min))
+ (re-search-forward "Body\\.")
+ ,@body)))
+
+(ert-deftest test-pearl-add-comment-interactive-composes-and-converts ()
+ "An interactive add-comment composes Org and sends the Markdown conversion."
+ (test-pearl--in-issue
+ (let (sent)
+ (cl-letf (((symbol-function 'pearl--compose-in-buffer)
+ (lambda (_label _instr initial on-finish)
+ ;; the composer would start empty for a new comment
+ (should (string= "" initial))
+ (funcall on-finish "see *bold* and `code`")))
+ ((symbol-function 'pearl--create-comment-async)
+ (lambda (_id body _cb) (setq sent body))))
+ (pearl-add-comment) ; interactive form: body nil
+ ;; Org markup converted to Markdown before sending
+ (should (string= "see **bold** and `code`" sent))))))
+
+(ert-deftest test-pearl-add-comment-with-body-skips-composer ()
+ "A non-interactive add-comment with BODY sends it directly, no composer."
+ (test-pearl--in-issue
+ (let ((composed nil) sent)
+ (cl-letf (((symbol-function 'pearl--compose-in-buffer)
+ (lambda (&rest _) (setq composed t)))
+ ((symbol-function 'pearl--create-comment-async)
+ (lambda (_id body _cb) (setq sent body))))
+ (pearl-add-comment "literal body")
+ (should-not composed)
+ (should (string= "literal body" sent))))))
+
+;;; compose-current-description wiring
+
+(ert-deftest test-pearl-compose-description-seeds-body-and-syncs ()
+ "The description composer seeds the current body and, on submit, writes it back and syncs."
+ (test-pearl--in-issue
+ (let (seeded synced)
+ (cl-letf (((symbol-function 'pearl--compose-in-buffer)
+ (lambda (_label _instr initial on-finish)
+ (setq seeded initial)
+ (funcall on-finish "Edited description")))
+ ((symbol-function 'pearl-sync-current-issue)
+ (lambda () (setq synced t))))
+ (pearl-compose-current-description)
+ ;; seeded with the existing body
+ (should (string= "Body." seeded))
+ ;; the edited text was written into the issue body, then synced
+ (should synced)
+ (goto-char (point-min))
+ (should (re-search-forward "Edited description" nil t))))))
+
+(provide 'test-pearl-compose)
+;;; test-pearl-compose.el ends here