aboutsummaryrefslogtreecommitdiff
path: root/pearl.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-28 01:55:00 -0500
committerCraig Jennings <c@cjennings.net>2026-05-28 01:55:00 -0500
commit505e7070bfbff9812823c09e94c9c87ed8c9ca0d (patch)
treea763a256a0b0a358f1a2d14c843b5c291852a06b /pearl.el
parent00f218a13add7fce072879da0749ee2afe6d4a6f (diff)
downloadpearl-505e7070bfbff9812823c09e94c9c87ed8c9ca0d.tar.gz
pearl-505e7070bfbff9812823c09e94c9c87ed8c9ca0d.zip
feat(prompts): split sentinel + default-yes for safe yes/no
Two UX refinements on the just-shipped prompt surface, both following the principle "the prompt should describe what it's doing and the default should be the most-common choice." First, the sentinel that was uniformly "[ None. ]" really meant two different things at two kinds of prompt, and the label only matched one of them. Filter-dimension prompts (team, state, project, labels, assignee) treat picking the sentinel as "no constraint on this dimension." Every value matches, which is *any*, not *none*. The saved-query prompts (delete, run) treat picking the sentinel as "don't act." That one is *cancel*, not *none* or *any*. Renamed accordingly: `pearl--filter-any` ("[ Any. ]") for the five filter dimensions and `pearl--filter-cancel` ("[ Cancel. ]") for the two saved-query prompts. The generic helper became `pearl--with-sentinel SENTINEL CANDIDATES` so each call site picks the label that fits its case. The predicate became `pearl--filter-sentinel-value-p` (recognizes either sentinel or empty/nil) so the cancellation logic is unchanged. Second, three non-destructive yes/no prompts ("Open issues only?", "Save this filter locally...", "Save N fields across M issues?") moved from `y-or-n-p` to a new `pearl--read-yes-no` helper. The helper renders a completing-read over ("yes" "no") with the most-common choice as the default and topmost candidate, so RET takes it without typing. Default is "yes" for all three (each is a do-the-thing-I-asked confirmation), but the helper takes a DEFAULT arg so a future prompt where "no" is more common can opt in. The destructive prompts (delete issue, delete saved query, delete comment) stay as `yes-or-no-p`. Typing "yes" there is a deliberate safety affordance, not friction worth removing. Tests cover the sentinel-value predicate across both sentinels + empty/nil + real values, the `pearl--with-sentinel` helper, the `pearl--read-yes-no` t/nil return and default-ordering behavior, and the three save-test stubs swapped to mock the new helper.
Diffstat (limited to 'pearl.el')
-rw-r--r--pearl.el115
1 files changed, 71 insertions, 44 deletions
diff --git a/pearl.el b/pearl.el
index 5cd1373..cb5a256 100644
--- a/pearl.el
+++ b/pearl.el
@@ -3217,9 +3217,10 @@ active file with the query recorded as the source."
(completing-read
"Saved query: "
(pearl--completion-table-keep-order
- (pearl--with-none (mapcar #'car pearl-saved-queries)))
+ (pearl--with-sentinel pearl--filter-cancel
+ (mapcar #'car pearl-saved-queries)))
nil t))))
- (when (pearl--filter-none-value-p name)
+ (when (pearl--filter-sentinel-value-p name)
(user-error "Cancelled; no saved query run"))
(let ((entry (assoc name pearl-saved-queries)))
(unless entry
@@ -3347,32 +3348,57 @@ with just what the user set."
(when project (list :project project))
(when labels (list :labels labels))))
-(defconst pearl--filter-none "[ None. ]"
- "Sentinel candidate shown at the top of `pearl--read-filter-interactively'
-prompts. Picking it drops the dimension from the filter, the same as
-opting out. Always rendered first so \"no constraint\" is a visible
-choice rather than a hidden empty-input idiom.")
-
-(defun pearl--filter-none-value-p (value)
- "Return non-nil when VALUE means \"no constraint\" for a filter dimension.
-A nil, an empty string, or `pearl--filter-none' all mean the user opted
-out of constraining this dimension."
+(defconst pearl--filter-any "[ Any. ]"
+ "Sentinel candidate shown at the top of filter-dimension prompts.
+Picking it means \"no constraint on this dimension\" -- i.e. *any* value
+matches. Earlier drafts read \"[ None. ]\", which described what the user
+picked rather than what the filter does; the dimension's not absent, it's
+unconstrained. Always rendered first so the choice is visible rather
+than a hidden empty-input idiom.")
+
+(defconst pearl--filter-cancel "[ Cancel. ]"
+ "Sentinel candidate shown at the top of pick-an-existing-thing prompts
+(delete a saved query, run a saved query) where the opt-out reads as
+\"don't do this\" rather than \"no constraint.\" Cancelling these
+prompts is a real choice, not a missing one.")
+
+(defun pearl--filter-sentinel-value-p (value)
+ "Return non-nil when VALUE is a sentinel (any opt-out marker) or empty.
+A nil, an empty string, `pearl--filter-any', or `pearl--filter-cancel'
+all mean the user opted out (of constraining, or of acting)."
(or (null value)
(and (stringp value)
(or (string-empty-p value)
- (string= value pearl--filter-none)))))
-
-(defun pearl--with-none (candidates)
- "Return CANDIDATES with `pearl--filter-none' prepended as the first option."
- (cons pearl--filter-none candidates))
+ (string= value pearl--filter-any)
+ (string= value pearl--filter-cancel)))))
+
+(defun pearl--with-sentinel (sentinel candidates)
+ "Return CANDIDATES with SENTINEL prepended as the first option."
+ (cons sentinel candidates))
+
+(defun pearl--read-yes-no (prompt &optional default)
+ "Ask PROMPT as a completing-read over (\"yes\" \"no\") and return t / nil.
+DEFAULT is the value RET picks without typing; \"yes\" if omitted. The
+candidate list is ordered with the default first so the most-common
+choice is what the user sees at the top, consistent with
+`pearl--with-sentinel'. Use this for non-destructive prompts where
+RET-through the defaults is a feature; keep `yes-or-no-p' for destructive
+prompts (delete, overwrite) where typing \"yes\" is a safety affordance."
+ (let* ((d (or default "yes"))
+ (other (if (string= d "yes") "no" "yes"))
+ (choice (completing-read prompt
+ (pearl--completion-table-keep-order
+ (list d other))
+ nil t nil nil d)))
+ (string= "yes" choice)))
(defun pearl--completion-table-keep-order (candidates)
"Return a completion table over CANDIDATES that preserves input order.
The framework-side sort (vertico-sort-function, prescient, ivy, etc.)
-re-sorts alphabetically by default, which clobbers `pearl--filter-none'
-as the topmost option whenever a real candidate sorts ahead of \"[\".
-Setting the table's `display-sort-function' to `identity' is the standard
-Emacs hook for \"these are pre-sorted, leave them alone\"."
+re-sorts alphabetically by default, which clobbers the sentinel as the
+topmost option whenever a real candidate sorts ahead of \"[\". Setting
+the table's `display-sort-function' to `identity' is the standard Emacs
+hook for \"these are pre-sorted, leave them alone\"."
(lambda (string pred action)
(if (eq action 'metadata)
'(metadata (display-sort-function . identity)
@@ -3382,56 +3408,56 @@ Emacs hook for \"these are pre-sorted, leave them alone\"."
(defun pearl--read-filter-interactively ()
"Build a filter plist by completing over the chosen team's fetched dimensions.
Picks a team first (scoping the rest), then offers open-only, state, project,
-labels, and assignee. Each prompt carries `pearl--filter-none' as the topmost
-choice; selecting it drops that dimension. The assignee prompt offers `me'
-(the viewer), `member' (a specific teammate, resolved to an id), or
-`pearl--filter-none' (no scoping)."
+labels, and assignee. Each prompt carries `pearl--filter-any' as the topmost
+choice; selecting it means \"no constraint on this dimension.\" The assignee
+prompt offers `me' (the viewer), `member' (a specific teammate, resolved to
+an id), or `pearl--filter-any' (any assignee, no scoping)."
(let* ((teams (pearl--all-teams))
(team-name (completing-read
"Team: "
(pearl--completion-table-keep-order
- (pearl--with-none
+ (pearl--with-sentinel pearl--filter-any
(mapcar (lambda (tm) (cdr (assoc 'name tm))) teams)))
nil t))
- (team-id (and (not (pearl--filter-none-value-p team-name))
+ (team-id (and (not (pearl--filter-sentinel-value-p team-name))
(pearl--get-team-id-by-name team-name)))
- (open (y-or-n-p "Open issues only? "))
+ (open (pearl--read-yes-no "Open issues only? "))
(state (and team-id
(let ((s (completing-read
"State: "
(pearl--completion-table-keep-order
- (pearl--with-none
+ (pearl--with-sentinel pearl--filter-any
(mapcar (lambda (st) (cdr (assoc 'name st)))
(pearl--team-states team-id))))
nil t)))
- (unless (pearl--filter-none-value-p s) s))))
+ (unless (pearl--filter-sentinel-value-p s) s))))
(project (and team-id
(let ((p (completing-read
"Project: "
(pearl--completion-table-keep-order
- (pearl--with-none
+ (pearl--with-sentinel pearl--filter-any
(pearl--team-collection-names 'projects team-id)))
nil t)))
;; The :project filter compiles to project.id.eq, so
;; resolve the picked name to its id before passing.
- (unless (pearl--filter-none-value-p p)
+ (unless (pearl--filter-sentinel-value-p p)
(pearl--resolve-team-id 'projects p team-id)))))
(labels (and team-id
- ;; `[ None. ]' alongside real labels is redundant, so
+ ;; `[ Any. ]' alongside real labels is redundant, so
;; filter the sentinel out of the result. A result of
- ;; only-sentinel (or empty) means "no labels."
- (cl-remove pearl--filter-none
+ ;; only-sentinel (or empty) means "no label constraint."
+ (cl-remove pearl--filter-any
(completing-read-multiple
"Labels (comma-separated): "
(pearl--completion-table-keep-order
- (pearl--with-none
+ (pearl--with-sentinel pearl--filter-any
(pearl--team-collection-names 'labels team-id)))
nil t)
:test #'string=)))
(assignee (let ((choice (completing-read
"Assignee: "
(pearl--completion-table-keep-order
- (list pearl--filter-none "me" "member"))
+ (list pearl--filter-any "me" "member"))
nil t)))
(pcase choice
("me" :me)
@@ -3447,7 +3473,7 @@ choice; selecting it drops that dimension. The assignee prompt offers `me'
;; The :team filter compiles to team.key.eq, so look the picked name up
;; in the teams alist and pass its key (not the display name).
(pearl--assemble-filter
- (and (not (pearl--filter-none-value-p team-name))
+ (and (not (pearl--filter-sentinel-value-p team-name))
(cdr (assoc 'key
(cl-find team-name teams
:key (lambda (tm) (cdr (assoc 'name tm)))
@@ -3458,8 +3484,8 @@ choice; selecting it drops that dimension. The assignee prompt offers `me'
(defun pearl-delete-saved-query (name)
"Delete the saved query NAME from `pearl-saved-queries' after confirmation.
Interactively, completes over the configured query names with
-`pearl--filter-none' at the top as a cancel. Local-only: removes the entry
-from the customized list and persists via `customize-save-variable'. Linear
+`pearl--filter-cancel' at the top. Local-only: removes the entry from
+the customized list and persists via `customize-save-variable'. Linear
is untouched (saved queries are local; syncing them up is a separate spec)."
(interactive
(list (if (null pearl-saved-queries)
@@ -3467,10 +3493,11 @@ is untouched (saved queries are local; syncing them up is a separate spec)."
(completing-read
"Delete saved query: "
(pearl--completion-table-keep-order
- (pearl--with-none (mapcar #'car pearl-saved-queries)))
+ (pearl--with-sentinel pearl--filter-cancel
+ (mapcar #'car pearl-saved-queries)))
nil t))))
(cond
- ((pearl--filter-none-value-p name)
+ ((pearl--filter-sentinel-value-p name)
(message "Cancelled; no saved query deleted"))
((not (assoc name pearl-saved-queries))
(user-error "No saved query named %s" name))
@@ -3502,7 +3529,7 @@ 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'."
(interactive
(list (pearl--read-filter-interactively)
- (when (y-or-n-p "Save this filter locally so you can re-run it (not pushed to Linear)? ")
+ (when (pearl--read-yes-no "Save this filter locally so you can re-run it (not pushed to Linear)? ")
(read-string "Name for this saved query: "))))
;; Validate before saving or running, so a bad filter never gets persisted
;; or run -- the command boundary is where the clear error belongs.
@@ -4457,7 +4484,7 @@ the prompt counts comments as viewer-unavailable rather than read-only. BUFFER
is surfaced if anything pushed. Declining the prompt mutates nothing. The
queue continues past a per-field conflict, so one conflicted issue does not
stop the rest."
- (if (not (y-or-n-p (pearl--save-all-prompt
+ (if (not (pearl--read-yes-no (pearl--save-all-prompt
(pearl--save-all-counts scan viewer-id viewer-failed))))
(message "save-all cancelled; nothing saved")
(let (thunks)