diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-01 17:46:34 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-01 17:46:34 -0500 |
| commit | 6206fde5acfd48f9eba2ab231c1fe1d32717c98e (patch) | |
| tree | dd8659ff59e6c2da3a44eea503c702a2e6b1a558 /pearl.el | |
| parent | c2ba1d8f25463bf6342ac635850500a9738b31e9 (diff) | |
| download | pearl-6206fde5acfd48f9eba2ab231c1fe1d32717c98e.tar.gz pearl-6206fde5acfd48f9eba2ab231c1fe1d32717c98e.zip | |
feat(views): add create and edit local views with a metadata-preserving writer
I added pearl-create-local-view and pearl-edit-local-view, plus the writer and guards the spec calls for. pearl--save-local-view replaces the old rebuild-from-scratch writer. It copies an existing entry and updates only the filter, sort, and order, so a published view keeps its :linear-view-* tracking link and a copied-down view keeps its provenance. A new entry gets the active account stamped under pearl-accounts.
pearl--require-local-view-account is the one guard every read or mutate command shares, and pearl-run-local-view now calls it instead of an inline check. pearl--resolve-local-view-name carries the Replace/Rename/Cancel collision policy for create, edit-rename, and copy-down. The ad-hoc builder's save offer routes through the preserving writer and reads "save this as a local view".
Edit re-runs the builder when you choose to rebuild and otherwise keeps the stored filter. Full per-dimension pre-seeding of the builder is deferred, since it needs the id-to-name reverse resolution that copy-down's reverse-compile introduces. I filed it as a follow-up.
Phase 2 of the views spec. Tests cover metadata preservation, the account guard, the collision policy, and the metadata-preserving rename. Suite, compile, and lint are green.
Diffstat (limited to 'pearl.el')
| -rw-r--r-- | pearl.el | 163 |
1 files changed, 143 insertions, 20 deletions
@@ -3664,15 +3664,10 @@ active file with the query recorded as the source." (source (list :type 'filter :name name :filter filter-plist :sort sort :order order))) ;; Account guard, checked before any filter compilation or network call: - ;; a query tagged for another account would otherwise resolve its team / + ;; a view tagged for another account would otherwise resolve its team / ;; state / label names against the wrong workspace. Inert in legacy mode - ;; and for an untagged (shared) query. - (let ((q-account (plist-get spec :account))) - (when (and pearl-accounts q-account) - (let ((active (plist-get (pearl--current-account-context) :name))) - (unless (string-equal q-account active) - (user-error "Local view '%s' belongs to %s; switch accounts first" - name q-account))))) + ;; and for an untagged (shared) view. + (pearl--require-local-view-account name spec) ;; Validate the local view's authoring plist at the command boundary so ;; a typo'd key or bad value surfaces as a clear user-error instead of ;; being silently dropped or failing deeper in the compiler. @@ -4004,18 +3999,71 @@ by hand." (t (message "Kept local view %s; Linear view also untouched" name))))) -(defun pearl--save-query (name filter-plist &optional sort order) - "Save FILTER-PLIST as the local view NAME, replacing any entry of that NAME. -Persists `pearl-local-views' via Customize. SORT and ORDER are -stored when given." - (let ((entry (cons name (append (list :filter filter-plist) - (when sort (list :sort sort)) - (when order (list :order order)))))) +(defun pearl--save-local-view (name filter-plist &optional sort order) + "Save FILTER-PLIST as local view NAME, preserving an existing entry's metadata. +On an existing entry, replaces only `:filter' (and `:sort' / `:order' when +non-nil) and keeps every other key -- `:linear-view-id', `:linear-view-team-id', +`:linear-view-shared', `:linear-view-url', `:linear-view-synced-at', `:account', +and `:copied-from-view-id'. A new entry is stamped with the active account's +name under `:account' when `pearl-accounts' is configured. Persists +`pearl-local-views' via Customize. This is the metadata-preserving writer; +unlike a naive rebuild it never drops a published view's tracking link." + (let* ((existing (assoc name pearl-local-views)) + (spec (and existing (copy-sequence (cdr existing))))) + (setq spec (plist-put spec :filter filter-plist)) + (when sort (setq spec (plist-put spec :sort sort))) + (when order (setq spec (plist-put spec :order order))) + (unless existing + (when pearl-accounts + (let ((active (plist-get (pearl--current-account-context) :name))) + (when active (setq spec (plist-put spec :account active)))))) (setq pearl-local-views - (cons entry (assoc-delete-all name (copy-sequence pearl-local-views)))) + (cons (cons name spec) + (assoc-delete-all name (copy-sequence pearl-local-views)))) (ignore-errors (customize-save-variable 'pearl-local-views pearl-local-views)))) +(defun pearl--require-local-view-account (name spec) + "Refuse when local view NAME (SPEC plist) belongs to a non-active account. +Inert in legacy single-account mode and for an untagged entry -- the same +guard `pearl-run-local-view' applies, shared so every read/mutate command +enforces it identically. Returns the active account name, or nil in legacy +mode, so callers can stamp newly-created entries." + (let ((v-account (plist-get spec :account))) + (cond + ((and pearl-accounts v-account) + (let ((active (plist-get (pearl--current-account-context) :name))) + (unless (string-equal v-account active) + (user-error "Local view '%s' belongs to %s; switch accounts first" + name v-account)) + active)) + (pearl-accounts (plist-get (pearl--current-account-context) :name)) + (t nil)))) + +(defun pearl--resolve-local-view-name (proposed &optional current) + "Resolve a local-view name collision for PROPOSED, returning the final name. +CURRENT, when given, is the entry's existing name -- keeping the same name on +an edit is not a collision. When PROPOSED already names a different local +view, prompts Replace / Rename / Cancel (default Rename); Replace returns the +name to overwrite, Cancel returns nil. Used by create, edit-rename, and +copy-down so the three share one collision policy." + (let ((name proposed) (done nil) (result nil)) + (while (not done) + (cond + ((or (null name) (string-empty-p name)) + (setq done t result nil)) + ((or (and current (string-equal name current)) + (not (assoc name pearl-local-views))) + (setq done t result name)) + (t + (pcase (completing-read + (format "Local view \"%s\" already exists: " name) + '("Rename" "Replace" "Cancel") nil t nil nil "Rename") + ("Replace" (setq done t result name)) + ("Cancel" (setq done t result nil)) + (_ (setq name (read-string "New local-view name: "))))))) + result)) + ;;; Saved-query -> Linear view sync (see docs/saved-query-sync-spec.org) (defun pearl--sync-scope-label (team-name shared) @@ -4418,17 +4466,19 @@ the scope-and-visibility display string for the success message." "Build an ad-hoc issue filter interactively, run it, and render it. Interactively, completes each dimension from the chosen team's fetched projects/states/labels (so a typo can't produce a confusing empty result), and -offers to save the filter as a local query. FILTER-PLIST is the authoring -filter; SAVE-NAME, when given, persists it via `pearl--save-query'." +offers to save it as a local view. FILTER-PLIST is the authoring filter; +SAVE-NAME, when given, persists it via `pearl--save-local-view'. For a +deliberate create use `pearl-create-local-view'; this is the ad-hoc path with +a save offer at the end." (interactive (list (pearl--read-filter-interactively) - (when (pearl--read-yes-no "Save this filter locally so you can re-run it (not pushed to Linear)? ") + (when (pearl--read-yes-no "Save this as a local view so you can re-run it (not published to Linear)? ") (read-string "Name for this local view: ")))) ;; Validate before saving or running, so a bad filter never gets persisted ;; or run -- the command boundary is where the clear error belongs. (pearl--validate-issue-filter filter-plist) (when (and save-name (not (string-empty-p save-name))) - (pearl--save-query save-name filter-plist)) + (pearl--save-local-view save-name filter-plist)) (let ((source (list :type 'filter :name (if (and save-name (not (string-empty-p save-name))) save-name @@ -4439,6 +4489,79 @@ filter; SAVE-NAME, when given, persists it via `pearl--save-query'." (pearl--build-issue-filter filter-plist) (lambda (result) (pearl--render-query-result result source))))) +;;;###autoload +(defun pearl-create-local-view () + "Build a filter interactively, name it, and save it as a local view. +Completes each dimension from the chosen team's fetched values, then prompts a +name (Replace / Rename / Cancel on a collision), persists via +`pearl--save-local-view' (stamping the active account under `pearl-accounts'), +and runs the new view. The deliberate create path; +`pearl-list-issues-filtered' is the ad-hoc run that merely offers to save." + (interactive) + (let ((filter-plist (pearl--read-filter-interactively))) + (pearl--validate-issue-filter filter-plist) + (let ((name (pearl--resolve-local-view-name + (read-string "Name for this local view: ")))) + (unless name (user-error "Cancelled; no local view created")) + (pearl--save-local-view name filter-plist) + (let ((source (list :type 'filter :name name :filter filter-plist))) + (pearl--progress "Running local view %s..." name) + (pearl--query-issues-async + (pearl--build-issue-filter filter-plist) + (lambda (result) (pearl--render-query-result result source))))))) + +(defun pearl--read-sort-or-keep (current) + "Read a sort key, defaulting to CURRENT. Returns a symbol, or nil for none." + (let ((choice (completing-read + "Sort: " + (pearl--completion-table-keep-order + '("keep" "updated" "created" "priority" "title" "none")) + nil t nil nil "keep"))) + (pcase choice ("keep" current) ("none" nil) (_ (intern choice))))) + +(defun pearl--read-order-or-keep (current) + "Read an order, defaulting to CURRENT. Returns `asc', `desc', or nil." + (let ((choice (completing-read + "Order: " + (pearl--completion-table-keep-order '("keep" "desc" "asc")) + nil t nil nil "keep"))) + (pcase choice ("keep" current) (_ (intern choice))))) + +;;;###autoload +(defun pearl-edit-local-view (name) + "Edit the local view NAME, preserving its non-edited metadata. +Refuses a cross-account entry. Offers to rebuild the filter (otherwise keeps +the stored one), then prompts a new name (Replace / Rename / Cancel on a +collision; keeping the same name is fine), and a sort and order. Saves through +`pearl--save-local-view', so a published view keeps its `:linear-view-*' link +and a copied-down view keeps its `:copied-from-view-id'." + (interactive + (list (if (null pearl-local-views) + (user-error "No local views to edit") + (completing-read "Edit local view: " + (mapcar #'car pearl-local-views) nil t)))) + (let ((entry (assoc name pearl-local-views))) + (unless entry (user-error "No local view named %s" name)) + (let ((spec (cdr entry))) + (pearl--require-local-view-account name spec) + (let* ((filter (if (pearl--read-yes-no "Rebuild the filter? " "no") + (pearl--read-filter-interactively) + (plist-get spec :filter))) + (sort (pearl--read-sort-or-keep (plist-get spec :sort))) + (order (pearl--read-order-or-keep (plist-get spec :order))) + (new-name (pearl--resolve-local-view-name + (read-string "Name: " name) name))) + (pearl--validate-issue-filter filter) + (unless new-name (user-error "Cancelled; no edit saved")) + ;; On rename, carry the existing spec to the new name first so the + ;; metadata survives, then let `pearl--save-local-view' update it. + (unless (string-equal new-name name) + (setq pearl-local-views + (cons (cons new-name (copy-sequence spec)) + (assoc-delete-all name (copy-sequence pearl-local-views))))) + (pearl--save-local-view new-name filter sort order) + (message "Saved local view %s" new-name))))) + (defun pearl--cap-issue-list-comments (issue) "Cap a bulk-list ISSUE's fetched comments and record a `:comment-count' marker. The list/view fetch pulls the newest `pearl-list-comments-count-cap'+1 comments |
