From 0ab4afcd1e4e336cb69bd983e498bc441828ed5b Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Tue, 16 Jun 2026 16:49:08 -0500 Subject: fix(views): save a local view from any Custom View, not just favorites pearl-save-linear-view-locally only listed favorited Linear views, while pearl-run-linear-view lists every Custom View in the workspace. A view you could run often wasn't offered to save, so the copy-down silently produced nothing. The command now saves the current buffer's view when one is showing, and otherwise prompts over all Custom Views. Favorited custom views also came through nameless: the favorites query fetched customView id but not name, and Linear leaves the favorite's title blank for entity favorites. The picker showed a blank entry and the save dead-ended at a blank name. The query now selects customView name and pearl--normalize-favorite falls back to it, which also fixes blank names in pearl-pick-source. The success message now names the saved view and how to run it or set it as the default. --- pearl.el | 82 +++++++++++++++++++++++-------------- tests/test-pearl-favorites.el | 17 ++++++++ tests/test-pearl-reverse-compile.el | 78 +++++++++++++++++++++++++++++++---- 3 files changed, 139 insertions(+), 38 deletions(-) diff --git a/pearl.el b/pearl.el index f66490c..41abab6 100644 --- a/pearl.el +++ b/pearl.el @@ -1365,9 +1365,18 @@ a list source). For an issue favorite, `:identifier' carries the human key like ENG-1; everything else carries only `:id'. =label= and =projectLabel= both normalize to `:kind label' so the dispatch collapses them." (let* ((type (cdr (assoc 'type node))) - (title (or (cdr (assoc 'title node)) "")) + (title-raw (cdr (assoc 'title node))) (url (cdr (assoc 'url node))) (sort-order (cdr (assoc 'sortOrder node))) + ;; Linear populates `Favorite.title' only for favorite folders; an + ;; entity favorite (a starred custom view) arrives with a null/empty + ;; title, so fall back to the entity's own name. Today the favorites + ;; query selects only `customView.name', the case Pearl dispatches on. + (title (cond ((and (stringp title-raw) (not (string-empty-p title-raw))) + title-raw) + ((string= type "customView") + (or (cdr (assoc 'name (cdr (assoc 'customView node)))) "")) + (t ""))) (kind+id (pcase type ("customView" (cons 'view (pearl--favorite-entity-id node 'customView))) @@ -1413,7 +1422,7 @@ dashboard / other)." "query Favorites($after: String) { favorites(first: 100, after: $after) { nodes { id type title url sortOrder - customView { id } project { id } cycle { id } + customView { id name } project { id } cycle { id } label { id } user { id } issue { id identifier } team { id } projectLabel { id } document { id } dashboard { id } } pageInfo { hasNextPage endCursor } } }" @@ -4928,16 +4937,6 @@ GraphQL error or a view with no readable filter." (funcall callback fd))) (lambda (&rest _) (funcall callback nil)))) -(defun pearl--linear-view-favorites (favorites) - "Return `((TITLE . VIEW-ID) ...)' for the view-kind entries in FAVORITES. -These are the copy-down candidates -- only a favorited Linear view carries a -Custom View id and a readable filter." - (delq nil - (mapcar (lambda (fav) - (when (eq (plist-get fav :kind) 'view) - (cons (plist-get fav :title) (plist-get fav :id)))) - favorites))) - (defun pearl--save-linear-view-locally-finish (title view-id filter-data) "Reverse-compile FILTER-DATA for Linear view TITLE/VIEW-ID and save a fork. On a refusal, signal a `user-error' naming the construct and pointing at @@ -4965,13 +4964,27 @@ link, `:copied-from-view-id' provenance only) and report the independence." (ignore-errors (customize-save-variable 'pearl-local-views pearl-local-views)))) (message - "Saved Linear view %s as independent local view %s; edits won't update the original until you publish it back" - title name))))) + "Saved '%s' as an independent local view (forked from '%s'); run it with pearl-run-local-view or set it as your default with pearl-set-default-view" + name title))))) + +(defun pearl--save-linear-view-locally-fetch (title view-id) + "Fetch VIEW-ID's filter and hand it to the copy-down finisher for TITLE. +Shared by both copy-down entry points -- saving the current buffer's view and +picking one from the custom-view list -- so they reverse-compile and save +identically." + (pearl--progress "Fetching filter for %s..." title) + (pearl--customview-filterdata-async + view-id + (lambda (filter-data) + (pearl--save-linear-view-locally-finish title view-id filter-data)))) ;;;###autoload (defun pearl-save-linear-view-locally () - "Copy a favorited Linear view down into a new, editable local view. -Lists your favorited Linear views, fetches the chosen view's filter, reverse- + "Copy a Linear Custom View down into a new, editable local view. +When the current buffer is showing a Linear view, offers to save that view +directly. Otherwise -- or when you decline -- prompts over *all* the +workspace's Custom Views (not only favorited ones, matching what +`pearl-run-linear-view' lists). Fetches the chosen view's filter, reverse- compiles it into a Pearl authoring filter, and saves a forked local view -- no tracking link, so editing or renaming it never touches the Linear view. The new view is stamped with the active account. When the view's filter is outside @@ -4980,21 +4993,28 @@ refuses and names the construct; run such a view directly with `pearl-run-linear-view'." (interactive) (pearl--require-account-context t) - (pearl--progress "Fetching favorited Linear views...") - (pearl--favorites-async - (lambda (favorites) - (let ((views (pearl--linear-view-favorites favorites))) - (unless views - (user-error - "No favorited Linear views; favorite a view in Linear first, then copy it down")) - (let* ((title (completing-read "Save Linear view locally: " - (mapcar #'car views) nil t)) - (view-id (cdr (assoc title views)))) - (pearl--progress "Fetching filter for %s..." title) - (pearl--customview-filterdata-async - view-id - (lambda (filter-data) - (pearl--save-linear-view-locally-finish title view-id filter-data)))))))) + (let* ((source (pearl--read-active-source)) + (current (and (eq (plist-get source :type) 'view) + (plist-get source :id) + source))) + (if (and current + (pearl--read-yes-no + (format "Save current view '%s' locally? " + (plist-get current :name)))) + (pearl--save-linear-view-locally-fetch + (plist-get current :name) (plist-get current :id)) + (let ((views (pearl--custom-views))) + (unless views + (user-error "No Custom Views in this workspace to copy down")) + (let* ((title (completing-read "Save Linear view locally: " + (mapcar (lambda (v) (cdr (assoc 'name v))) + views) + nil t)) + (view (seq-find (lambda (v) (string= (cdr (assoc 'name v)) title)) + views)) + (view-id (and view (cdr (assoc 'id view))))) + (unless view-id (user-error "No Custom View named %s" title)) + (pearl--save-linear-view-locally-fetch title view-id)))))) (defun pearl--assemble-filter (team open state project labels assignee) "Assemble a filter authoring plist from the chosen dimensions. diff --git a/tests/test-pearl-favorites.el b/tests/test-pearl-favorites.el index 55808b3..3f1452f 100644 --- a/tests/test-pearl-favorites.el +++ b/tests/test-pearl-favorites.el @@ -56,6 +56,23 @@ For an issue node, IDENTIFIER fills the issue's user-facing key." (should (string= "Active bugs" (plist-get p :title))) (should (string= "view-1" (plist-get p :id))))) +(ert-deftest test-pearl-normalize-favorite-custom-view-blank-title-uses-name () + "A favorited custom view with a blank own title takes the customView's name. +Linear populates Favorite.title only for favorite folders, so an entity favorite +arrives with a null/empty title and the entity's own name is the only display +string -- the favorites query selects customView.name for exactly this." + (let* ((node (list (cons 'id "fav-customView") + (cons 'type "customView") + (cons 'title nil) + (cons 'url "https://linear.app/x/view/v1") + (cons 'sortOrder 0.1) + (cons 'customView (list (cons 'id "view-1") + (cons 'name "Pearl Open Issues"))))) + (p (pearl--normalize-favorite node))) + (should (eq 'view (plist-get p :kind))) + (should (string= "Pearl Open Issues" (plist-get p :title))) + (should (string= "view-1" (plist-get p :id))))) + (ert-deftest test-pearl-normalize-favorite-project () (let ((p (pearl--normalize-favorite (test-pearl-fav--node "project" "Platform" "https://linear.app/x/project/p1" 0.2 diff --git a/tests/test-pearl-reverse-compile.el b/tests/test-pearl-reverse-compile.el index 382c72a..209d9a6 100644 --- a/tests/test-pearl-reverse-compile.el +++ b/tests/test-pearl-reverse-compile.el @@ -195,13 +195,77 @@ ;;; copy-down command-side: candidate builder + finish -(ert-deftest test-pearl-linear-view-favorites-extracts-views () - "Only view-kind favorites become copy-down candidates, as (title . id)." - (let ((favs (list (list :kind 'view :title "V1" :id "vid-1") - (list :kind 'project :title "P" :id "pid") - (list :kind 'view :title "V2" :id "vid-2")))) - (should (equal (pearl--linear-view-favorites favs) - '(("V1" . "vid-1") ("V2" . "vid-2")))))) +(ert-deftest test-pearl-save-linear-view-locally-saves-current-view () + "When the buffer shows a Linear view, the command offers to save that view +directly -- without touching favorites or fetching the custom-view list -- and +hands its name + id to the finisher." + (let (finished favorites-called custom-views-called) + (cl-letf (((symbol-function 'pearl--require-account-context) (lambda (&rest _) nil)) + ((symbol-function 'pearl--read-active-source) + (lambda () '(:type view :name "Pearl Open Issues" :id "view-1"))) + ((symbol-function 'pearl--read-yes-no) (lambda (&rest _) t)) + ((symbol-function 'pearl--progress) (lambda (&rest _) nil)) + ((symbol-function 'pearl--favorites-async) + (lambda (&rest _) (setq favorites-called t))) + ((symbol-function 'pearl--custom-views) + (lambda (&rest _) (setq custom-views-called t) nil)) + ((symbol-function 'pearl--customview-filterdata-async) + (lambda (view-id cb) (funcall cb (list view-id)))) + ((symbol-function 'pearl--save-linear-view-locally-finish) + (lambda (title view-id _fd) (setq finished (cons title view-id))))) + (pearl-save-linear-view-locally) + (should (equal finished '("Pearl Open Issues" . "view-1"))) + (should-not favorites-called) + (should-not custom-views-called)))) + +(ert-deftest test-pearl-save-linear-view-locally-picks-from-all-views () + "With no view in the buffer, the picker lists ALL custom views (not just +favorites) and hands the chosen view's name + id to the finisher." + (let (finished favorites-called) + (cl-letf (((symbol-function 'pearl--require-account-context) (lambda (&rest _) nil)) + ((symbol-function 'pearl--read-active-source) (lambda () nil)) + ((symbol-function 'pearl--progress) (lambda (&rest _) nil)) + ((symbol-function 'pearl--favorites-async) + (lambda (&rest _) (setq favorites-called t))) + ((symbol-function 'pearl--custom-views) + (lambda (&rest _) + '(((id . "v1") (name . "Pearl Open Issues")) + ((id . "v2") (name . "My bugs"))))) + ((symbol-function 'completing-read) (lambda (&rest _) "My bugs")) + ((symbol-function 'pearl--customview-filterdata-async) + (lambda (view-id cb) (funcall cb (list view-id)))) + ((symbol-function 'pearl--save-linear-view-locally-finish) + (lambda (title view-id _fd) (setq finished (cons title view-id))))) + (pearl-save-linear-view-locally) + (should (equal finished '("My bugs" . "v2"))) + (should-not favorites-called)))) + +(ert-deftest test-pearl-save-linear-view-locally-declined-current-falls-to-picker () + "Declining the current-view offer falls through to the all-views picker." + (let (finished) + (cl-letf (((symbol-function 'pearl--require-account-context) (lambda (&rest _) nil)) + ((symbol-function 'pearl--read-active-source) + (lambda () '(:type view :name "Current" :id "cur"))) + ((symbol-function 'pearl--read-yes-no) (lambda (&rest _) nil)) + ((symbol-function 'pearl--progress) (lambda (&rest _) nil)) + ((symbol-function 'pearl--custom-views) + (lambda (&rest _) '(((id . "v9") (name . "Picked"))))) + ((symbol-function 'completing-read) (lambda (&rest _) "Picked")) + ((symbol-function 'pearl--customview-filterdata-async) + (lambda (view-id cb) (funcall cb (list view-id)))) + ((symbol-function 'pearl--save-linear-view-locally-finish) + (lambda (title view-id _fd) (setq finished (cons title view-id))))) + (pearl-save-linear-view-locally) + (should (equal finished '("Picked" . "v9")))))) + +(ert-deftest test-pearl-save-linear-view-locally-no-views-errors () + "With no current view and no custom views, the command errors rather than +silently saving nothing." + (cl-letf (((symbol-function 'pearl--require-account-context) (lambda (&rest _) nil)) + ((symbol-function 'pearl--read-active-source) (lambda () nil)) + ((symbol-function 'pearl--progress) (lambda (&rest _) nil)) + ((symbol-function 'pearl--custom-views) (lambda (&rest _) nil))) + (should-error (pearl-save-linear-view-locally) :type 'user-error))) (ert-deftest test-pearl-save-linear-view-locally-finish-nil-filterdata-errors () "A nil filterData (fetch failure / no filter) errors rather than saving." -- cgit v1.2.3