From f7f868ce00e01e4026be19437242537967875ce5 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 14 Aug 2026 12:53:58 +0200 Subject: feat(search): offer the header's fields for searching The menu lists what is searchable rather than hit-testing which line of a rich-text label was clicked, which breaks as soon as the label wraps. The values are collected by the pass that renders the header, so nothing parses the markup back into structure. From, To and Cc appear only for a single-message thread, sharing the condition with the header's own display: a thread's recipient differs message to message, and the menu must not offer what the header is not stating. The test fixture's Date: header named the wrong weekday, which Qt::RFC2822Date rejects outright, so no date offer would have been produced from it. --- tests/test_messageview.cpp | 99 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) (limited to 'tests/test_messageview.cpp') diff --git a/tests/test_messageview.cpp b/tests/test_messageview.cpp index e94fb86..6403308 100644 --- a/tests/test_messageview.cpp +++ b/tests/test_messageview.cpp @@ -49,6 +49,9 @@ private slots: void detailsDialogIsOfferedForEveryThread(); void placeholderRendersAndReportsItself(); void aMessageBodyCannotRunAQuery(); + void headerOffersSubjectDateAndSenderForOneMessage(); + void headerOffersNoSenderForARealThread(); + void headerOffersNothingForAnAbsentField(); private: QWebEngineView *webViewOf(MessageView *view) const @@ -372,7 +375,7 @@ static ThreadRenderItem oneMessage() message.to = QStringLiteral("Recipient "); message.cc = QStringLiteral("Copied "); message.subject = QStringLiteral("Quarterly report"); - message.date = QStringLiteral("Mon, 4 Aug 2026 09:00:00 +0200"); + message.date = QStringLiteral("Tue, 4 Aug 2026 09:00:00 +0200"); message.plainBody = QStringLiteral("body"); ThreadRenderItem item; @@ -566,5 +569,99 @@ void TestMessageView::aMessageBodyCannotRunAQuery() QVERIFY(queries.isEmpty()); } +void TestMessageView::headerOffersSubjectDateAndSenderForOneMessage() +{ + MessageView view; + view.showThread({ oneMessage() }); + + const QList offers = view.headerSearchOffers(); + + QStringList queries; + for (const SearchOffer &offer : offers) + queries << offer.query; + const QString shown = queries.join(QStringLiteral(" | ")); + + QVERIFY2(queries.contains(QStringLiteral("subject:\"Quarterly report\"")), + qPrintable(shown)); + QVERIFY2(queries.contains( + QStringLiteral("from:\"Sender \"")), + qPrintable(shown)); + QVERIFY2(queries.contains( + QStringLiteral("to:\"Recipient \"")), + qPrintable(shown)); + QVERIFY2(queries.contains( + QStringLiteral("cc:\"Copied \"")), + qPrintable(shown)); + + // The date is offered as a one-day range. Qt::RFC2822Date checks the + // weekday against the date, so a fixture with the wrong day silently + // produces no offer at all: 2026-08-04 is a Tuesday. + QVERIFY2(queries.contains(QStringLiteral("date:2026-08-04..2026-08-04")), + qPrintable(shown)); + + // Every offer carries a label the menu shows, naming the value so the user + // can see what they are about to search for. + for (const SearchOffer &offer : offers) { + QVERIFY(!offer.label.isEmpty()); + QVERIFY(!offer.query.isEmpty()); + } +} + +void TestMessageView::headerOffersNoSenderForARealThread() +{ + // The header shows From/To/Cc only for a single-message thread, because a + // thread's recipient differs message to message. The menu shares that + // condition: it must never offer a value the header is not stating. + ThreadRenderItem first = oneMessage(); + ThreadRenderItem second = oneMessage(); + second.message.from = QStringLiteral("Recipient "); + second.message.to = QStringLiteral("Sender "); + + MessageView view; + view.showThread({ first, second }); + + QStringList queries; + for (const SearchOffer &offer : view.headerSearchOffers()) + queries << offer.query; + + // THE GUARD. A test asserting only that something is absent passes against + // no implementation whatever. Subject and date must still be offered, + // which proves the list was built before the absences below mean anything. + QVERIFY2(!queries.isEmpty(), "no offers at all: the list was never built"); + QVERIFY(queries.contains(QStringLiteral("subject:\"Quarterly report\""))); + QVERIFY(queries.contains(QStringLiteral("date:2026-08-04..2026-08-04"))); + + for (const QString &query : queries) { + QVERIFY2(!query.startsWith(QStringLiteral("from:")), + qPrintable(QStringLiteral("thread offered %1").arg(query))); + QVERIFY2(!query.startsWith(QStringLiteral("to:")), + qPrintable(QStringLiteral("thread offered %1").arg(query))); + QVERIFY2(!query.startsWith(QStringLiteral("cc:")), + qPrintable(QStringLiteral("thread offered %1").arg(query))); + } +} + +void TestMessageView::headerOffersNothingForAnAbsentField() +{ + // cc:"" parses cleanly and matches nothing, so an entry built from an + // empty header would look enabled and silently do nothing. + ThreadRenderItem item = oneMessage(); + item.message.cc.clear(); + + MessageView view; + view.showThread({ item }); + + QStringList queries; + for (const SearchOffer &offer : view.headerSearchOffers()) + queries << offer.query; + + // Guard first, then the absence. + QVERIFY2(!queries.isEmpty(), "no offers at all: the list was never built"); + QVERIFY(queries.contains(QStringLiteral("subject:\"Quarterly report\""))); + + for (const QString &query : queries) + QVERIFY(!query.startsWith(QStringLiteral("cc:"))); +} + QTEST_MAIN(TestMessageView) #include "test_messageview.moc" -- cgit v1.2.3 From 8a2e02a3e1a669a738d1de4615720378d5cc2d38 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 14 Aug 2026 12:56:53 +0200 Subject: feat(search): search for the selected body text selectedText() reads the selection with no script injection; JavaScript stays disabled in the profile. The page's standard menu is kept and the entries are added to it below a separator. The quoting is tested through a function taking the text, so it needs no live web engine: a selection is arbitrary prose and can carry quotes, newlines and query syntax, none of which notmuch reports as an error. --- src/messageview.cpp | 42 ++++++++++++++++++++++++++++++++++++++++++ src/messageview.h | 12 ++++++++++++ tests/test_messageview.cpp | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) (limited to 'tests/test_messageview.cpp') diff --git a/src/messageview.cpp b/src/messageview.cpp index 8a1976d..5f7c9d2 100644 --- a/src/messageview.cpp +++ b/src/messageview.cpp @@ -153,6 +153,12 @@ MessageView::MessageView(QWidget *parent) settings->setAttribute(QWebEngineSettings::PluginsEnabled, false); settings->setAttribute(QWebEngineSettings::FullScreenSupportEnabled, false); + // Item 85: a selection in the body is searchable. CustomContextMenu so the + // page's standard entries survive and the search is added to them. + m_view->setContextMenuPolicy(Qt::CustomContextMenu); + connect(m_view, &QWidget::customContextMenuRequested, + this, &MessageView::showBodyContextMenu); + // Ctrl+wheel zoom. The filter goes on the application rather than on // m_view: the wheel event is delivered to an internal QQuickWidget the // view creates lazily, so there is no child to filter at this point and a @@ -569,6 +575,42 @@ void MessageView::showHeaderContextMenu(const QPoint &pos) menu.exec(m_headerLabel->mapToGlobal(pos)); } +SearchOffer MessageView::selectionSearchOffer(const QString &selectedText) const +{ + const QString query = SearchTerm::quote(selectedText); + if (query.isEmpty()) + return {}; + + constexpr int kMaxLabel = 40; + const QString shown = selectedText.simplified(); + return { shown.size() > kMaxLabel + ? shown.left(kMaxLabel) + QStringLiteral("...") + : shown, + query }; +} + +void MessageView::showBodyContextMenu(const QPoint &pos) +{ + // The page's own menu first: copy, select all and the rest stay exactly as + // they were. This adds to that menu rather than replacing it. + QMenu *menu = m_view->createStandardContextMenu(); + if (!menu) + menu = new QMenu(this); + menu->setAttribute(Qt::WA_DeleteOnClose); + + // selectedText() reads the selection out of the render process with no + // script injection. JavaScript is disabled in this profile and stays so. + const SearchOffer offer = selectionSearchOffer(m_view->page()->selectedText()); + if (!offer.query.isEmpty()) { + menu->addSeparator(); + addSearchEntries(menu, { offer }); + } + + // popup() rather than exec(): the menu owns itself via WA_DeleteOnClose and + // must not block this handler. + menu->popup(m_view->mapToGlobal(pos)); +} + void MessageView::showDetailsDialog() { if (m_items.isEmpty()) diff --git a/src/messageview.h b/src/messageview.h index 26ed216..3e600f0 100644 --- a/src/messageview.h +++ b/src/messageview.h @@ -140,6 +140,15 @@ public: /// parsing it back: rich text does not survive a second parse. QList headerSearchOffers() const { return m_headerOffers; } + /// The offer for a body selection, its query empty when there is nothing + /// usable selected. + /// + /// Takes the text rather than reading the page, so the quoting is testable + /// without a live web engine and a rendered document. A selection is + /// arbitrary prose and can carry quotes, newlines and query syntax, none + /// of which notmuch reports as an error. + SearchOffer selectionSearchOffer(const QString &selectedText) const; + public slots: void toggleHtml(); void loadRemoteContent(); @@ -235,6 +244,9 @@ private: /// Builds and pops the header's menu at `pos`, in the label's coordinates. void showHeaderContextMenu(const QPoint &pos); + /// Builds and pops the web view's menu, keeping its standard entries. + void showBodyContextMenu(const QPoint &pos); + /// Appends a "Search for ..." submenu per offer, each holding the replace /// and the narrow operation. /// diff --git a/tests/test_messageview.cpp b/tests/test_messageview.cpp index 6403308..f83a377 100644 --- a/tests/test_messageview.cpp +++ b/tests/test_messageview.cpp @@ -52,6 +52,7 @@ private slots: void headerOffersSubjectDateAndSenderForOneMessage(); void headerOffersNoSenderForARealThread(); void headerOffersNothingForAnAbsentField(); + void bodySelectionBecomesAQuotedSearch(); private: QWebEngineView *webViewOf(MessageView *view) const @@ -663,5 +664,38 @@ void TestMessageView::headerOffersNothingForAnAbsentField() QVERIFY(!query.startsWith(QStringLiteral("cc:"))); } +void TestMessageView::bodySelectionBecomesAQuotedSearch() +{ + // The selection reaches the query as ONE quoted term. Asserted on the + // constructed string: a query that lost its quoting is not an error to + // notmuch, it simply matches nothing, so nothing downstream would report + // this being wrong. + // + // Takes the text as an argument rather than reading the page, so the + // quoting is testable without a live web engine and a rendered document. + MessageView view; + + QCOMPARE(view.selectionSearchOffer(QStringLiteral("invoice 4471")).query, + QStringLiteral("\"invoice 4471\"")); + + // A selection spanning paragraphs arrives full of newlines. + QCOMPARE(view.selectionSearchOffer( + QStringLiteral("first line\n\nsecond line")).query, + QStringLiteral("\"first line second line\"")); + + // Query syntax in the selection is data, not syntax: it is quoted, not + // interpreted, so a selection reading "a or b" searches for that phrase. + QCOMPARE(view.selectionSearchOffer(QStringLiteral("tag:inbox or x")).query, + QStringLiteral("\"tag:inbox or x\"")); + + // Nothing selected means no entry, rather than an entry searching for "". + QVERIFY(view.selectionSearchOffer(QString()).query.isEmpty()); + QVERIFY(view.selectionSearchOffer(QStringLiteral(" \n ")).query.isEmpty()); + + // A usable offer always carries a label for the menu to show. + QVERIFY(!view.selectionSearchOffer(QStringLiteral("invoice 4471")) + .label.isEmpty()); +} + QTEST_MAIN(TestMessageView) #include "test_messageview.moc" -- cgit v1.2.3 From 5b18bc2123d8bf0830ebf8ab3f735c9496342e97 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 14 Aug 2026 13:36:11 +0200 Subject: feat(search): run a search asked for from the message pane The panes carry a finished query and know nothing of the query bar; the window sets the field and calls the existing runner, so the account scope and the generation counter keep working as they do for a typed query. Narrowing combines here rather than in a pane, because only the window can see what the bar currently holds. The tag strip's chips join the header, the body selection and the details dialog as a fourth surface. Also fixes the details dialog to actually close when a search is chosen: the comment above the connection already described this requirement, but nothing called accept() or reject(), so the dialog stayed open, the query ran behind it, and the modal exec() never returned. This hung the whole test suite on QT_QPA_PLATFORM=offscreen once a covering test was added. --- src/mainwindow.cpp | 18 +++++++++++++ src/mainwindow.h | 7 ++++++ src/messageview.cpp | 30 ++++++++++++++++++++-- tests/test_mainwindow.cpp | 63 ++++++++++++++++++++++++++++++++++++++++++++++ tests/test_messageview.cpp | 53 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 169 insertions(+), 2 deletions(-) (limited to 'tests/test_messageview.cpp') diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index bb6aa43..115646e 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -55,6 +55,7 @@ #include "querycompleter.h" #include "carddelegate.h" #include "cardlayout.h" +#include "searchterm.h" #include "tagchip.h" #include "tagdialog.h" #include "savequerydialog.h" @@ -672,6 +673,8 @@ void MainWindow::buildUi() this, &MainWindow::onPlaceholderQueryRequested); connect(m_messageView, &MessageView::staleThreadRecoveryRequested, this, &MainWindow::recoverStaleThread); + connect(m_messageView, &MessageView::searchRequested, + this, &MainWindow::runSearchFromPane); m_splitter = new QSplitter(Qt::Horizontal, central); m_splitter->addWidget(m_threadView); @@ -1648,6 +1651,21 @@ void MainWindow::onPlaceholderQueryRequested(const QString &query) runCurrentQuery(); } +void MainWindow::runSearchFromPane(const QString &query, bool extend) +{ + if (query.isEmpty()) + return; + + const QString next = + extend ? SearchTerm::extend(m_queryEdit->text(), query) : query; + + // 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(); +} + void MainWindow::showWarnings() { const QStringList warnings = m_config.warnings() + m_keyMap.warnings(); diff --git a/src/mainwindow.h b/src/mainwindow.h index a68fcd9..f54a889 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -373,6 +373,13 @@ private slots: /// Runs a query the user clicked on the placeholder pane. void onPlaceholderQueryRequested(const QString &query); + /// Runs a search asked for from the message pane. + /// + /// `extend` narrows the current query rather than replacing it. The panes + /// carry a finished query and no knowledge of the bar; the combining + /// happens here, because only the window can see what the bar holds. + void runSearchFromPane(const QString &query, bool extend); + /// Runs one tagging rule's query in the thread list, so the user can see /// which mail it collects. The rules dialog stays open; the point is to /// compare the rule against its results. diff --git a/src/messageview.cpp b/src/messageview.cpp index fbd43d6..1e6c256 100644 --- a/src/messageview.cpp +++ b/src/messageview.cpp @@ -252,6 +252,20 @@ MessageView::MessageView(QWidget *parent) m_tagStrip = new TagStrip(this); m_tagStrip->hide(); + // Item 85: a tag chip is searchable. The strip reports which chip was hit + // and where; what a tag can do is decided here, beside the other menus, so + // all three surfaces offer the same pair of operations. + connect(m_tagStrip, &TagStrip::tagContextMenuRequested, this, + [this](const QString &tag, const QPoint &globalPos) { + const QString query = SearchTerm::tag(tag); + if (query.isEmpty()) + return; + + QMenu menu(this); + addSearchEntries(&menu, { { tr("tag %1").arg(tag), query } }); + menu.exec(globalPos); + }); + auto *layout = new QVBoxLayout(this); layout->addLayout(headerRow); layout->addLayout(blockedRow); @@ -616,10 +630,22 @@ void MessageView::showDetailsDialog() return; MessageDetailsDialog dialog(m_items, this); + // The dialog's searches are the pane's searches: one signal reaches the // window whichever surface the user used. - connect(&dialog, &MessageDetailsDialog::searchRequested, - this, &MessageView::searchRequested); + // + // It CLOSES on the way out, and that is not tidiness. The dialog is modal, + // so without this the query runs and the thread list repaints behind a + // window the user still has to dismiss, making the search look like it did + // nothing. The dialog is also built from m_items, which the new query is + // about to replace, so what it displays would describe a thread the pane + // has already stopped showing. + connect(&dialog, &MessageDetailsDialog::searchRequested, this, + [this, &dialog](const QString &query, bool extend) { + emit searchRequested(query, extend); + dialog.accept(); + }); + dialog.exec(); } diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp index 6e300bf..8b60e37 100644 --- a/tests/test_mainwindow.cpp +++ b/tests/test_mainwindow.cpp @@ -88,6 +88,9 @@ private slots: void markReadTimerIsNotArmedForAReadThread(); void aConfirmedEditArmsTheAutoSync(); void autoSyncDebouncesABurstOfEdits(); + void aSearchFromThePaneReplacesTheQuery(); + void aSearchFromThePaneCanNarrowTheQuery(); + void narrowingAnEmptyQueryBarIsAPlainSearch(); void autoSyncIsNotArmedWhenDisabledOrWithNothingPending(); void autoSyncSkipsWhileABackgroundSyncIsRunning(); void aSuccessfulSyncRefreshesRatherThanRerunningTheQuery(); @@ -3725,6 +3728,66 @@ void TestMainWindow::autoSyncDebouncesABurstOfEdits() 1); } +void TestMainWindow::aSearchFromThePaneReplacesTheQuery() +{ + const Config config; + MainWindow window(config); + + QLineEdit *queryEdit = + window.findChild(QStringLiteral("queryEdit")); + QVERIFY2(queryEdit, "no query bar: the window was never built"); + + MessageView *view = window.findChild(); + QVERIFY2(view, "no message view"); + + queryEdit->setText(QStringLiteral("tag:inbox")); + emit view->searchRequested(QStringLiteral("from:\"foo@example.org\""), false); + + QCOMPARE(queryEdit->text(), QStringLiteral("from:\"foo@example.org\"")); +} + +void TestMainWindow::aSearchFromThePaneCanNarrowTheQuery() +{ + // The case the feature exists for: a query returning a thousand threads is + // narrowed by adding a condition. BOTH sides are parenthesised, because + // 'a or b AND c' binds as 'a or (b AND c)', which WIDENS a search the user + // asked to narrow, and notmuch reports no error for it. + const Config config; + MainWindow window(config); + + QLineEdit *queryEdit = + window.findChild(QStringLiteral("queryEdit")); + QVERIFY2(queryEdit, "no query bar: the window was never built"); + + MessageView *view = window.findChild(); + QVERIFY2(view, "no message view"); + + queryEdit->setText(QStringLiteral("tag:inbox or tag:flagged")); + emit view->searchRequested(QStringLiteral("from:\"foo@example.org\""), true); + + QCOMPARE(queryEdit->text(), + QStringLiteral("(tag:inbox or tag:flagged) AND (from:\"foo@example.org\")")); +} + +void TestMainWindow::narrowingAnEmptyQueryBarIsAPlainSearch() +{ + // Rather than "() AND (x)", which matches nothing. + const Config config; + MainWindow window(config); + + QLineEdit *queryEdit = + window.findChild(QStringLiteral("queryEdit")); + QVERIFY2(queryEdit, "no query bar: the window was never built"); + + MessageView *view = window.findChild(); + QVERIFY2(view, "no message view"); + + queryEdit->clear(); + emit view->searchRequested(QStringLiteral("tag:inbox"), true); + + QCOMPARE(queryEdit->text(), QStringLiteral("tag:inbox")); +} + void TestMainWindow::autoSyncIsNotArmedWhenDisabledOrWithNothingPending() { // A negative delay is the switch that restores the pre-0.16.0 behaviour, so diff --git a/tests/test_messageview.cpp b/tests/test_messageview.cpp index f83a377..edcba03 100644 --- a/tests/test_messageview.cpp +++ b/tests/test_messageview.cpp @@ -24,6 +24,7 @@ #include #include "htmlbuilder.h" +#include "messagedetailsdialog.h" #include "messageview.h" #include "mimeparser.h" @@ -53,6 +54,7 @@ private slots: void headerOffersNoSenderForARealThread(); void headerOffersNothingForAnAbsentField(); void bodySelectionBecomesAQuotedSearch(); + void aSearchFromTheDetailsDialogClosesIt(); private: QWebEngineView *webViewOf(MessageView *view) const @@ -697,5 +699,56 @@ void TestMessageView::bodySelectionBecomesAQuotedSearch() .label.isEmpty()); } +void TestMessageView::aSearchFromTheDetailsDialogClosesIt() +{ + // The dialog is modal. Without closing it, the query runs and the thread + // list repaints BEHIND a window the user still has to dismiss, so the + // search looks like it did nothing. The dialog also describes m_items, + // which the new query is about to replace. + MessageView view; + view.showThread({ oneMessage() }); + + QSignalSpy spy(&view, &MessageView::searchRequested); + QVERIFY(spy.isValid()); + + // showDetailsDialog() blocks in exec(), so the dialog has to be driven + // from a timer once it is up. + bool foundTheDialog = false; + QTimer::singleShot(0, &view, [&view, &foundTheDialog]() { + auto *dialog = view.findChild(); + if (!dialog) { + // Never leave exec() spinning: a missing dialog must fail the test, + // not hang the suite. + QApplication::exit(1); + return; + } + foundTheDialog = true; + + const QList rows = dialog->rows(); + const auto from = std::find_if( + rows.cbegin(), rows.cend(), [](const HeaderRow &row) { + return row.field == QStringLiteral("from"); + }); + if (from == rows.cend()) { + dialog->reject(); + return; + } + + dialog->requestSearch(*from, false); + }); + + view.showDetailsDialog(); + + QVERIFY2(foundTheDialog, "the details dialog never appeared"); + QCOMPARE(spy.count(), 1); + QCOMPARE(spy.at(0).at(0).toString(), + QStringLiteral("from:\"Sender \"")); + + // exec() returned, which is the assertion: the dialog closed on its own + // rather than waiting for the user to dismiss it. + QVERIFY(!view.findChild() + || !view.findChild()->isVisible()); +} + QTEST_MAIN(TestMessageView) #include "test_messageview.moc" -- cgit v1.2.3