From b75e6ecc1eb0bc16ffd7f8ca6c8a42229419c8d6 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 28 May 2026 00:55:30 -0500 Subject: 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. --- pearl.el | 38 ++++++++++++++++++++++++++++---------- tests/test-pearl-adhoc.el | 16 ++++++++++++++++ 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/pearl.el b/pearl.el index cb1351b..8ea24d2 100644 --- a/pearl.el +++ b/pearl.el @@ -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 () -- cgit v1.2.3