diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-28 00:55:30 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-28 00:55:30 -0500 |
| commit | b75e6ecc1eb0bc16ffd7f8ca6c8a42229419c8d6 (patch) | |
| tree | 299d9102e1d9ea4154bb0056d1cb8fd9c9574789 | |
| parent | 1288c2af084fb9ce09bd8807a30291821b39f7b3 (diff) | |
| download | pearl-b75e6ecc1eb0bc16ffd7f8ca6c8a42229419c8d6.tar.gz pearl-b75e6ecc1eb0bc16ffd7f8ca6c8a42229419c8d6.zip | |
fix(filter): pin builder candidate order so [ None. ] stays at the top
Wrapping each builder prompt with `pearl--with-none` put the sentinel first in the candidate list, but the completion framework's default sort (vertico-sort-history-length-alpha, prescient, ivy's sorter, etc.) re-sorted alphabetically and any real candidate starting with a letter before "[" pushed the sentinel down. Linear's "Software Engineering" outsorted "[ None. ]", so the new pattern failed at the very first prompt.
The standard Emacs hook for "I sorted these on purpose" is a completion table that returns `(metadata (display-sort-function . identity))` for the `metadata` action. The new `pearl--completion-table-keep-order` helper does exactly that, and the five builder prompts (team, state, project, labels, assignee) now wrap their candidate lists through it. The framework keeps its default sort everywhere else. Only these five prompts opt out, in the place where author-order is meaningful.
| -rw-r--r-- | pearl.el | 38 | ||||
| -rw-r--r-- | tests/test-pearl-adhoc.el | 16 |
2 files changed, 44 insertions, 10 deletions
@@ -3359,6 +3359,19 @@ out of constraining this dimension." "Return CANDIDATES with `pearl--filter-none' prepended as the first option." (cons pearl--filter-none candidates)) +(defun pearl--completion-table-keep-order (candidates) + "Return a completion table over CANDIDATES that preserves input order. +The framework-side sort (vertico-sort-function, prescient, ivy, etc.) +re-sorts alphabetically by default, which clobbers `pearl--filter-none' +as the topmost option whenever a real candidate sorts ahead of \"[\". +Setting the table's `display-sort-function' to `identity' is the standard +Emacs hook for \"these are pre-sorted, leave them alone\"." + (lambda (string pred action) + (if (eq action 'metadata) + '(metadata (display-sort-function . identity) + (cycle-sort-function . identity)) + (complete-with-action action candidates string pred)))) + (defun pearl--read-filter-interactively () "Build a filter plist by completing over the chosen team's fetched dimensions. Picks a team first (scoping the rest), then offers open-only, state, project, @@ -3369,8 +3382,9 @@ choice; selecting it drops that dimension. The assignee prompt offers `me' (let* ((teams (pearl--all-teams)) (team-name (completing-read "Team: " - (pearl--with-none - (mapcar (lambda (tm) (cdr (assoc 'name tm))) teams)) + (pearl--completion-table-keep-order + (pearl--with-none + (mapcar (lambda (tm) (cdr (assoc 'name tm))) teams))) nil t)) (team-id (and (not (pearl--filter-none-value-p team-name)) (pearl--get-team-id-by-name team-name))) @@ -3378,16 +3392,18 @@ choice; selecting it drops that dimension. The assignee prompt offers `me' (state (and team-id (let ((s (completing-read "State: " - (pearl--with-none - (mapcar (lambda (st) (cdr (assoc 'name st))) - (pearl--team-states team-id))) + (pearl--completion-table-keep-order + (pearl--with-none + (mapcar (lambda (st) (cdr (assoc 'name st))) + (pearl--team-states team-id)))) nil t))) (unless (pearl--filter-none-value-p s) s)))) (project (and team-id (let ((p (completing-read "Project: " - (pearl--with-none - (pearl--team-collection-names 'projects team-id)) + (pearl--completion-table-keep-order + (pearl--with-none + (pearl--team-collection-names 'projects team-id))) nil t))) ;; The :project filter compiles to project.id.eq, so ;; resolve the picked name to its id before passing. @@ -3400,13 +3416,15 @@ choice; selecting it drops that dimension. The assignee prompt offers `me' (cl-remove pearl--filter-none (completing-read-multiple "Labels (comma-separated): " - (pearl--with-none - (pearl--team-collection-names 'labels team-id)) + (pearl--completion-table-keep-order + (pearl--with-none + (pearl--team-collection-names 'labels team-id))) nil t) :test #'string=))) (assignee (let ((choice (completing-read "Assignee: " - (list pearl--filter-none "me" "member") + (pearl--completion-table-keep-order + (list pearl--filter-none "me" "member")) nil t))) (pcase choice ("me" :me) diff --git a/tests/test-pearl-adhoc.el b/tests/test-pearl-adhoc.el index d8c7231..2bcc575 100644 --- a/tests/test-pearl-adhoc.el +++ b/tests/test-pearl-adhoc.el @@ -57,6 +57,22 @@ completing-read candidate list." (let ((wrapped (pearl--with-none '()))) (should (equal (list pearl--filter-none) wrapped)))) +(ert-deftest test-pearl-completion-table-keep-order-metadata-pins-identity () + "Action `metadata' returns an alist with `display-sort-function' bound to +`identity', the standard Emacs hook saying \"these are pre-sorted; don't re-sort.\"" + (let* ((tbl (pearl--completion-table-keep-order '("a" "b" "c"))) + (meta (funcall tbl "" nil 'metadata))) + (should (eq 'metadata (car meta))) + (should (eq #'identity (alist-get 'display-sort-function (cdr meta)))) + (should (eq #'identity (alist-get 'cycle-sort-function (cdr meta)))))) + +(ert-deftest test-pearl-completion-table-keep-order-delegates-all-completions () + "Non-metadata actions delegate to `complete-with-action' over the original +candidate list, so completion still works the way the framework expects." + (let ((tbl (pearl--completion-table-keep-order '("alpha" "beta" "gamma")))) + (should (equal '("alpha") (all-completions "a" tbl))) + (should (equal '("beta") (all-completions "b" tbl))))) + ;;; --assemble-filter (ert-deftest test-pearl-assemble-filter-only-set-keys () |
