aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-01 18:00:58 -0500
committerCraig Jennings <c@cjennings.net>2026-06-01 18:00:58 -0500
commit49e79a262638ca5fb673ac3f5d8e9cde0cb7905a (patch)
treed46f3084a8bda7e9095bb5a7b01b8262d7e60c40
parentad1e0b8c33f909814b69458eb34cc792f032e844 (diff)
downloadpearl-49e79a262638ca5fb673ac3f5d8e9cde0cb7905a.tar.gz
pearl-49e79a262638ca5fb673ac3f5d8e9cde0cb7905a.zip
feat(views): rework the picker for local/Linear labels, dedup, and local dispatch
I reworked pearl--pick-source-candidates for the three Phase 4 behaviors. Labels now read [local] for a private local view, [local -> Linear:SCOPE] for a published one, [linear] for a Linear view favorite, and [KIND] for a non-view favorite, so the label itself shows where a view lives. Dispatch follows the source-of-truth rule (decision 16): every local view, tracked or not, dispatches as a :type filter and runs its local authoring filter, never the Linear mirror. A tracked entry carries its :linear-view-url so the rendered buffer can still open the mirror. Before this, a published local view ran server-side, which contradicted the editable-local model. Cross-store dedup: a Linear view favorite whose customView id matches a tracked local view's :linear-view-id is dropped, so the editable local entry is the one shown. A local and a Linear view that merely share a name stay distinct. Phase 4 of the views spec. Tests cover the labels, the local-filter dispatch, and the dedup and same-name cases. Suite, compile, and lint are green.
-rw-r--r--pearl.el101
-rw-r--r--tests/test-pearl-favorites.el83
2 files changed, 117 insertions, 67 deletions
diff --git a/pearl.el b/pearl.el
index 8d2e491..d984876 100644
--- a/pearl.el
+++ b/pearl.el
@@ -3702,36 +3702,42 @@ user rather than silently mis-labelling)."
(defun pearl--pick-source-candidates (favorites saved-queries &optional teams)
"Build the picker's candidate alist `((DISPLAY . SOURCE-OR-ACTION) ...)'.
FAVORITES is a list of normalized favorite plists; SAVED-QUERIES is the
-`pearl-local-views' alist; TEAMS is the team alist list (see
-`pearl--all-teams') used to resolve the scope name of any saved-query
-entry carrying `:linear-view-id'. Favorites come first in ascending
-`:sort-order'; local views follow alphabetically.
+`pearl-local-views' alist; TEAMS resolves the scope name of a tracked local
+view. Favorites come first in ascending `:sort-order'; local views follow
+alphabetically.
Each DISPLAY is one of:
- `[KIND] TITLE' for favorites
- `[saved] NAME' for a local-only local view
- `[saved \\u2192 SCOPE] NAME' for a synced local view
-where SCOPE is the team name resolved from `:linear-view-team-id'
-against TEAMS, or \"Personal\" when there's no team scope, or \"?\" when
-the team id is stale (no longer in TEAMS) or TEAMS is nil.
-
-A synced entry's source plist is `(:type view :name NAME :id LINEAR-VIEW-ID)'
-so dispatch flows through the existing view branch and runs
-`pearl--query-view-async' against the synced view -- the synced query
-lives on Linear now and any filter drift there is honored at refresh.
-Local-only entries keep the `:type filter' shape and dispatch through
-`pearl--query-issues-async' against the stored authoring filter.
-
-Display strings are made unique by appending ` (#N)' on a true collision
--- dispatch reads SOURCE-OR-ACTION, not the display string."
- (let ((fav-sorted (sort (copy-sequence favorites)
- (lambda (a b)
- (< (or (plist-get a :sort-order) 0.0)
- (or (plist-get b :sort-order) 0.0)))))
- (saved-sorted (sort (copy-sequence saved-queries)
- (lambda (a b) (string< (car a) (car b)))))
- (alist nil)
- (seen (make-hash-table :test 'equal)))
+ `[KIND] TITLE' for a non-view favorite (opens in browser)
+ `[linear] TITLE' for a Linear view favorite
+ `[local] NAME' for a local-only local view
+ `[local \\u2192 Linear:SCOPE] NAME' for a published (tracked) local view
+where SCOPE is the team name resolved from `:linear-view-team-id' against
+TEAMS, \"Personal\" when there's no team scope, or \"?\" when the team id is
+stale or TEAMS is nil.
+
+Dispatch follows the source-of-truth rule (decision 16): EVERY local view --
+tracked or not -- dispatches as `:type filter' and runs its LOCAL authoring
+filter via `pearl--query-issues-async', never the Linear mirror. A tracked
+entry carries its `:linear-view-url' as `:url' so the rendered buffer can
+still open the mirror. A native Linear view favorite dispatches as
+`:type view'.
+
+Cross-store dedup: a Linear view favorite whose `customView' id matches a
+tracked local view's `:linear-view-id' is dropped, so the editable local
+entry is the one shown. Local and Linear views that merely share a NAME stay
+distinct (their labels differ). Display strings are made unique by appending
+` (#N)' on a true collision."
+ (let* ((tracked-ids
+ (delq nil (mapcar (lambda (sq) (plist-get (cdr sq) :linear-view-id))
+ saved-queries)))
+ (fav-sorted (sort (copy-sequence favorites)
+ (lambda (a b)
+ (< (or (plist-get a :sort-order) 0.0)
+ (or (plist-get b :sort-order) 0.0)))))
+ (saved-sorted (sort (copy-sequence saved-queries)
+ (lambda (a b) (string< (car a) (car b)))))
+ (alist nil)
+ (seen (make-hash-table :test 'equal)))
(cl-labels
((push-unique
(display source)
@@ -3744,29 +3750,34 @@ Display strings are made unique by appending ` (#N)' on a true collision
(dolist (fav fav-sorted)
(let* ((kind (plist-get fav :kind))
(title (plist-get fav :title))
- (display (format "[%s] %s" (symbol-name kind) title))
- (source (pearl--favorite->source fav)))
- (push-unique display source)))
+ (source (pearl--favorite->source fav))
+ (fav-view-id (and (eq (plist-get source :type) 'view)
+ (plist-get source :id))))
+ ;; Cross-store dedup: drop a Linear view favorite already mirrored by
+ ;; a tracked local view, so the editable local entry is what's shown.
+ (unless (and fav-view-id (member fav-view-id tracked-ids))
+ (push-unique
+ (if (eq kind 'view)
+ (format "[linear] %s" title)
+ (format "[%s] %s" (symbol-name kind) title))
+ source))))
(dolist (sq saved-sorted)
(let* ((name (car sq))
(spec (cdr sq))
(view-id (plist-get spec :linear-view-id))
(scope-label (pearl--saved-query-scope-label spec teams))
(display (if view-id
- (format "[saved → %s] %s" scope-label name)
- (format "[saved] %s" name)))
- (source (if view-id
- ;; Include :url when stored so `pearl-open-current-
- ;; view-in-linear' works on synced sources. Entries
- ;; synced before :linear-view-url tracking landed
- ;; carry no URL; a re-sync repopulates it.
- (append (list :type 'view :name name :id view-id)
- (let ((u (plist-get spec :linear-view-url)))
- (when u (list :url u))))
- (list :type 'filter :name name
- :filter (plist-get spec :filter)
- :sort (plist-get spec :sort)
- :order (plist-get spec :order)))))
+ (format "[local → Linear:%s] %s" scope-label name)
+ (format "[local] %s" name)))
+ ;; Decision 16: every local view runs its LOCAL filter, tracked
+ ;; or not. :url carries the mirror so open-in-linear still works.
+ (source (append
+ (list :type 'filter :name name
+ :filter (plist-get spec :filter)
+ :sort (plist-get spec :sort)
+ :order (plist-get spec :order))
+ (let ((u (plist-get spec :linear-view-url)))
+ (when u (list :url u))))))
(push-unique display source))))
(nreverse alist)))
diff --git a/tests/test-pearl-favorites.el b/tests/test-pearl-favorites.el
index b6235c7..55808b3 100644
--- a/tests/test-pearl-favorites.el
+++ b/tests/test-pearl-favorites.el
@@ -186,10 +186,10 @@ For an issue node, IDENTIFIER fills the issue's user-facing key."
(cands (pearl--pick-source-candidates favs saved))
(labels (mapcar #'car cands)))
(should (equal labels
- '("[view] Alpha"
+ '("[linear] Alpha"
"[project] Bravo"
- "[saved] All bugs"
- "[saved] My open")))))
+ "[local] All bugs"
+ "[local] My open")))))
(ert-deftest test-pearl-pick-source-candidates-dispatch-from-attached-plist ()
"Dispatch reads the attached source plist, not the display string."
@@ -216,51 +216,53 @@ For an issue node, IDENTIFIER fills the issue's user-facing key."
;;; pick-source candidate builder -- synced local views (view-sync Phase 4)
(ert-deftest test-pearl-pick-source-candidates-synced-saved-query-team-scope ()
- "A team-scoped synced entry renders as `[saved \\u2192 <TeamName>] <Name>'."
+ "A team-scoped synced entry renders as `[local \\u2192 Linear:<TeamName>] <Name>'."
(let* ((teams '(((id . "team-eng-id") (key . "ENG") (name . "Engineering"))))
(saved '(("My team view" :filter (:open t)
:linear-view-id "view-uuid"
:linear-view-team-id "team-eng-id"
:linear-view-shared t)))
(cands (pearl--pick-source-candidates nil saved teams)))
- (should (assoc "[saved → Engineering] My team view" cands))))
+ (should (assoc "[local → Linear:Engineering] My team view" cands))))
(ert-deftest test-pearl-pick-source-candidates-synced-saved-query-personal-scope ()
- "A personal synced entry renders as `[saved \\u2192 Personal] <Name>'."
+ "A personal synced entry renders as `[local \\u2192 Linear:Personal] <Name>'."
(let* ((saved '(("Mine" :filter (:open t)
:linear-view-id "view-uuid"
:linear-view-team-id nil
:linear-view-shared :json-false)))
(cands (pearl--pick-source-candidates nil saved nil)))
- (should (assoc "[saved → Personal] Mine" cands))))
+ (should (assoc "[local → Linear:Personal] Mine" cands))))
(ert-deftest test-pearl-pick-source-candidates-synced-saved-query-unknown-team-renders-q ()
- "A synced entry whose team-id isn't in TEAMS renders `[saved \\u2192 ?] <Name>'."
+ "A synced entry whose team-id isn't in TEAMS renders `[local \\u2192 Linear:?] <Name>'."
(let* ((teams '(((id . "other-id") (key . "OTH") (name . "Other"))))
(saved '(("Stale" :filter (:open t)
:linear-view-id "view-uuid"
:linear-view-team-id "gone-team-id")))
(cands (pearl--pick-source-candidates nil saved teams)))
- (should (assoc "[saved → ?] Stale" cands))))
+ (should (assoc "[local → Linear:?] Stale" cands))))
(ert-deftest test-pearl-pick-source-candidates-synced-saved-query-no-teams-arg-renders-q ()
"When TEAMS is nil and an entry has a non-nil team-id, scope is `?'."
(let* ((saved '(("S" :filter (:open t)
:linear-view-id "view-uuid"
:linear-view-team-id "team-x"))))
- (should (assoc "[saved → ?] S"
+ (should (assoc "[local → Linear:?] S"
(pearl--pick-source-candidates nil saved nil)))))
-(ert-deftest test-pearl-pick-source-candidates-synced-saved-query-dispatches-as-view ()
- "A synced entry's source plist has :type view with :id = :linear-view-id."
+(ert-deftest test-pearl-pick-source-candidates-tracked-view-dispatches-as-local-filter ()
+ "Decision 16: a tracked local view dispatches as :type filter (its LOCAL
+filter), not :type view -- the local view is the source of truth."
(let* ((saved '(("S" :filter (:open t)
:linear-view-id "view-uuid-X"
:linear-view-team-id nil)))
(cands (pearl--pick-source-candidates nil saved nil))
- (source (cdr (assoc "[saved → Personal] S" cands))))
- (should (eq 'view (plist-get source :type)))
- (should (equal "view-uuid-X" (plist-get source :id)))
- (should (equal "S" (plist-get source :name)))))
+ (source (cdr (assoc "[local → Linear:Personal] S" cands))))
+ (should (eq 'filter (plist-get source :type)))
+ (should (equal '(:open t) (plist-get source :filter)))
+ (should (equal "S" (plist-get source :name)))
+ (should-not (eq 'view (plist-get source :type)))))
(ert-deftest test-pearl-pick-source-candidates-synced-source-includes-url-when-stored ()
"A synced entry carrying `:linear-view-url' surfaces it in the source plist
@@ -270,7 +272,7 @@ so `pearl-open-current-view-in-linear' can dispatch to the browser."
:linear-view-team-id nil
:linear-view-url "https://linear.app/ws/view/abc")))
(cands (pearl--pick-source-candidates nil saved nil))
- (source (cdr (assoc "[saved → Personal] S" cands))))
+ (source (cdr (assoc "[local → Linear:Personal] S" cands))))
(should (equal "https://linear.app/ws/view/abc"
(plist-get source :url)))))
@@ -281,14 +283,14 @@ source plist; the open-in-Linear command errors cleanly until the user re-syncs.
:linear-view-id "view-uuid-X"
:linear-view-team-id nil)))
(cands (pearl--pick-source-candidates nil saved nil))
- (source (cdr (assoc "[saved → Personal] S" cands))))
+ (source (cdr (assoc "[local → Linear:Personal] S" cands))))
(should-not (plist-get source :url))))
(ert-deftest test-pearl-pick-source-candidates-local-only-saved-query-keeps-saved-label ()
- "A local view without :linear-view-id still renders as `[saved] <Name>' and dispatches as filter."
+ "A local view without :linear-view-id still renders as `[local] <Name>' and dispatches as filter."
(let* ((saved '(("Local" :filter (:open t))))
(cands (pearl--pick-source-candidates nil saved '()))
- (source (cdr (assoc "[saved] Local" cands))))
+ (source (cdr (assoc "[local] Local" cands))))
(should source)
(should (eq 'filter (plist-get source :type)))
(should (equal '(:open t) (plist-get source :filter)))))
@@ -302,8 +304,8 @@ source plist; the open-in-Linear command errors cleanly until the user re-syncs.
:linear-view-team-id "team-eng-id")))
(cands (pearl--pick-source-candidates nil saved teams))
(labels (mapcar #'car cands)))
- (should (member "[saved] Local thing" labels))
- (should (member "[saved → Engineering] Synced thing" labels))))
+ (should (member "[local] Local thing" labels))
+ (should (member "[local → Linear:Engineering] Synced thing" labels))))
;;; pearl--saved-query-scope-label
@@ -356,5 +358,42 @@ source plist; the open-in-Linear command errors cleanly until the user re-syncs.
:type 'user-error)
(should-not visited))))
+;;; cross-store duplicate display (Phase 4)
+
+(ert-deftest test-pearl-pick-source-candidates-dedupes-same-id-favorite ()
+ "A Linear view favorite whose id matches a tracked local view is dropped,
+leaving the editable local entry as the only one shown."
+ (let* ((favs (list (test-pearl-fav--norm 'view "view-shared" "Shared")))
+ (saved '(("Shared local" :filter (:open t)
+ :linear-view-id "view-shared"
+ :linear-view-team-id nil)))
+ (cands (pearl--pick-source-candidates favs saved nil))
+ (labels (mapcar #'car cands)))
+ ;; the favorite is deduped away; only the local tracked entry remains
+ (should (= 1 (length cands)))
+ (should (member "[local → Linear:Personal] Shared local" labels))
+ (should-not (member "[linear] Shared" labels))))
+
+(ert-deftest test-pearl-pick-source-candidates-keeps-unmirrored-linear-favorite ()
+ "A Linear view favorite with no matching tracked local view is kept as [linear]."
+ (let* ((favs (list (test-pearl-fav--norm 'view "view-other" "Other")))
+ (saved '(("Local" :filter (:open t)
+ :linear-view-id "view-different"
+ :linear-view-team-id nil)))
+ (cands (pearl--pick-source-candidates favs saved nil))
+ (labels (mapcar #'car cands)))
+ (should (member "[linear] Other" labels))
+ (should (member "[local → Linear:Personal] Local" labels))))
+
+(ert-deftest test-pearl-pick-source-candidates-same-name-different-store-both-shown ()
+ "A local view and a Linear view favorite that merely share a name both appear."
+ (let* ((favs (list (test-pearl-fav--norm 'view "v-id" "My bugs")))
+ (saved '(("My bugs" :filter (:labels ("bug") :open t))))
+ (cands (pearl--pick-source-candidates favs saved nil))
+ (labels (mapcar #'car cands)))
+ (should (= 2 (length cands)))
+ (should (member "[linear] My bugs" labels))
+ (should (member "[local] My bugs" labels))))
+
(provide 'test-pearl-favorites)
;;; test-pearl-favorites.el ends here