aboutsummaryrefslogtreecommitdiff
path: root/pearl.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-05 00:56:19 -0500
committerCraig Jennings <c@cjennings.net>2026-06-05 00:56:19 -0500
commit951abea0b2ed07d71676cd4d10ff7c6ca50d1390 (patch)
tree6fbe516d9b757e38a5e6e6950f6fe8e4767bf40b /pearl.el
parent387c8a9ff9828d1b024987d136d216ef12c5103f (diff)
downloadpearl-951abea0b2ed07d71676cd4d10ff7c6ca50d1390.tar.gz
pearl-951abea0b2ed07d71676cd4d10ff7c6ca50d1390.zip
feat(views): sort the active view interactively
pearl-set-sort reorders the current view by updated, created, priority, or title without hand-editing a source. pearl-toggle-sort-order flips asc/desc on the current sort, defaulting to updated descending. Both are M-x for now. The spec deferred a key binding until the menu layout settles. Priority and title sort client-side: pearl--reorder-issue-subtrees moves whole issue subtrees by LINEAR-ID, byte-for-byte, so unsaved description and comment edits survive and a non-issue heading is kept rather than dropped. Updated and created refetch with the new server orderBy. The #+LINEAR-SOURCE header is rewritten only after the reorder or refetch succeeds, so a failed sort never advertises an order the buffer doesn't show. Two things the 2026-05-24 spec predates and I had to settle. A view's :sort now holds Linear's IssueSortInput, so the interactive sort on a view persists under separate :client-sort/:client-order keys and pearl--effective-sort-order re-applies it on refresh. That keeps a view sort surviving a refresh the same way a filter sort does, rather than behaving differently on a distinction the user can't see. Grouped views aren't reorderable in place yet. They refuse with a message. A created/updated sort on a Custom View is refused too, since Linear gives views no server-side order.
Diffstat (limited to 'pearl.el')
-rw-r--r--pearl.el251
1 files changed, 240 insertions, 11 deletions
diff --git a/pearl.el b/pearl.el
index 089e4e0..cc020a8 100644
--- a/pearl.el
+++ b/pearl.el
@@ -4294,6 +4294,42 @@ produces the same heading order rather than reshuffling into noise."
`updatedAt', the only other field Linear's public ordering supports."
(if (eq sort 'created) 'createdAt 'updatedAt))
+(defconst pearl--sort-keys '(updated created priority title)
+ "The interactive sort keys. `priority'/`title' sort client-side; `updated'/
+`created' map to the server `orderBy'.")
+
+(defconst pearl--client-sort-keys '(priority title)
+ "Sort keys computed client-side from buffer state, reordering without a refetch.")
+
+(defun pearl--check-sort-order (sort order)
+ "Validate SORT and ORDER symbols; signal a `user-error' on an unknown value.
+Returns t when both are acceptable. A nil SORT or ORDER is allowed (no sort)."
+ (when (and sort (not (memq sort pearl--sort-keys)))
+ (user-error "Unknown sort key %s; use one of %s"
+ sort (mapconcat #'symbol-name pearl--sort-keys "/")))
+ (when (and order (not (memq order '(asc desc))))
+ (user-error "Unknown order %s; use asc or desc" order))
+ t)
+
+(defun pearl--source-with-sort (source sort order)
+ "Return SOURCE with the interactive SORT/ORDER recorded.
+A view stores them under `:client-sort'/`:client-order', leaving its server
+`:sort' (an `IssueSortInput') intact; every other source uses `:sort'/`:order'
+\(symbols). The split keeps the interactive reorder from clobbering a view's
+configured server order, which the refresh path feeds back to Linear."
+ (let ((s (copy-sequence source)))
+ (if (eq (plist-get source :type) 'view)
+ (plist-put (plist-put s :client-sort sort) :client-order order)
+ (plist-put (plist-put s :sort sort) :order order))))
+
+(defun pearl--effective-sort-order (source)
+ "Return (SORT . ORDER) for the client-side render sort of SOURCE.
+A view uses `:client-sort'/`:client-order' (nil until the user sorts it, so the
+view's server order is honored); every other source uses `:sort'/`:order'."
+ (if (eq (plist-get source :type) 'view)
+ (cons (plist-get source :client-sort) (plist-get source :client-order))
+ (cons (plist-get source :sort) (plist-get source :order))))
+
;;; Sentinel infrastructure for filter / pick-one prompts
;;
;; These sentinel constants and helpers back the prompt UX shared by
@@ -5418,14 +5454,15 @@ boundary shared by list-issues, the ad-hoc filter, local views, views, and
refresh."
(pcase (pearl--query-result-status result)
('ok
- (let ((issues (pearl--sort-issues
- (mapcar (lambda (n)
- (pearl--cap-issue-list-comments
- (pearl--normalize-issue n)))
- (pearl--query-result-issues result))
- (plist-get source :sort)
- (plist-get source :order)))
- (truncated (pearl--query-result-truncated-p result)))
+ (let* ((eff (pearl--effective-sort-order source))
+ (issues (pearl--sort-issues
+ (mapcar (lambda (n)
+ (pearl--cap-issue-list-comments
+ (pearl--normalize-issue n)))
+ (pearl--query-result-issues result))
+ (car eff)
+ (cdr eff)))
+ (truncated (pearl--query-result-truncated-p result)))
(condition-case err
(progn
(pearl--update-org-from-issues issues source truncated)
@@ -6553,13 +6590,14 @@ rather than dropping every issue, mirroring the non-destructive empty handling
of the replace path."
(pcase (pearl--query-result-status result)
('ok
- (let* ((issues (pearl--sort-issues
+ (let* ((eff (pearl--effective-sort-order source))
+ (issues (pearl--sort-issues
(mapcar (lambda (n)
(pearl--cap-issue-list-comments
(pearl--normalize-issue n)))
(pearl--query-result-issues result))
- (plist-get source :sort)
- (plist-get source :order)))
+ (car eff)
+ (cdr eff)))
(truncated (pearl--query-result-truncated-p result))
(counts (pearl--merge-issues-into-buffer
issues (plist-get source :group))))
@@ -6618,6 +6656,197 @@ commands. Errors if no source is recorded."
(plist-get source :show-completed))))
(_ (user-error "Unknown Linear source type: %s" (plist-get source :type)))))))
+;;; Interactive sort/order of the active view
+
+(defun pearl--write-linear-source-header (source)
+ "Replace the buffer's `#+LINEAR-SOURCE:' descriptor with SOURCE.
+Distinct from `pearl--update-source-header', which only advances the
+count/timestamp provenance and leaves the descriptor alone."
+ (save-excursion
+ (goto-char (point-min))
+ (when (re-search-forward "^#\\+LINEAR-SOURCE: .*$" nil t)
+ (replace-match (format "#+LINEAR-SOURCE: %s" (prin1-to-string source)) t t))))
+
+(defun pearl--active-source-or-error ()
+ "Return the active source descriptor, or signal a `user-error' if absent."
+ (or (pearl--read-active-source)
+ (user-error "No Linear source recorded in this file; run a query or view first")))
+
+(defun pearl--issue-sort-key (sort)
+ "Return the client-side SORT key for the issue heading at point.
+`priority' reads the heading cookie (0-4); `title' reads the stripped heading
+title downcased -- the visible title, including an unsaved local edit. Point
+must be on the issue heading."
+ (pcase sort
+ ('priority (pearl--priority-number-at-point))
+ ('title (downcase (pearl--issue-title-at-point)))
+ (_ nil)))
+
+(defun pearl--sort-subtree-entries (entries order)
+ "Sort ENTRIES, each a plist (:key :idx :text), by `:key' in ORDER.
+A nil `:key' (a non-issue / malformed subtree) always sorts last, in original
+order. The keyed sort is stable, so equal keys keep document order."
+ (let ((keyed (cl-remove-if-not (lambda (e) (plist-get e :key)) entries))
+ (unkeyed (cl-remove-if (lambda (e) (plist-get e :key)) entries)))
+ (let* ((cmp (lambda (a b)
+ (let ((ka (plist-get a :key)) (kb (plist-get b :key)))
+ (if (numberp ka)
+ (if (eq order 'asc) (< ka kb) (> ka kb))
+ (if (eq order 'asc) (string< ka kb) (string> ka kb))))))
+ (ordered (cl-stable-sort (copy-sequence keyed) cmp)))
+ (append ordered unkeyed))))
+
+(defun pearl--reorder-issue-subtrees (sort order)
+ "Reorder the active view's issue subtrees in place by SORT in ORDER.
+Moves whole subtrees byte-for-byte -- descriptions, comments, drawers, and any
+unsaved local edits survive unchanged -- rather than reconstructing them from
+parsed data. A non-issue level-2 subtree (no `LINEAR-ID') is kept and sorted
+last. Returns t on success, `grouped' when issues sit below level 2 (a grouped
+view, unsupported in v1), or `no-issues' when there are none."
+ (save-excursion
+ (let ((markers (pearl--issue-subtree-markers)))
+ (cond
+ ((null markers) 'no-issues)
+ ((let (grouped)
+ (dolist (m markers)
+ (goto-char (cdr m))
+ (unless (= (or (org-current-level) 0) 2) (setq grouped t)))
+ grouped)
+ 'grouped)
+ (t
+ ;; The issues are the direct level-2 children of the single view parent.
+ ;; Walk every level-2 subtree in that span, capture each region's text,
+ ;; sort, and rewrite the contiguous block in one delete+insert.
+ (goto-char (cdr (car markers)))
+ (org-up-heading-safe)
+ (let ((parent-end (save-excursion (org-end-of-subtree t t) (point)))
+ (children '()) (idx 0) (block-beg nil) (block-end nil))
+ (outline-next-heading)
+ (while (and (< (point) parent-end) (org-at-heading-p))
+ (if (= (or (org-current-level) 0) 2)
+ (let* ((beg (point))
+ (end (save-excursion (org-end-of-subtree t t) (point)))
+ (id (org-entry-get nil "LINEAR-ID"))
+ (key (and id (not (string-empty-p id))
+ (pearl--issue-sort-key sort)))
+ (text (buffer-substring-no-properties beg end)))
+ (unless block-beg (setq block-beg beg))
+ (setq block-end end)
+ (push (list :key key :idx idx :text text) children)
+ (setq idx (1+ idx))
+ (goto-char end))
+ (goto-char (save-excursion (org-end-of-subtree t t) (point)))))
+ (when (and block-beg block-end)
+ (let ((sorted (pearl--sort-subtree-entries (nreverse children) order)))
+ (delete-region block-beg block-end)
+ (goto-char block-beg)
+ (insert (mapconcat (lambda (c) (plist-get c :text)) sorted ""))))
+ t))))))
+
+(defun pearl--toggle-sort-target (source)
+ "Return the (SORT . ORDER) to apply when toggling SOURCE's order.
+On a filter with no sort, establishes `updated' descending; otherwise flips the
+order, keeping the sort. On a view it flips `:client-order'; with no client
+sort set it signals a `user-error', since a Custom View has no symbol order to
+toggle until you sort it."
+ (if (eq (plist-get source :type) 'view)
+ (let ((cs (plist-get source :client-sort)))
+ (unless cs
+ (user-error "Sort the view first with `pearl-set-sort'; a Custom View has no default order to toggle"))
+ (cons cs (if (eq (plist-get source :client-order) 'asc) 'desc 'asc)))
+ (let ((s (plist-get source :sort))
+ (o (plist-get source :order)))
+ (if (null s)
+ (cons 'updated 'desc)
+ (cons s (if (eq o 'asc) 'desc 'asc))))))
+
+(defun pearl--apply-server-sort (source sort order)
+ "Refetch SOURCE (a filter) with the server `orderBy' for SORT, then persist.
+On success merges the result and writes the updated `#+LINEAR-SOURCE' header; on
+an empty/failed fetch it leaves the header unchanged and reports the merge
+outcome, so a failed sort never advertises an order the buffer doesn't show."
+ (let ((buffer (current-buffer)))
+ (pearl--progress "Refetching %s ordered by %s %s..."
+ (pearl--source-name source) sort order)
+ (pearl--query-issues-async
+ (pearl--build-issue-filter (plist-get source :filter))
+ (lambda (result)
+ (when (buffer-live-p buffer)
+ (with-current-buffer buffer
+ (pcase (pearl--query-result-status result)
+ ('ok
+ (pearl--merge-query-result result source)
+ (pearl--write-linear-source-header source)
+ (message "Refetched %s ordered by %s %sending"
+ (pearl--source-name source) sort
+ (if (eq order 'asc) "asc" "desc")))
+ (_ (pearl--merge-query-result result source))))))
+ (pearl--sort->order-by sort))))
+
+(defun pearl--apply-sort (source sort order)
+ "Apply interactive SORT/ORDER to SOURCE in the current buffer.
+Priority/title reorder the fetched issues in place (`client'); updated/created
+refetch with the new server ordering (`server'), refused on a Custom View. The
+`#+LINEAR-SOURCE' header is written only after a successful reorder or refetch.
+Returns `client', `server', `grouped', or `no-issues'."
+ (pearl--check-sort-order sort order)
+ (let ((type (plist-get source :type))
+ (client (memq sort pearl--client-sort-keys))
+ (updated (pearl--source-with-sort source sort order)))
+ (cond
+ ((and (eq type 'view) (not client))
+ (user-error "Linear has no server-side ordering for Custom Views; sort by priority or title to reorder the fetched issues"))
+ (client
+ (pcase (pearl--reorder-issue-subtrees sort order)
+ ('grouped
+ (message "Can't sort a grouped view in place yet; ungroup or sort in Linear")
+ 'grouped)
+ ('no-issues
+ (message "No issues to sort in this buffer")
+ 'no-issues)
+ (_
+ (pearl--write-linear-source-header updated)
+ (message "Sorted current buffer by %s %sending"
+ sort (if (eq order 'asc) "asc" "desc"))
+ 'client)))
+ (t
+ (pearl--apply-server-sort updated sort order)
+ 'server))))
+
+;;;###autoload
+(defun pearl-set-sort (sort order)
+ "Sort the active Linear view by SORT in ORDER.
+Interactively completes the sort key (updated/created/priority/title) and the
+order (asc/desc). Priority and title reorder the fetched issues in place,
+preserving unsaved edits; updated and created refetch with the new server
+ordering -- refused on a Custom View, which Linear gives no server-side order.
+The recorded source advances only after the sort succeeds, so a refresh
+reproduces what you see."
+ (interactive
+ (list (intern (completing-read
+ "Sort by: "
+ (pearl--completion-table-keep-order
+ '("updated" "created" "priority" "title"))
+ nil t))
+ (intern (completing-read
+ "Order: "
+ (pearl--completion-table-keep-order '("desc" "asc"))
+ nil t nil nil "desc"))))
+ (pearl--require-account-context)
+ (pearl--apply-sort (pearl--active-source-or-error) sort order))
+
+;;;###autoload
+(defun pearl-toggle-sort-order ()
+ "Flip the active Linear view between ascending and descending.
+Keeps the current sort and flips the order; with no sort yet, establishes
+`updated' descending. On a Custom View it flips the in-place priority/title
+sort -- with none set, asks you to run `pearl-set-sort' first."
+ (interactive)
+ (pearl--require-account-context)
+ (let* ((source (pearl--active-source-or-error))
+ (target (pearl--toggle-sort-target source)))
+ (pearl--apply-sort source (car target) (cdr target))))
+
(defun pearl--view-node-prefs (view)
"Extract the runnable view preferences from a Custom View node VIEW.
Returns `(:sort SORT :show-completed SC)': SORT is the `IssueSortInput' for the