diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-28 01:55:00 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-28 01:55:00 -0500 |
| commit | 505e7070bfbff9812823c09e94c9c87ed8c9ca0d (patch) | |
| tree | a763a256a0b0a358f1a2d14c843b5c291852a06b /pearl.el | |
| parent | 00f218a13add7fce072879da0749ee2afe6d4a6f (diff) | |
| download | pearl-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.el | 115 |
1 files changed, 71 insertions, 44 deletions
@@ -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) |
