diff options
Diffstat (limited to 'docs/superpowers/plans/2026-08-14-exclude-from-search.md')
| -rw-r--r-- | docs/superpowers/plans/2026-08-14-exclude-from-search.md | 817 |
1 files changed, 817 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-08-14-exclude-from-search.md b/docs/superpowers/plans/2026-08-14-exclude-from-search.md new file mode 100644 index 0000000..78209b0 --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-exclude-from-search.md @@ -0,0 +1,817 @@ +# Exclude from search Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a third right-click search operation, "Exclude from search", which narrows the current query by everything that is not the value under the cursor. + +**Architecture:** `SearchTerm` gains an `exclude()` beside `extend()` and a `SearchMode` enum. The `bool extend` carried from the two menu surfaces to `MainWindow` widens to that enum across four signatures. `MainWindow` pushes a `hasQuery` bool down into `MessageView` so both menus can grey the new entry when there is nothing to exclude from. + +**Tech Stack:** C++17, Qt 6.11, QtTest. Build with CMake + Ninja. + +**Spec:** `docs/superpowers/specs/2026-08-14-exclude-from-search-design.md`. Backlog item 86. + +--- + +## Before you start + +Build and test commands, from the repo root: + +```bash +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug +cmake --build build +ctest --test-dir build --output-on-failure +``` + +**Never run a test binary without `QT_QPA_PLATFORM=offscreen`**, and never +launch `./build/src/qtmaildir`. `tests/CMakeLists.txt` sets that variable for +ctest only, so a binary invoked directly inherits the desktop's Wayland setting +and throws real windows onto the user's screen. Running the application is a +hand test and belongs to the user. + +Commits are GPG-signed (`git commit -S`). Work directly on `master`. + +## File Structure + +| File | Responsibility | Change | +|---|---|---| +| `src/searchterm.h` | Query grammar declarations, `SearchMode` | Modify: add enum + `exclude()` | +| `src/searchterm.cpp` | Query grammar | Modify: add `exclude()` | +| `tests/test_searchterm.cpp` | Grammar tests, no widgets | Modify: 3 new cases | +| `src/messageview.h/.cpp` | Message pane, header + body menus | Modify: signal type, `setHasQuery`, third entry | +| `src/messagedetailsdialog.h/.cpp` | Per-row detail menus | Modify: signal type, ctor arg, third entry | +| `tests/test_messagedetailsdialog.cpp` | Dialog tests | Modify: update calls, add guard test | +| `src/mainwindow.h/.cpp` | Owns the query bar, runs the query | Modify: mode switch, push `hasQuery` | + +Task order matters: the grammar first, then the enum that every signature +depends on, then the two surfaces, then the guard. + +--- + +### Task 1: `SearchTerm::exclude()` + +The grammar, with no enum and no UI yet. Pure functions, no widget. + +**Files:** +- Modify: `src/searchterm.h` (after the `extend()` declaration, ~line 77) +- Modify: `src/searchterm.cpp` (after `extend()`, ~line 91) +- Test: `tests/test_searchterm.cpp` + +- [ ] **Step 1: Write the failing tests** + +Add three slot declarations to the `private slots:` block in +`tests/test_searchterm.cpp`, after `extendOntoAnEmptyQueryIsAReplace();`: + +```cpp + void excludeParenthesisesBothSides(); + void excludeFromAnEmptyQueryIsEmpty(); + void excludeWithNothingToExcludeLeavesTheQuery(); +``` + +Add the three implementations at the end of the file, before the +`QTEST_MAIN` / `#include "test_searchterm.moc"` lines: + +```cpp +void TestSearchTerm::excludeParenthesisesBothSides() +{ + // The query bar can hold a hand-written disjunction. Unparenthesised, + // `a or b AND NOT c` binds as `a or (b AND NOT c)`: the exclusion covers + // only the second term and every message matching `a` stays on screen, + // including the ones the user asked to be rid of. notmuch reports no + // error for either form, so this assertion is the only thing that fails. + QCOMPARE(SearchTerm::exclude(QStringLiteral("tag:inbox or tag:flagged"), + QStringLiteral("from:\"someone\"")), + QStringLiteral("(tag:inbox or tag:flagged) AND NOT " + "(from:\"someone\")")); +} + +void TestSearchTerm::excludeFromAnEmptyQueryIsEmpty() +{ + // Deliberately NOT extend()'s behaviour. extend() returns the addition + // alone, because narrowing nothing by x sensibly means x. Excluding from + // nothing would mean the whole Maildir minus one value, which is a + // legitimate query and an implausible thing to have meant by right + // clicking a value in a fresh window. The UI greys the entry; this is the + // second layer, against a caller that forgets the guard. + QCOMPARE(SearchTerm::exclude(QString(), QStringLiteral("tag:inbox")), + QString()); + QCOMPARE(SearchTerm::exclude(QStringLiteral(" "), + QStringLiteral("tag:inbox")), + QString()); +} + +void TestSearchTerm::excludeWithNothingToExcludeLeavesTheQuery() +{ + QCOMPARE(SearchTerm::exclude(QStringLiteral("tag:inbox"), QString()), + QStringLiteral("tag:inbox")); + QCOMPARE(SearchTerm::exclude(QString(), QString()), QString()); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_searchterm +``` + +Expected: a COMPILE failure, `'exclude' is not a member of 'SearchTerm'`. That +is the correct failure at this stage. + +- [ ] **Step 3: Declare it** + +In `src/searchterm.h`, immediately after the `extend()` declaration: + +```cpp +/// Narrows `existing` by everything that is NOT `addition`, as +/// `(existing) AND NOT (addition)`. +/// +/// **Both sides are parenthesised, for the same load-bearing reason as +/// extend().** The query bar may hold a hand-written disjunction, and +/// `a or b AND NOT c` binds as `a or (b AND NOT c)`: the exclusion would +/// cover only the second term, leaving on screen exactly the mail the user +/// asked to be rid of, with no error reported anywhere. +/// +/// **An empty `existing` yields an EMPTY STRING, unlike extend().** Excluding +/// from nothing would mean the entire Maildir minus one value: a legitimate +/// query, and an implausible thing to have meant by right-clicking a value in +/// a fresh window. The menus grey the entry out when the query bar is empty; +/// this is the second layer, against a caller that forgets the guard. +/// +/// An empty `addition` leaves `existing` untouched. +QString exclude(const QString &existing, const QString &addition); +``` + +- [ ] **Step 4: Implement it** + +In `src/searchterm.cpp`, immediately after `extend()`: + +```cpp +QString exclude(const QString &existing, const QString &addition) +{ + const QString left = existing.trimmed(); + const QString right = addition.trimmed(); + + if (right.isEmpty()) + return left; + // NOT a replace, unlike extend(): see the header. An empty left would make + // this "everything except", which no right-click asked for. + if (left.isEmpty()) + return QString(); + + return QStringLiteral("(%1) AND NOT (%2)").arg(left, right); +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_searchterm +``` + +Expected: PASS, all cases. + +- [ ] **Step 6: Commit** + +```bash +git add src/searchterm.h src/searchterm.cpp tests/test_searchterm.cpp +git commit -S -m "feat(search): add SearchTerm::exclude + +Parenthesises both sides, as extend() does: unparenthesised, a +disjunction in the query bar binds so the exclusion covers only its +last term and leaves the excluded mail on screen. + +An empty existing query returns empty rather than the addition alone, +which is where this deliberately differs from extend(). Excluding from +nothing means the whole Maildir minus one value; the menus grey the +entry out and this is the second layer. + +Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>" +``` + +--- + +### Task 2: The `SearchMode` enum + +Introduce the type on its own, before anything uses it. No behaviour change. + +**Files:** +- Modify: `src/searchterm.h` + +- [ ] **Step 1: Add the enum** + +In `src/searchterm.h`, inside `namespace SearchTerm`, immediately BEFORE the +`quote()` declaration (so it reads before the functions it selects between): + +```cpp +/// Which of the three search operations a menu entry asked for. +/// +/// Replaces the `bool extend` that carried two operations, which had no room +/// for a third. Plain enum class, NOT registered as a metatype: every +/// connection carrying it is direct, within the UI thread. The `Q_ENUM` trap +/// recorded in CLAUDE.md belongs to the queued signals into NotmuchWorker and +/// does not apply here. +enum class SearchMode { + /// Replace the query bar outright. + Replace, + /// Narrow what is there, via extend(). + Narrow, + /// Narrow by everything that is not this value, via exclude(). + Exclude, +}; +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +cmake --build build +``` + +Expected: builds clean. Nothing uses the type yet. + +- [ ] **Step 3: Commit** + +```bash +git add src/searchterm.h +git commit -S -m "refactor(search): add SearchMode, the type replacing bool extend + +Introduced alone, ahead of the four signatures that change to it, so +that change is a mechanical one commit later. + +Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>" +``` + +--- + +### Task 3: Widen the four signatures + +Mechanical: `bool extend` becomes `SearchMode` end to end, with the two +existing operations mapping to `Replace` and `Narrow`. No new menu entry yet, +so behaviour is unchanged and the suite must stay green. + +**Files:** +- Modify: `src/messageview.h` (signal, ~line 193), `src/messageview.cpp` (~lines 566-580, ~648) +- Modify: `src/messagedetailsdialog.h` (~lines 75, 80), `src/messagedetailsdialog.cpp` (~lines 88-96, 148) +- Modify: `src/mainwindow.h` (~line 381), `src/mainwindow.cpp` (~line 1654) +- Modify: `tests/test_messagedetailsdialog.cpp` (~lines 135-136, 177) + +- [ ] **Step 1: Update the existing test calls first** + +`tests/test_messagedetailsdialog.cpp` already calls `requestSearch` with a +bool, so it stops compiling the moment the signature changes. Update it now so +the build error you see next is only ever the one you intend. + +At `tests/test_messagedetailsdialog.cpp:135-136`, replace: + +```cpp + dialog.requestSearch(*from, false); + dialog.requestSearch(*from, true); +``` + +with: + +```cpp + dialog.requestSearch(*from, SearchTerm::SearchMode::Replace); + dialog.requestSearch(*from, SearchTerm::SearchMode::Narrow); +``` + +At `tests/test_messagedetailsdialog.cpp:177`, replace: + +```cpp + dialog.requestSearch(*id, false); +``` + +with: + +```cpp + dialog.requestSearch(*id, SearchTerm::SearchMode::Replace); +``` + +Both spies assert on `searchRequested`'s arguments. Where a spy compares the +second argument against a bool, compare against the enum instead, e.g. +`QCOMPARE(spy.at(0).at(1).value<SearchTerm::SearchMode>(), SearchTerm::SearchMode::Replace);`. +Read the surrounding assertions and adjust each to match; do not guess at +their shape. + +- [ ] **Step 2: Change `MessageView`** + +In `src/messageview.h`, replace the `searchRequested` signal and the tail of +its doc comment: + +```cpp + /// `mode` says whether to replace the query bar, narrow it, or narrow it + /// by everything that is not this value. The view does not know what the + /// query bar holds and must not: the window owns that field and does the + /// combining. + /// + /// Separate from queryRequested(), which carries a gate against a link in + /// a rendered document driving the thread list. These menus are chrome + /// built by our own code from values we extracted, so they need no gate, + /// and widening the existing signal would change what that gate protects. + void searchRequested(const QString &query, SearchTerm::SearchMode mode); +``` + +In `src/messageview.cpp:566`, `addSearchEntries` becomes: + +```cpp +void MessageView::addSearchEntries(QMenu *menu, const QList<SearchOffer> &offers) +{ + for (const SearchOffer &entry : offers) { + auto *sub = menu->addMenu(tr("Search for %1").arg(entry.label)); + + auto *replace = sub->addAction(tr("Search for this")); + connect(replace, &QAction::triggered, this, [this, entry]() { + emit searchRequested(entry.query, SearchTerm::SearchMode::Replace); + }); + + auto *narrow = sub->addAction(tr("Add to search")); + connect(narrow, &QAction::triggered, this, [this, entry]() { + emit searchRequested(entry.query, SearchTerm::SearchMode::Narrow); + }); + } +} +``` + +In `src/messageview.cpp:~648`, the dialog connection. Keep the comment above it +untouched, it documents the accept()-before-emit ordering: + +```cpp + connect(&dialog, &MessageDetailsDialog::searchRequested, this, + [this, &dialog](const QString &query, SearchTerm::SearchMode mode) { + dialog.accept(); + emit searchRequested(query, mode); + }); +``` + +- [ ] **Step 3: Change `MessageDetailsDialog`** + +In `src/messagedetailsdialog.h`: + +```cpp + /// Emits searchRequested for `row`, or nothing when the row carries no + /// searchable query. The menu entries call this; a test can too, without + /// popping a menu. + void requestSearch(const HeaderRow &row, SearchTerm::SearchMode mode); + +signals: + /// The user chose a search from a row's menu. `mode` says whether to + /// replace the query, narrow it, or narrow it by everything that is not + /// this value. + void searchRequested(const QString &query, SearchTerm::SearchMode mode); +``` + +In `src/messagedetailsdialog.cpp:148`: + +```cpp +void MessageDetailsDialog::requestSearch(const HeaderRow &row, + SearchTerm::SearchMode mode) +{ + if (row.query.isEmpty()) + return; + emit searchRequested(row.query, mode); +} +``` + +In `src/messagedetailsdialog.cpp:~88`, the per-row menu lambda: + +```cpp + QMenu menu(this); + auto *replace = menu.addAction(tr("Search for this")); + connect(replace, &QAction::triggered, this, + [this, row]() { + requestSearch( + row, SearchTerm::SearchMode::Replace); + }); + auto *narrow = menu.addAction(tr("Add to search")); + connect(narrow, &QAction::triggered, this, + [this, row]() { + requestSearch( + row, SearchTerm::SearchMode::Narrow); + }); + menu.exec(value->mapToGlobal(pos)); +``` + +- [ ] **Step 4: Change `MainWindow`** + +In `src/mainwindow.h:~381`, update the declaration and its comment: + +```cpp + /// `mode` says whether to replace the query bar, narrow it, or narrow it + /// by everything that is not this value. The panes do not read the query + /// bar; this is where the combining happens. + void runSearchFromPane(const QString &query, SearchTerm::SearchMode mode); +``` + +In `src/mainwindow.cpp:1654`: + +```cpp +void MainWindow::runSearchFromPane(const QString &query, + SearchTerm::SearchMode mode) +{ + if (query.isEmpty()) + return; + + QString next; + switch (mode) { + case SearchTerm::SearchMode::Replace: + next = query; + break; + case SearchTerm::SearchMode::Narrow: + next = SearchTerm::extend(m_queryEdit->text(), query); + break; + case SearchTerm::SearchMode::Exclude: + next = SearchTerm::exclude(m_queryEdit->text(), query); + break; + } + + // exclude() returns empty when there is nothing to exclude from, which the + // greyed menu entry should already have prevented. Running it would clear + // the query bar and show the whole Maildir, so refuse instead. + if (next.isEmpty()) + return; + + // Through the query bar and the existing runner, so the account scope, the + // generation counter and the flat-mode reset all behave exactly as they do + // for a typed query. Nothing here builds a second query path. + m_queryEdit->setText(next); + runCurrentQuery(); +} +``` + +- [ ] **Step 5: Build and run the full suite** + +```bash +cmake --build build && ctest --test-dir build --output-on-failure +``` + +Expected: builds clean, all tests pass. This task changes no behaviour, so a +failure here is a mistake in the mechanical change, not a real finding. + +- [ ] **Step 6: Commit** + +```bash +git add src/messageview.h src/messageview.cpp src/messagedetailsdialog.h \ + src/messagedetailsdialog.cpp src/mainwindow.h src/mainwindow.cpp \ + tests/test_messagedetailsdialog.cpp +git commit -S -m "refactor(search): carry SearchMode instead of bool extend + +Four signatures, no behaviour change: the two shipped operations map to +Replace and Narrow. runSearchFromPane becomes a switch and gains the +Exclude arm, which nothing can reach until the menu entry exists. + +Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>" +``` + +--- + +### Task 4: The `hasQuery` guard + +`MainWindow` pushes down whether the query bar holds anything, so the menus can +grey the new entry. Still no third entry, so still no behaviour change. + +**Files:** +- Modify: `src/messageview.h`, `src/messageview.cpp` +- Modify: `src/messagedetailsdialog.h`, `src/messagedetailsdialog.cpp` +- Modify: `src/mainwindow.cpp:~370` + +- [ ] **Step 1: Add the setter to `MessageView`** + +In `src/messageview.h`, in the public section near the other small accessors: + +```cpp + /// Tells the pane whether the query bar currently holds anything. + /// + /// The menus need it to grey out "Exclude from search": excluding from an + /// empty query would mean the whole Maildir minus one value. The pane + /// cannot read the query bar and must not, so the window pushes the fact + /// down as it changes. Passed on to the details dialog at construction, + /// which is built fresh per invocation and so cannot go stale. + void setHasQuery(bool hasQuery) { m_hasQuery = hasQuery; } +``` + +And in the private members, beside `m_headerOffers`: + +```cpp + bool m_hasQuery = false; +``` + +- [ ] **Step 2: Take it in the dialog's constructor** + +In `src/messagedetailsdialog.h`: + +```cpp + explicit MessageDetailsDialog(const QList<ThreadRenderItem> &items, + bool hasQuery = false, + QWidget *parent = nullptr); +``` + +and in the private members: + +```cpp + bool m_hasQuery = false; +``` + +In `src/messagedetailsdialog.cpp`, update the constructor definition to match +the new parameter list and store it (`m_hasQuery(hasQuery)` in the init list, +keeping the existing `QDialog(parent)` base and the existing body unchanged). + +The default argument keeps every existing test call compiling; a test that +cares passes it explicitly. + +- [ ] **Step 3: Pass it at the construction site** + +In `src/messageview.cpp`, where the dialog is constructed inside +`showDetailsDialog()` (just above the connect from Task 3, ~line 640), add the +argument: + +```cpp + MessageDetailsDialog dialog(m_items, m_hasQuery, this); +``` + +Read the existing line first and preserve its exact variable name and the +`this` parent; only the middle argument is new. + +- [ ] **Step 4: Push it from `MainWindow`** + +In `src/mainwindow.cpp`, the `updateSaveState` lambda at ~line 370 already +runs on every `textChanged`. Widen it, and rename it to say what it now does: + +```cpp + auto updateQueryState = [this, save]() { + const bool hasQuery = !m_queryEdit->text().trimmed().isEmpty(); + save->setEnabled(hasQuery); + // The message pane greys "Exclude from search" without it: there + // is nothing to exclude from. + m_messageView->setHasQuery(hasQuery); + }; + connect(m_queryEdit, &QLineEdit::textChanged, this, updateQueryState); + updateQueryState(); +``` + +**Ordering, verified 2026-08-14, no guard needed.** The line numbers look +alarming: this lambda is at ~370 and `m_messageView` is constructed at +`src/mainwindow.cpp:668`. But this block lives in `registerActions()`, called +from the constructor at line 344, while both `m_queryEdit` (495) and +`m_messageView` (668) are constructed inside `buildUi()`, called one line +earlier at 343. Both pointers are live by the time the lambda is defined or +invoked. Do not add an `if (m_messageView)` guard: it would be dead code that +implies a hazard which does not exist. + +- [ ] **Step 5: Build and run the full suite** + +```bash +cmake --build build && ctest --test-dir build --output-on-failure +``` + +Expected: builds clean, all tests pass, no behaviour change. + +- [ ] **Step 6: Commit** + +```bash +git add src/messageview.h src/messageview.cpp src/messagedetailsdialog.h \ + src/messagedetailsdialog.cpp src/mainwindow.cpp +git commit -S -m "feat(search): push the query bar's emptiness into the panes + +The menus cannot read the query bar and must not. MainWindow already +watched textChanged for the Save button; the same lambda now tells +MessageView, which passes it to the details dialog at construction. + +Nothing consumes it yet. + +Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>" +``` + +--- + +### Task 5: The third menu entry + +**Files:** +- Modify: `src/messageview.cpp:566` +- Modify: `src/messagedetailsdialog.cpp:~88` +- Test: `tests/test_messagedetailsdialog.cpp` + +- [ ] **Step 1: Write the failing test** + +Add to the `private slots:` block in `tests/test_messagedetailsdialog.cpp`: + +```cpp + void excludeIsOfferedOnlyWithAQueryToExcludeFrom(); +``` + +And the implementation, modelled on the existing +`offersASearchForEachValue()`. Read that test first and reuse its fixture +construction verbatim, including how it builds `items` and finds a row: + +```cpp +void TestMessageDetailsDialog::excludeIsOfferedOnlyWithAQueryToExcludeFrom() +{ + // Build `items` exactly as offersASearchForEachValue() does. + const QList<ThreadRenderItem> items = /* same fixture as that test */; + + // The menu is built per row in a customContextMenuRequested lambda and + // cannot be popped without a real context-menu event, so assert on the + // property the entry's enabled state is derived from: the dialog was told + // whether a query exists. + MessageDetailsDialog withQuery(items, true); + MessageDetailsDialog withoutQuery(items, false); + + QVERIFY(withQuery.canExcludeFromSearch()); + QVERIFY(!withoutQuery.canExcludeFromSearch()); + + // And the emit itself refuses when there is nothing to exclude from, so + // the guard does not rest on the menu alone. + const auto rows = withoutQuery.rows(); + const auto *from = std::find_if( + rows.cbegin(), rows.cend(), + [](const HeaderRow &row) { return !row.query.isEmpty(); }); + QVERIFY(from != rows.cend()); + + QSignalSpy spy(&withoutQuery, &MessageDetailsDialog::searchRequested); + withoutQuery.requestSearch(*from, SearchTerm::SearchMode::Exclude); + QCOMPARE(spy.count(), 0); +} +``` + +This requires one small accessor, added in the same step, in +`src/messagedetailsdialog.h`: + +```cpp + /// Whether "Exclude from search" is offered. False with an empty query + /// bar: there would be nothing to exclude from. Exposed for testing + /// without popping a context menu. + bool canExcludeFromSearch() const { return m_hasQuery; } +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_messagedetailsdialog +``` + +Expected: FAIL on the `spy.count()` assertion, because `requestSearch` does not +yet refuse an Exclude with no query. (`canExcludeFromSearch()` passes as soon +as the accessor exists, which is fine; the emit guard is the real subject.) + +- [ ] **Step 3: Guard the emit** + +In `src/messagedetailsdialog.cpp`, `requestSearch`: + +```cpp +void MessageDetailsDialog::requestSearch(const HeaderRow &row, + SearchTerm::SearchMode mode) +{ + if (row.query.isEmpty()) + return; + // Nothing to exclude from: the entry is greyed, and this is the second + // layer in case it is reached another way. + if (mode == SearchTerm::SearchMode::Exclude && !m_hasQuery) + return; + emit searchRequested(row.query, mode); +} +``` + +- [ ] **Step 4: Add the entry to the dialog's menu** + +In `src/messagedetailsdialog.cpp`, after the `narrow` action in the per-row +lambda and before `menu.exec(...)`: + +```cpp + auto *exclude = + menu.addAction(tr("Exclude from search")); + // Visible but greyed rather than hidden: someone + // exploring a fresh window is exactly who should see + // that the feature exists. + exclude->setEnabled(m_hasQuery); + connect(exclude, &QAction::triggered, this, + [this, row]() { + requestSearch( + row, SearchTerm::SearchMode::Exclude); + }); +``` + +- [ ] **Step 5: Add the entry to the message pane's menus** + +In `src/messageview.cpp`, `addSearchEntries`, after the `narrow` action: + +```cpp + auto *exclude = sub->addAction(tr("Exclude from search")); + // Visible but greyed rather than hidden, as in the details dialog. + exclude->setEnabled(m_hasQuery); + connect(exclude, &QAction::triggered, this, [this, entry]() { + emit searchRequested(entry.query, SearchTerm::SearchMode::Exclude); + }); +``` + +- [ ] **Step 6: Run the full suite** + +```bash +cmake --build build && ctest --test-dir build --output-on-failure +``` + +Expected: all tests pass, including the new one. + +- [ ] **Step 7: Mutation check** + +Verify the new test can fail. Temporarily change the guard in `requestSearch` +to `if (false && mode == ...)`, rebuild, and confirm +`test_messagedetailsdialog` FAILS. Then revert the change and rebuild. + +**Do not restore from a backup file.** A backup taken before a fix and restored +afterwards silently reverted a fix in the item 85 session, leaving the test and +its comment orphaned. Edit the line back by hand and re-read it. + +**If the binary hangs rather than failing**, that is item 85's ordering trap: +without `accept()` before the emit, nothing leaves `exec()`. Kill the binary +and rebuild before concluding anything, since a later `ctest` will otherwise +re-run a stale one. + +- [ ] **Step 8: Commit** + +```bash +git add src/messageview.cpp src/messagedetailsdialog.h \ + src/messagedetailsdialog.cpp tests/test_messagedetailsdialog.cpp +git commit -S -m "feat(search): offer Exclude from search in both menus + +Third entry in the message pane's submenus and in each details row, +greyed rather than hidden when the query bar is empty, so the feature +stays visible to someone exploring a fresh window. + +requestSearch refuses an Exclude with no query as well, so the guard +does not rest on the menu's enabled state alone. + +Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>" +``` + +--- + +### Task 6: Close the item + +**Files:** +- Modify: `docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md` +- Modify: `docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md` +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Run the full suite one more time** + +```bash +cmake --build build && ctest --test-dir build --output-on-failure +``` + +Record the actual pass count for the commit message. Do not write a number you +did not read. + +- [ ] **Step 2: Hand it to the user** + +Do NOT launch the application. Tell the user what to look at: right-click a +header value, a body selection and a details row, with the query bar empty and +then with a query in it, and confirm the third entry is greyed in the first +case and narrows correctly in the second. Wait for their verdict before the +remaining steps. + +- [ ] **Step 3: Move the backlog section** + +Set item 86's status cell to `**done** 2026-08-14` with the spec reference, and +move its whole `## 86.` section from the backlog to +`2026-08-03-post-0.1.0-usability-closed.md`, keeping the number. The backlog +keeps the table row only. Move it on this commit, not in a later cleanup pass. + +- [ ] **Step 4: Add the changelog entry** + +Under `## [Unreleased]`, in `### Added`: + +```markdown +- A third right-click search action, **Exclude from search**, which narrows the + current query by everything that is not the value under the cursor. Offered + everywhere the other two are, and greyed out when the query bar is empty, + since there would be nothing to exclude from. +``` + +- [ ] **Step 5: Commit** + +```bash +git add docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md \ + docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md \ + CHANGELOG.md +git commit -S -m "docs: close item 86, excluding a value from a search + +Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>" +``` + +--- + +## Self-review notes + +Spec coverage, section by section: the grammar and its empty-case table are +Task 1; the `SearchMode` enum is Task 2 and its four signatures Task 3; the +`setHasQuery` guard is Task 4; the menus are Task 5; the testing section is +split across Tasks 1 and 5, including the mutation check and both traps it +inherits. "Out of scope" needs no task, the thread list is untouched throughout. + +Type consistency: `SearchTerm::SearchMode` is spelled with its namespace at +every use outside `searchterm.h`. `m_hasQuery` is the member in both +`MessageView` and `MessageDetailsDialog`; `setHasQuery` is the setter on the +view and a constructor argument on the dialog, deliberately different because +the dialog is rebuilt per invocation. + +Two steps ask the implementer to read surrounding code rather than trusting the +plan: the spy assertions in Task 3 Step 1, and the dialog fixture in Task 5 +Step 1. Both are existing test code whose exact shape is not reproduced here; +the plan says so explicitly rather than inventing it. |
