From 951abea0b2ed07d71676cd4d10ff7c6ca50d1390 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Fri, 5 Jun 2026 00:56:19 -0500 Subject: 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. --- pearl.el | 251 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 240 insertions(+), 11 deletions(-) (limited to 'pearl.el') 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 -- cgit v1.2.3