aboutsummaryrefslogtreecommitdiff
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
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.
-rw-r--r--README.org6
-rw-r--r--pearl.el251
-rw-r--r--tests/test-pearl-sort.el294
3 files changed, 540 insertions, 11 deletions
diff --git a/README.org b/README.org
index 443b217..129ba99 100644
--- a/README.org
+++ b/README.org
@@ -295,6 +295,12 @@ Sorting is local and deterministic. Query filters are AND-only; use a Linear Cus
=pearl-refresh-current-view= merges the fresh fetch into the buffer by issue id rather than rebuilding it: issues update in place, new matches are appended, and ones no longer in the result are dropped. Your view survives: point and the fold state of every untouched issue stay put, and only the subtrees that actually changed get re-folded. Editing an issue and refreshing won't collapse the subtree you were working in or scroll you back to the top.
+*** Sorting the current view
+
+=pearl-set-sort= reorders the active view without hand-editing a source. It asks for a key (=updated=, =created=, =priority=, =title=) and an order (=desc= / =asc=). =pearl-toggle-sort-order= flips the order on the current sort, defaulting to =updated= descending when nothing is set yet.
+
+Priority and title sort the fetched issues in place: whole subtrees move by id, so unsaved edits survive, and the order sticks across a refresh. Updated and created refetch with the new server ordering. A Custom View has no server-side order in Linear's API, so =updated= / =created= are refused on a view -- sort it by priority or title instead, which still reorders the issues it returned. Sorting a grouped view in place isn't supported yet. Both commands are =M-x= for now. A key binding lands once the menu layout settles.
+
*** Editing issues
Pearl has one write path. Edit an issue however you like in the buffer, then save it -- Pearl diffs each field against what it last fetched and pushes only what changed. Nothing pushes the moment you pick a value, and there is no per-field "push" command to remember. All issue commands work from anywhere inside an issue subtree.
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
diff --git a/tests/test-pearl-sort.el b/tests/test-pearl-sort.el
new file mode 100644
index 0000000..6527989
--- /dev/null
+++ b/tests/test-pearl-sort.el
@@ -0,0 +1,294 @@
+;;; test-pearl-sort.el --- Tests for interactive sort/order -*- 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 changing the order of the active view interactively:
+;; the source-plist helpers (symbol :sort/:order for filters,
+;; :client-sort/:client-order for views), the #+LINEAR-SOURCE header
+;; rewrite, the byte-for-byte client-side subtree reorder (preserving
+;; local edits), and the pearl--apply-sort routing (client vs server vs
+;; refuse) plus the toggle target.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'cl-lib)
+
+(defmacro test-pearl-sort--in-org (content &rest body)
+ "Run BODY in an org-mode temp buffer holding CONTENT."
+ (declare (indent 1))
+ `(let ((org-todo-keywords '((sequence "TODO" "IN-PROGRESS" "|" "DONE"))))
+ (with-temp-buffer
+ (insert ,content)
+ (org-mode)
+ (goto-char (point-min))
+ ,@body)))
+
+(defconst test-pearl-sort--flat
+ (concat
+ "#+title: Linear — My open issues\n"
+ "#+STARTUP: show2levels\n"
+ "#+TODO: TODO | DONE\n"
+ "#+LINEAR-SOURCE: (:type filter :name \"My open issues\" :filter (:open t))\n"
+ "#+LINEAR-COUNT: 2\n\n"
+ "* My open issues\n"
+ "** TODO [#C] SE-2: Beta\n:PROPERTIES:\n:LINEAR-ID: i2\n:LINEAR-IDENTIFIER: SE-2\n:END:\n"
+ "Body beta.\n"
+ "** TODO [#A] SE-1: Alpha\n:PROPERTIES:\n:LINEAR-ID: i1\n:LINEAR-IDENTIFIER: SE-1\n:END:\n"
+ "Body alpha.\n")
+ "A flat two-issue buffer: SE-2 [#C] Beta first, SE-1 [#A] Alpha second.")
+
+(defun test-pearl-sort--issue-ids ()
+ "Return the LINEAR-IDs of the level-2 issue headings in document order."
+ (let (ids)
+ (save-excursion
+ (goto-char (point-min))
+ (while (re-search-forward "^\\*\\* " nil t)
+ (let ((id (org-entry-get nil "LINEAR-ID")))
+ (when id (push id ids)))))
+ (nreverse ids)))
+
+;;; source-plist helpers
+
+(ert-deftest test-pearl-source-with-sort-filter-uses-sort-keys ()
+ "A filter source records the interactive sort under :sort/:order (symbols)."
+ (let ((out (pearl--source-with-sort '(:type filter :name "x") 'priority 'asc)))
+ (should (eq (plist-get out :sort) 'priority))
+ (should (eq (plist-get out :order) 'asc))))
+
+(ert-deftest test-pearl-source-with-sort-view-uses-client-keys ()
+ "A view source records the interactive sort under :client-sort/:client-order,
+leaving its server :sort (an IssueSortInput) untouched."
+ (let ((out (pearl--source-with-sort
+ '(:type view :name "v" :id "vid" :sort ((:priority "Ascending")))
+ 'title 'desc)))
+ (should (eq (plist-get out :client-sort) 'title))
+ (should (eq (plist-get out :client-order) 'desc))
+ (should (equal (plist-get out :sort) '((:priority "Ascending"))))))
+
+(ert-deftest test-pearl-effective-sort-order-filter ()
+ "Effective client sort for a filter is its :sort/:order."
+ (should (equal (pearl--effective-sort-order '(:type filter :sort priority :order asc))
+ '(priority . asc))))
+
+(ert-deftest test-pearl-effective-sort-order-view-with-client ()
+ "Effective client sort for a view is its :client-sort/:client-order."
+ (should (equal (pearl--effective-sort-order
+ '(:type view :sort ((:x "y")) :client-sort title :client-order desc))
+ '(title . desc))))
+
+(ert-deftest test-pearl-effective-sort-order-view-without-client ()
+ "A view with no client sort has no effective client sort (server order honored)."
+ (should (equal (pearl--effective-sort-order '(:type view :sort ((:x "y"))))
+ '(nil . nil))))
+
+;;; symbol contract
+
+(ert-deftest test-pearl-check-sort-order-valid ()
+ "Valid sort/order symbols pass."
+ (should (pearl--check-sort-order 'priority 'asc))
+ (should (pearl--check-sort-order 'updated 'desc))
+ (should (pearl--check-sort-order nil nil)))
+
+(ert-deftest test-pearl-check-sort-order-unknown-sort-errors ()
+ "An unknown sort symbol signals a user-error."
+ (should-error (pearl--check-sort-order 'sideways 'asc) :type 'user-error))
+
+(ert-deftest test-pearl-check-sort-order-unknown-order-errors ()
+ "An unknown order symbol signals a user-error."
+ (should-error (pearl--check-sort-order 'priority 'upward) :type 'user-error))
+
+;;; header rewrite
+
+(ert-deftest test-pearl-write-linear-source-header-roundtrips ()
+ "Writing a new source header is read back by `pearl--read-active-source'."
+ (test-pearl-sort--in-org test-pearl-sort--flat
+ (pearl--write-linear-source-header
+ '(:type filter :name "My open issues" :filter (:open t)
+ :sort priority :order asc))
+ (let ((src (pearl--read-active-source)))
+ (should (eq (plist-get src :sort) 'priority))
+ (should (eq (plist-get src :order) 'asc)))))
+
+;;; client-side reorder
+
+(ert-deftest test-pearl-reorder-priority-ascending ()
+ "Priority-ascending reorder puts the [#A] issue before the [#C] issue."
+ (test-pearl-sort--in-org test-pearl-sort--flat
+ (should (eq (pearl--reorder-issue-subtrees 'priority 'asc) t))
+ (should (equal (test-pearl-sort--issue-ids) '("i1" "i2")))))
+
+(ert-deftest test-pearl-reorder-priority-descending ()
+ "Priority-descending reorder keeps the [#C] issue before the [#A] issue."
+ (test-pearl-sort--in-org test-pearl-sort--flat
+ (should (eq (pearl--reorder-issue-subtrees 'priority 'desc) t))
+ (should (equal (test-pearl-sort--issue-ids) '("i2" "i1")))))
+
+(ert-deftest test-pearl-reorder-title-ascending ()
+ "Title-ascending reorder sorts Alpha before Beta."
+ (test-pearl-sort--in-org test-pearl-sort--flat
+ (should (eq (pearl--reorder-issue-subtrees 'title 'asc) t))
+ (should (equal (test-pearl-sort--issue-ids) '("i1" "i2")))))
+
+(ert-deftest test-pearl-reorder-preserves-local-edits ()
+ "Reorder moves whole subtrees byte-for-byte, keeping an unsaved body edit."
+ (test-pearl-sort--in-org test-pearl-sort--flat
+ ;; Edit SE-1's body in place before sorting.
+ (goto-char (point-min))
+ (search-forward "Body alpha.")
+ (replace-match "Body alpha EDITED LOCALLY.")
+ (should (eq (pearl--reorder-issue-subtrees 'priority 'asc) t))
+ (should (equal (test-pearl-sort--issue-ids) '("i1" "i2")))
+ (goto-char (point-min))
+ (should (search-forward "Body alpha EDITED LOCALLY." nil t))))
+
+(ert-deftest test-pearl-reorder-grouped-refuses ()
+ "A grouped buffer (issues below level 2) refuses with 'grouped, unchanged."
+ (test-pearl-sort--in-org
+ (concat
+ "#+LINEAR-SOURCE: (:type view :name \"v\" :id \"vid\")\n\n"
+ "* v\n"
+ "** Group One\n"
+ "*** TODO [#C] SE-2: Beta\n:PROPERTIES:\n:LINEAR-ID: i2\n:END:\n"
+ "*** TODO [#A] SE-1: Alpha\n:PROPERTIES:\n:LINEAR-ID: i1\n:END:\n")
+ (let ((before (buffer-string)))
+ (should (eq (pearl--reorder-issue-subtrees 'priority 'asc) 'grouped))
+ (should (equal (buffer-string) before)))))
+
+(ert-deftest test-pearl-reorder-no-issues ()
+ "A buffer with no issue headings returns 'no-issues."
+ (test-pearl-sort--in-org "* Nothing here\nplain text\n"
+ (should (eq (pearl--reorder-issue-subtrees 'priority 'asc) 'no-issues))))
+
+(ert-deftest test-pearl-reorder-keeps-non-issue-subtree-last ()
+ "A level-2 heading with no LINEAR-ID is kept (sorted last), not dropped."
+ (test-pearl-sort--in-org
+ (concat
+ "#+LINEAR-SOURCE: (:type filter :name \"x\" :filter nil)\n\n"
+ "* x\n"
+ "** TODO [#C] SE-2: Beta\n:PROPERTIES:\n:LINEAR-ID: i2\n:LINEAR-IDENTIFIER: SE-2\n:END:\n"
+ "** A stray local heading\nsome notes\n"
+ "** TODO [#A] SE-1: Alpha\n:PROPERTIES:\n:LINEAR-ID: i1\n:LINEAR-IDENTIFIER: SE-1\n:END:\n")
+ (should (eq (pearl--reorder-issue-subtrees 'priority 'asc) t))
+ (should (equal (test-pearl-sort--issue-ids) '("i1" "i2")))
+ (goto-char (point-min))
+ (should (search-forward "A stray local heading" nil t))))
+
+;;; toggle target
+
+(ert-deftest test-pearl-toggle-target-filter-no-sort-defaults ()
+ "Toggle on a filter with no sort establishes updated/desc."
+ (should (equal (pearl--toggle-sort-target '(:type filter))
+ '(updated . desc))))
+
+(ert-deftest test-pearl-toggle-target-filter-flips-order ()
+ "Toggle on a filter flips the current order, keeping the sort."
+ (should (equal (pearl--toggle-sort-target '(:type filter :sort priority :order asc))
+ '(priority . desc))))
+
+(ert-deftest test-pearl-toggle-target-view-needs-client-sort ()
+ "Toggle on a view with no client sort refuses (nothing to flip)."
+ (should-error (pearl--toggle-sort-target '(:type view :sort ((:x "y"))))
+ :type 'user-error))
+
+(ert-deftest test-pearl-toggle-target-view-flips-client-order ()
+ "Toggle on a view flips the client order."
+ (should (equal (pearl--toggle-sort-target
+ '(:type view :client-sort title :client-order desc))
+ '(title . asc))))
+
+;;; apply-sort routing
+
+(ert-deftest test-pearl-apply-sort-view-server-key-refuses ()
+ "A created/updated sort on a view is refused (Linear has no view orderBy)."
+ (test-pearl-sort--in-org test-pearl-sort--flat
+ (should-error (pearl--apply-sort '(:type view :name "v" :id "vid") 'updated 'desc)
+ :type 'user-error)))
+
+(ert-deftest test-pearl-apply-sort-filter-client-key-reorders-and-persists ()
+ "A priority sort on a filter reorders in place and writes :sort to the header."
+ (test-pearl-sort--in-org test-pearl-sort--flat
+ (should (eq (pearl--apply-sort
+ '(:type filter :name "My open issues" :filter (:open t))
+ 'priority 'asc)
+ 'client))
+ (should (equal (test-pearl-sort--issue-ids) '("i1" "i2")))
+ (should (eq (plist-get (pearl--read-active-source) :sort) 'priority))))
+
+(ert-deftest test-pearl-apply-sort-view-client-key-persists-client-keys ()
+ "A priority sort on a view reorders and writes :client-sort, not :sort."
+ (test-pearl-sort--in-org
+ (concat
+ "#+LINEAR-SOURCE: (:type view :name \"v\" :id \"vid\" :sort ((:priority \"Ascending\")))\n\n"
+ "* v\n"
+ "** TODO [#C] SE-2: Beta\n:PROPERTIES:\n:LINEAR-ID: i2\n:LINEAR-IDENTIFIER: SE-2\n:END:\n"
+ "** TODO [#A] SE-1: Alpha\n:PROPERTIES:\n:LINEAR-ID: i1\n:LINEAR-IDENTIFIER: SE-1\n:END:\n")
+ (should (eq (pearl--apply-sort
+ (pearl--read-active-source) 'priority 'asc)
+ 'client))
+ (should (equal (test-pearl-sort--issue-ids) '("i1" "i2")))
+ (let ((src (pearl--read-active-source)))
+ (should (eq (plist-get src :client-sort) 'priority))
+ (should (equal (plist-get src :sort) '((:priority "Ascending")))))))
+
+(ert-deftest test-pearl-apply-sort-grouped-refuses-no-header-write ()
+ "A grouped buffer refuses the client reorder and leaves the header unchanged."
+ (test-pearl-sort--in-org
+ (concat
+ "#+LINEAR-SOURCE: (:type filter :name \"x\" :filter nil)\n\n"
+ "* x\n"
+ "** Group\n"
+ "*** TODO [#A] SE-1: Alpha\n:PROPERTIES:\n:LINEAR-ID: i1\n:END:\n")
+ (should (eq (pearl--apply-sort '(:type filter :name "x" :filter nil) 'priority 'asc)
+ 'grouped))
+ (should (null (plist-get (pearl--read-active-source) :sort)))))
+
+;;; server-side refetch persistence (HP3)
+
+(ert-deftest test-pearl-apply-server-sort-ok-writes-header ()
+ "A successful server refetch writes the updated sort into #+LINEAR-SOURCE."
+ (test-pearl-sort--in-org test-pearl-sort--flat
+ (cl-letf (((symbol-function 'pearl--query-issues-async)
+ (lambda (_filter callback &optional _order-by)
+ (funcall callback '(:status ok :issues nil :truncated nil))))
+ ((symbol-function 'pearl--merge-query-result) (lambda (&rest _) nil))
+ ((symbol-function 'pearl--progress) (lambda (&rest _) nil)))
+ (pearl--apply-server-sort
+ '(:type filter :name "My open issues" :filter (:open t) :sort updated :order asc)
+ 'updated 'asc)
+ (let ((src (pearl--read-active-source)))
+ (should (eq (plist-get src :sort) 'updated))
+ (should (eq (plist-get src :order) 'asc))))))
+
+(ert-deftest test-pearl-apply-server-sort-failure-keeps-header ()
+ "A failed server refetch leaves the #+LINEAR-SOURCE header unchanged (HP3)."
+ (test-pearl-sort--in-org test-pearl-sort--flat
+ (cl-letf (((symbol-function 'pearl--query-issues-async)
+ (lambda (_filter callback &optional _order-by)
+ (funcall callback '(:status error :message "boom"))))
+ ((symbol-function 'pearl--merge-query-result) (lambda (&rest _) nil))
+ ((symbol-function 'pearl--progress) (lambda (&rest _) nil)))
+ (pearl--apply-server-sort
+ '(:type filter :name "My open issues" :filter (:open t) :sort updated :order asc)
+ 'updated 'asc)
+ (should (null (plist-get (pearl--read-active-source) :sort))))))
+
+(provide 'test-pearl-sort)
+;;; test-pearl-sort.el ends here