aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md21
-rw-r--r--CLAUDE.md44
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md61
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md90
4 files changed, 159 insertions, 57 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3dea47f..7b22d9f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,27 @@ point at which they are stable.
## [Unreleased]
+### Added
+
+- Anything on screen in the message pane can be searched for by right-clicking
+ it. The subject and the date in the header, the sender and recipients when
+ the thread holds one message, a tag chip, a phrase selected in the message
+ body, and every header of every message in the details dialog. Each offers
+ **Search for this**, which replaces the query, and **Add to search**, which
+ narrows what is already there.
+
+ Narrowing is the half worth knowing about: a query returning a thousand
+ threads can be cut down by adding a sender or a date to it, without retyping
+ the query you started from.
+
+ A search is only ever a search. Turning what you find into a tagging rule is
+ still the existing road: save the query, then create a rule from it.
+
+### Changed
+
+- The message details dialog shows labelled rows rather than one block of
+ text, so each value can be searched for on its own.
+
## [0.19.0] - 2026-08-14
A saved query can become a tagging rule without retyping it, and a rule can no
diff --git a/CLAUDE.md b/CLAUDE.md
index b66c3b2..f6f9996 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -65,6 +65,7 @@ MainWindow NotmuchWorker
└ MessageView (header QLabel, QWebEngineView, attachment bar, TagStrip)
CardLayout (pure geometry, no painting)
+SearchTerm (pure query strings, no widget)
Config (INI) KeyMap MailSync (QProcess) MimeParser (GMime)
SyncMonitor (/proc/locks) TagColors QueryCompleter ThreadCidMap
```
@@ -74,9 +75,19 @@ The query row and the message-pane header are **built inline in `MainWindow` and
listed `QueryBar`, `SavedQueryBar`, `HeaderWidget` and `AttachmentBar`; none of
those types have ever existed, and looking for them wastes a search. The widget
classes that do exist are `MessageView`, `ThreadListView`, `TagStrip`,
-`TagDialog`, `RowStyleDelegate` and `CardDelegate`; `TagChip` is a namespace of
-painting helpers, not a widget, and `ThreadCidMap` and `CardLayout` are structs.
-`SubjectDelegate` existed until item 53 and is gone.
+`TagDialog`, `MessageDetailsDialog`, `RowStyleDelegate` and `CardDelegate`;
+`TagChip` is a namespace of painting helpers, not a widget, `SearchTerm` is a
+namespace of query builders, and `ThreadCidMap`, `CardLayout`, `SearchOffer`
+and `HeaderRow` are structs. `SubjectDelegate` existed until item 53 and is
+gone.
+
+**`MessageDetailsDialog` was a `QPlainTextEdit` inside `MessageView` until item
+85.** It is rows now so each value can carry its own context menu, and its
+plain-textness was a SECURITY property rather than a style: header values come
+from strangers and plain text cannot interpret markup. Every value label states
+`Qt::PlainText` explicitly, because a `QLabel` guesses under `Qt::AutoText`.
+Escaping into a rich-text label is the same protection one mistake away from
+failing, so do not "simplify" it back.
**`ThreadListView` survives only for the expander hit-test.** `CardDelegate`
draws the reply count, and a delegate gets no click of its own without an
@@ -254,6 +265,17 @@ a failure or a `-1` count fails against correct code. This was recorded in
building the rules. Assert on the positional contract, never on a provoked
failure.
+**Every query this application builds goes through `SearchTerm`
+(`src/searchterm.h`), and that is what stops five surfaces growing five quoting
+rules.** It holds no widget, so the grammar is tested without a painter or a web
+engine. Two of its rules are load-bearing rather than cosmetic. `quote()`
+escapes backslashes BEFORE quotes, since the other order escapes the
+backslashes it just added; it truncates before escaping, so a cut cannot land
+mid-escape. And `extend()` parenthesises BOTH sides, because the query bar can
+hold a hand-written disjunction and `a or b AND c` binds as `a or (b AND c)`,
+which widens a search the user asked to narrow, reporting nothing. This is the
+same trap the `post-new` hook handles when it scopes a rule with `tag:new`.
+
**A writer that does not validate what its reader requires loses data
silently.** `TagRules::save()` wrote any id and `load()` required
`^[a-z0-9][a-z0-9-]*$`, so a rule named `justeat orders` in a field labelled
@@ -341,6 +363,22 @@ one route out of three. Assert every route. Underneath sits a second trap:
`done()`, so a test for the closed path has to `show()` the dialog first or it
asserts nothing at all.
+**A modal dialog must close BEFORE the action it asked for runs, not after.**
+A signal from a dialog to its parent is a DIRECT connection, so the emit runs
+the handler synchronously while `exec()` is still on the stack: the details
+dialog's search ran the query, cleared the model and blanked the message pane
+while the dialog was still up, holding the `m_items` it was built from. Call
+`accept()` first, then emit. The mutation check for this HANGS rather than
+failing, since without the `accept()` nothing ever leaves `exec()`, and a hung
+test binary is item 84's second trap waiting to mislead the next run.
+
+**`Qt::RFC2822Date` validates the weekday against the date.** `Thu, 14 Aug
+2026` parses as INVALID because that day is a Friday, and an invalid parse here
+is indistinguishable from the trailing-comment trap `MimeParser::parseDate`
+exists to handle. Two fixtures carried a wrong weekday, one of them
+pre-existing and unnoticed until something finally parsed it. Write a date
+fixture with `date -d <yyyy-mm-dd> +%A`, never from memory.
+
**Under a tiling compositor a window's size is not the application's to
restore, and the user's desktop is Hyprland.** `saveGeometry` stores
`frameGeometry` and `normalGeometry`; `restoreGeometry` restores the NORMAL
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
index 8cfa6b0..8ae8dff 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
@@ -4741,3 +4741,64 @@ how this was found: the suite ran past its two-minute timeout with no output.
The cause is not the account at all, it is `showWarnings()` raising a modal
from the MainWindow constructor for any config problem, with nothing offscreen
to dismiss it. Filed as item 84.
+
+## 85. Nothing on screen can be searched for by right-clicking it
+
+**Observed (2026-08-14).** The user asked, while discussing item 78, to be able
+to right-click a sender, a subject, a date, a tag chip or a piece of selected
+body text and be offered a search built from it, both replacing the query bar
+and narrowing what is already in it. The narrowing case decided the shape: a
+query returning a thousand threads is refined by adding a second condition, and
+before this that meant retyping a query the user never typed.
+
+**Cause.** Not a defect, unbuilt. `MessageView` had no context menu on any
+surface, and `TagStrip` painted chips with no hit test and no signals.
+
+**Done 2026-08-14**, designed in
+`specs/2026-08-14-search-from-message-design.md` and built over eight tasks.
+Five surfaces offer **Search for this** and **Add to search**: the header's
+subject and date, its From/To/Cc for a single-message thread, a tag chip, a
+body selection, and every header per message in the details dialog.
+
+**What is worth keeping from it.**
+
+`SearchTerm` (`src/searchterm.h`) owns the whole query grammar and holds no
+widget, so the quoting is tested without a painter or a web engine. That
+matters because **a mis-quoted notmuch query is not an error, it matches zero**:
+nothing downstream would ever report the feature being broken. `extend()`
+parenthesises both sides, since `a or b AND c` binds as `a or (b AND c)` and
+silently WIDENS a search the user asked to narrow.
+
+**`Qt::RFC2822Date` validates the weekday against the date**, so `Thu, 14 Aug
+2026` parses as invalid: that day is a Friday. Two test fixtures carried a wrong
+weekday, one of them pre-existing, and the failure is indistinguishable from the
+trailing-comment trap `MimeParser::parseDate` exists to handle. Check a date
+fixture with `date -d <yyyy-mm-dd> +%A` rather than writing one from memory.
+
+The Date: parse was **extracted** into `MimeParser::parseDate` rather than
+rewritten, because the existing copy inside a file-local function already
+carried the fix for `Qt::RFC2822Date` rejecting a string with a trailing
+timezone comment. A second parser without it would have lost the date on a
+large share of real mail, showing up only as a menu entry that never appears.
+
+**The header lists its fields rather than hit-testing them.** It is one
+rich-text `QLabel` of up to four lines, and mapping a point through laid-out
+rich text breaks as soon as the label wraps. From/To/Cc are offered exactly when
+the header displays them, which is a single-message thread, so the menu can
+never name a value the header is withholding.
+
+**The details dialog stopped being a `QPlainTextEdit`**, which the user had
+disliked independently. Its plain-textness was a security property rather than a
+style: header values come from strangers and plain text cannot interpret markup.
+Every value label therefore states `Qt::PlainText` explicitly, because a
+`QLabel` guesses under `Qt::AutoText`, and a test enumerates every label and
+asserts the format.
+
+**A modal dialog must close BEFORE the search it asked for runs.** The
+connection is direct, so emitting first runs the query synchronously: the model
+clears and the pane blanks while the dialog is still up, holding the `m_items`
+it was built from. Caught by a test whose mutation check HUNG rather than
+failed, which is what a missing `accept()` does to `exec()`.
+
+**Item 78 stays open**, carrying the rule shortcut alone. The road from a search
+to a rule already exists: save the query, then create a rule from it.
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
index f562e97..980aefb 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -142,14 +142,14 @@ taking that too literally.
| 75 | The tagging rules window forgets its size and its column widths | persistence | S | **done** 2026-08-13, shipped in 0.17.0. The window-kind question is left open, see the closed-items file |
| 76 | Every field in the rules dialog is free text, so a rule is easy to get wrong | workflow | M | **done** 2026-08-13, shipped in 0.17.0. See `specs/2026-08-13-rule-builder-design.md` |
| 77 | No way to see what a rule would collect, in the thread list | workflow | S | **done** 2026-08-13, shipped in 0.17.0 |
-| 78 | No way to build a rule from something visible in a message | workflow | M | open, narrowed 2026-08-14; the search half split out as item 85, which is the road a rule is made from. Revisit once 85 has been used |
+| 78 | No way to build a rule from something visible in a message | workflow | S | open, narrowed 2026-08-14; the search half shipped as item 85, which is the road a rule is made from. Now a shortcut across that road: the menus and the seeded-dialog path both exist. Use 85 first and see which values are worth promoting |
| 80 | A rule with many conditions squeezes the rule list to one visible row | defect | XS | **done** 2026-08-13, shipped in 0.17.0. Follows item 76 |
| 79 | Opening the rules dialog and saving destroys the first rule | defect | XS | **fixed on `rule-builder`** 2026-08-13, unreleased. Shipped in 0.16.0; damaged one real rule, repaired by hand |
| 81 | No way to turn a saved query into a tagging rule | workflow | S | **done** 2026-08-14, unreleased; see `specs/2026-08-14-query-to-rule-design.md` |
| 82 | A saved query cannot be edited, unpinned or deleted from the UI | defect | S | **done** 2026-08-13, shipped in 0.18.0. Right-click offers Edit, Pin/Unpin and Delete |
| 83 | A rule named with spaces is written to the file and dropped by every reader | defect | S | **done** 2026-08-14, unreleased. The name is sanitised into an id, save validates, a bad id loads for repair |
| 84 | A config problem blocks `test_mainwindow` on a modal nobody can dismiss | testing | S | open; measured 2026-08-14, `showWarnings()` calls `QMessageBox::warning` from the constructor |
-| 85 | Nothing on screen can be searched for by right-clicking it | workflow | M | open; designed 2026-08-14, see `specs/2026-08-14-search-from-message-design.md`. Split from 78; rebuilds the details dialog as rows |
+| 85 | Nothing on screen can be searched for by right-clicking it | workflow | M | **done** 2026-08-14, unreleased; see `specs/2026-08-14-search-from-message-design.md`. Split from 78; rebuilt the details dialog as rows |
Sizes are rough: XS under an hour, S a sitting, M a session.
@@ -483,26 +483,45 @@ query look fast is a worse trade than the wait.
**Observed.** The user would like to select an address or another piece of a
message in the main window, right-click, and be offered a rule built from it.
-**Cause.** Not a defect, unbuilt. The thread list has a context menu (item
-24) and the message pane is a `QWebEngineView` whose selection is inside the
-render process.
-
-**Approach.** Start from the thread list's own context menu, where the
-sender is already a value the model holds, rather than from a text selection
-in the web view. A "Create rule from sender" entry that opens the rules
-dialog with the query prefilled covers the case the user described and needs
-no new plumbing.
-
-**Constraints.** JavaScript is disabled in the profile and must stay
-disabled, so reading a selection out of the web view means
-`QWebEnginePage::selectedText()` and nothing that injects script. Do that
-part only if the sender case turns out not to be enough.
+**Cause.** Not a defect, unbuilt.
+
+**Narrowed 2026-08-14, and most of the work is already done.** The search half
+shipped as item 85, which is the road a rule is made from: search for a value,
+save the query, create a rule from the saved query. What remains here is a
+SHORTCUT across that road, and both of its halves now exist.
+
+- The menus are built and every surface already extracts its value as a
+ finished query (`SearchOffer`, `src/searchterm.h`). A rule entry is another
+ action beside the two search ones, not new plumbing.
+- The seeded-dialog path exists from item 81:
+ `MainWindow::showTagRulesDialog(const TagRule &seed)`. A rule from a message
+ becomes a second caller of it, with a different seed, which is what item 81's
+ spec anticipated when it made the seed a whole `TagRule` rather than a query
+ string.
+
+**Approach.** Decide it after using item 85 for a while. Which values are worth
+promoting straight to a rule is a usage question, and the earlier answer to it
+was wrong (see below), so it is worth having the evidence first.
+
+**Constraints.** The original approach here said to start from the thread
+list's context menu "where the sender is already a value the model holds".
+**That is false and item 85 verified it.** `ThreadSummary::authors` comes from
+`notmuch_thread_get_authors` and is a DISPLAY SUMMARY, reading `Alice, Bob` or
+`Alice| Bob`, so a `from:` built from it matches nothing. A real address comes
+from `MessageNode::from` or `ParsedMessage::from`, neither of which the thread
+list carries. Any thread-list entry needs an address resolved from a message
+first.
+
+JavaScript is disabled in the profile and must stay disabled. Item 85 reads a
+body selection with `QWebEnginePage::selectedText()`, which injects no script;
+reuse that rather than adding anything.
The rules file is shared with mailctl, so a rule created here must go through
`TagRules` and preserve unknown fields; see "Changing the shared rule format"
in CLAUDE.md.
-**Size: M.**
+**Size: S**, down from M now that item 85 has built the menus and item 81 the
+seeded dialog.
## 84. A config problem blocks `test_mainwindow` on a modal nobody can dismiss
@@ -558,43 +577,6 @@ here.
**Size: S.** The diagnosis is the expensive part and it is already done.
-## 85. Nothing on screen can be searched for by right-clicking it
-
-**Observed (2026-08-14).** The user asked, while discussing item 78, to be able
-to right-click a sender, a subject, a date, a tag chip or a piece of selected
-body text and be offered a search built from it, both replacing the query bar
-and narrowing what is already in it. The narrowing case is the one that decides
-the shape: a query returning a thousand threads is refined by adding a second
-condition, and today that means retyping a query the user never typed.
-
-**Cause.** Not a defect, unbuilt. `MessageView` has no context menu on any
-surface, and `TagStrip` paints chips with no hit test and no signals.
-
-**Approach.** Designed 2026-08-14. **Read
-`specs/2026-08-14-search-from-message-design.md` rather than planning from this
-entry.**
-
-Three constraints decide whether to open the spec. **This is search only, and
-that is the point**: item 78's rule shortcut is deliberately left out, because a
-saved query can already be promoted to a rule and searching is the missing step
-on that road, as well as the safe one. **The details dialog is rebuilt as rows**,
-which the user wanted anyway, and its value labels must stay explicitly
-`Qt::PlainText`: the current `QPlainTextEdit` is a deliberate protection against
-markup in header values that come from strangers. And **Add to search
-parenthesises both sides**, because the bar may hold a hand-written `or` and
-`a or b AND c` binds the wrong way, silently widening a search meant to narrow.
-
-**Constraints.** The thread list is out of scope and the reason is worth
-keeping: a row's `authors` comes from `notmuch_thread_get_authors` and is a
-display summary reading `Alice, Bob`, so a `from:` built from it matches
-nothing. Item 78's own approach line assumed otherwise.
-
-A mis-quoted query is not an error to notmuch, it matches zero, so the quoting
-helper is tested against the constructed string rather than against a provoked
-failure.
-
-**Size: M.**
-
## Deferred, unsized, or split out
Items noted while triaging but not part of the original list. Same numbering