diff options
| -rw-r--r-- | pearl.el | 101 | ||||
| -rw-r--r-- | tests/test-pearl-favorites.el | 83 |
2 files changed, 117 insertions, 67 deletions
@@ -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 |
