aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-14 18:04:32 +0200
committerDanilo M. <danix@danix.xyz>2026-08-14 18:04:32 +0200
commitde1f7dba196d59cdaa46d2938e5e40e40f97a692 (patch)
tree539fef2d664c1a83cc9ee7c7d69968ddc1f52b37
parent612eb64871de7e00f21a9b7ebf33d8179caa16bc (diff)
downloadqtmaildir-de1f7dba196d59cdaa46d2938e5e40e40f97a692.tar.gz
qtmaildir-de1f7dba196d59cdaa46d2938e5e40e40f97a692.zip
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. Mutation checked: disabling that condition fails the new test on the blocked emit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--src/messagedetailsdialog.cpp15
-rw-r--r--src/messagedetailsdialog.h5
-rw-r--r--src/messageview.cpp8
-rw-r--r--tests/test_messagedetailsdialog.cpp39
4 files changed, 67 insertions, 0 deletions
diff --git a/src/messagedetailsdialog.cpp b/src/messagedetailsdialog.cpp
index e862fce..b024989 100644
--- a/src/messagedetailsdialog.cpp
+++ b/src/messagedetailsdialog.cpp
@@ -98,6 +98,17 @@ MessageDetailsDialog::MessageDetailsDialog(const QList<ThreadRenderItem> &items,
requestSearch(
row, SearchTerm::SearchMode::Narrow);
});
+ 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);
+ });
menu.exec(value->mapToGlobal(pos));
});
}
@@ -156,5 +167,9 @@ void MessageDetailsDialog::requestSearch(const HeaderRow &row,
{
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);
}
diff --git a/src/messagedetailsdialog.h b/src/messagedetailsdialog.h
index b62618a..186006f 100644
--- a/src/messagedetailsdialog.h
+++ b/src/messagedetailsdialog.h
@@ -75,6 +75,11 @@ public:
/// The rows on display, in order. Exposed for testing without rendering.
QList<HeaderRow> rows() const { return m_rows; }
+ /// 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; }
+
/// 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.
diff --git a/src/messageview.cpp b/src/messageview.cpp
index 9dc5b36..bdf1b13 100644
--- a/src/messageview.cpp
+++ b/src/messageview.cpp
@@ -577,6 +577,14 @@ void MessageView::addSearchEntries(QMenu *menu, const QList<SearchOffer> &offers
connect(narrow, &QAction::triggered, this, [this, entry]() {
emit searchRequested(entry.query, SearchTerm::SearchMode::Narrow);
});
+
+ auto *exclude = sub->addAction(tr("Exclude from search"));
+ // Visible but greyed rather than hidden, as in the details dialog:
+ // there must be a query to exclude FROM.
+ exclude->setEnabled(m_hasQuery);
+ connect(exclude, &QAction::triggered, this, [this, entry]() {
+ emit searchRequested(entry.query, SearchTerm::SearchMode::Exclude);
+ });
}
}
diff --git a/tests/test_messagedetailsdialog.cpp b/tests/test_messagedetailsdialog.cpp
index 403750b..073611d 100644
--- a/tests/test_messagedetailsdialog.cpp
+++ b/tests/test_messagedetailsdialog.cpp
@@ -36,6 +36,7 @@ private slots:
void offersASearchForEachValue();
void omitsAnEmptyHeader();
void messageIdIsShownButNotSearchable();
+ void excludeIsOfferedOnlyWithAQueryToExcludeFrom();
private:
/// One message, with every header populated. The date's weekday matches
@@ -180,5 +181,43 @@ void TestMessageDetailsDialog::messageIdIsShownButNotSearchable()
QCOMPARE(spy.count(), 0);
}
+void TestMessageDetailsDialog::excludeIsOfferedOnlyWithAQueryToExcludeFrom()
+{
+ // Excluding from an empty query bar would mean the whole Maildir minus one
+ // value: a legitimate query, and an implausible thing to have meant by
+ // right-clicking a value in a fresh window.
+ MessageDetailsDialog withQuery({ oneMessage() }, true);
+ MessageDetailsDialog withoutQuery({ oneMessage() }, false);
+
+ // The menu is built inside a customContextMenuRequested lambda and cannot
+ // be popped without a real context-menu event, so assert on the property
+ // its enabled state is derived from.
+ QVERIFY(withQuery.canExcludeFromSearch());
+ QVERIFY(!withoutQuery.canExcludeFromSearch());
+
+ const QList<HeaderRow> rows = withoutQuery.rows();
+ const auto from = std::find_if(
+ rows.cbegin(), rows.cend(), [](const HeaderRow &row) {
+ return row.field == QStringLiteral("from");
+ });
+ QVERIFY2(from != rows.cend(), "no From row to search from");
+
+ // The emit refuses too, so the guard does not rest on the menu alone.
+ QSignalSpy blocked(&withoutQuery,
+ &MessageDetailsDialog::searchRequested);
+ QVERIFY(blocked.isValid());
+ withoutQuery.requestSearch(*from, SearchTerm::SearchMode::Exclude);
+ QCOMPARE(blocked.count(), 0);
+
+ // And with a query it goes through, so the guard is not simply refusing
+ // every exclude.
+ QSignalSpy allowed(&withQuery, &MessageDetailsDialog::searchRequested);
+ QVERIFY(allowed.isValid());
+ withQuery.requestSearch(*from, SearchTerm::SearchMode::Exclude);
+ QCOMPARE(allowed.count(), 1);
+ QCOMPARE(allowed.at(0).at(1).value<SearchTerm::SearchMode>(),
+ SearchTerm::SearchMode::Exclude);
+}
+
QTEST_MAIN(TestMessageDetailsDialog)
#include "test_messagedetailsdialog.moc"