From 49e79a262638ca5fb673ac3f5d8e9cde0cb7905a Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Mon, 1 Jun 2026 18:00:58 -0500 Subject: 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. --- pearl.el | 101 +++++++++++++++++++++++++++++++++++---------------------------- 1 file changed, 56 insertions(+), 45 deletions(-) (limited to 'pearl.el') 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))) -- cgit v1.2.3