aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-13 17:00:55 +0200
committerDanilo M. <danix@danix.xyz>2026-08-13 17:00:55 +0200
commitb8de7ce746718c7193e40b0f70cd3e2178f4ed8a (patch)
treeab280755094321666d29d50694c48722809d953c
parent1ab12862fd47bf3816f7fad7b8a96eceb8f84272 (diff)
downloadqtmaildir-b8de7ce746718c7193e40b0f70cd3e2178f4ed8a.tar.gz
qtmaildir-b8de7ce746718c7193e40b0f70cd3e2178f4ed8a.zip
feat(rules): preview a rule's mail in the thread list
Item 77. The dialog could say how many messages a rule matched and not which ones. A Preview in list button now runs the selected rule's query in the main window; the dialog stays open, since comparing the rule against its results is the point. Two constraints from the backlog entry, both now asserted and both mutation-checked. The query runs exactly as stored, with no tag:new and no wrapping parentheses. The post-new hook adds those when it applies a rule, and a preview that copied them would match nothing outside a sync window, since tag:new is set only on mail that has just arrived. The account selector is cleared first. runQuery() wraps the bar's text in the selected account's scope, and a rule query usually names its own path already, so previewing one with an account selected would scope it twice and show an empty list, which reads as "this rule collects no mail". The second mutation only fails once the test's config has an account to select: with the default empty config the selector sits on "All accounts" anyway, and asserting that a preview leaves it there passed against the mutation. Recorded in the test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--CHANGELOG.md6
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md14
-rw-r--r--src/mainwindow.cpp43
-rw-r--r--src/mainwindow.h26
-rw-r--r--src/tagrulesdialog.cpp24
-rw-r--r--src/tagrulesdialog.h14
-rw-r--r--tests/test_tagrules.cpp74
7 files changed, 200 insertions, 1 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e46584a..901734d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,12 @@ point at which they are stable.
on disk, so a folder that exists but has no mail in it yet is still offered.
It stays editable, so a folder in the rules file that is no longer on disk
still opens and still saves.
+- A **Preview in list** button in the tagging rules dialog runs the selected
+ rule's query in the main window, so you can see which mail a rule collects
+ rather than only how many messages it matches. The dialog stays open. The
+ query runs exactly as stored, without the `tag:new` scope the hook adds, and
+ the account selector is cleared first, since a rule query that names its own
+ folder would otherwise be scoped twice and match nothing.
- The rule list and the rule editor are now divided by a draggable splitter,
and the condition rows scroll instead of growing without limit. A rule with
eight senders used to squeeze the list to about one visible row, since the
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 110938d..eec544e 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
@@ -133,7 +133,7 @@ taking that too literally.
| 74 | "Searching..." keeps claiming a query is running while rows are already arriving | feedback | XS | open; cause measured 2026-08-11, the delay itself is the cold page cache and is not fixable here |
| 75 | The tagging rules window forgets its size and its column widths | persistence | S | **done** 2026-08-13 on `rule-builder`, unreleased. The window-kind question is left open, see below |
| 76 | Every field in the rules dialog is free text, so a rule is easy to get wrong | workflow | M | **done** 2026-08-13 on `rule-builder`, unreleased. 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 | open; the dialog counts matches, it cannot show them |
+| 77 | No way to see what a rule would collect, in the thread list | workflow | S | **done** 2026-08-13 on `rule-builder`, unreleased |
| 78 | No way to build a rule from something visible in a message | workflow | M | open; wants 76 first, so the created rule lands in a form that can hold it |
| 80 | A rule with many conditions squeezes the rule list to one visible row | defect | XS | **done** 2026-08-13 on `rule-builder`, unreleased. 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 |
@@ -4891,6 +4891,18 @@ means the preview discards whatever thread load was in flight.
**Size: S.**
+**Done 2026-08-13** on `rule-builder`, unreleased. A **Preview in list**
+button emits `previewRequested(query)`; `MainWindow::onRulePreviewRequested`
+clears the account selector, puts the query in the bar and runs it, then
+raises itself. The dialog stays open, which is the point.
+
+Both constraints above became assertions, and BOTH mutations were needed: a
+test that emitted the hook's `tag:new and (...)` wrapping fails, and one that
+skips the account reset fails. The second only bites once the test config
+actually has an account to select, since the default empty config leaves the
+selector on "All accounts" already and the assertion passed against the
+mutation until that was fixed.
+
## 78. No way to build a rule from something visible in a message
**Observed.** The user would like to select an address or another piece of a
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index e8b4dda..e2df6de 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -1324,6 +1324,9 @@ void MainWindow::showTagRulesDialog()
// setFolders refills the rows that exist by then.
QMetaObject::invokeMethod(m_worker, "requestFolders", Qt::QueuedConnection);
+ connect(dialog, &TagRulesDialog::previewRequested,
+ this, &MainWindow::onRulePreviewRequested);
+
connect(dialog, &TagRulesDialog::countsRequested, this, [this, dialog]() {
QMetaObject::invokeMethod(
m_worker, "requestMessageCounts", Qt::QueuedConnection,
@@ -1570,6 +1573,46 @@ void MainWindow::onCountsReady(const QVector<int> &counts, quint64 generation)
m_messageView->showPlaceholder(placeholderHelpers());
}
+QString MainWindow::queryTextForTesting() const
+{
+ return m_queryEdit->text();
+}
+
+QString MainWindow::selectedAccountForTesting() const
+{
+ return m_accountBox->currentData().toString();
+}
+
+void MainWindow::selectAccountForTesting(const QString &key)
+{
+ const int index = m_accountBox->findData(key);
+ if (index >= 0)
+ m_accountBox->setCurrentIndex(index);
+}
+
+void MainWindow::onRulePreviewRequested(const QString &query)
+{
+ // Unscoped, deliberately. runQuery() wraps the bar's text in the selected
+ // account's scope, and a rule query usually names its own path already
+ // (path:"work/**" is what every account rule looks like), so previewing
+ // one with an account selected would scope it twice and match nothing.
+ // That reads as "this rule collects no mail", which is the opposite of
+ // what the preview is for.
+ m_accountBox->setCurrentIndex(0);
+
+ // Through the query bar, like onPlaceholderQueryRequested: the bar then
+ // shows what is on screen and the user can edit the rule's query there
+ // before deciding to change the rule itself.
+ m_queryEdit->setText(query);
+ runCurrentQuery();
+
+ // The dialog is a separate window and may be covering this one or sitting
+ // beside it. Raising makes the result visible either way, and the dialog
+ // stays open so the two can be compared.
+ raise();
+ activateWindow();
+}
+
void MainWindow::onPlaceholderQueryRequested(const QString &query)
{
// Through the query bar rather than straight to the worker, so the bar
diff --git a/src/mainwindow.h b/src/mainwindow.h
index adf63b0..18fbbb4 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -185,6 +185,27 @@ public:
/// The generation the next counts reply must carry to be accepted.
quint64 countsGenerationForTesting() const { return m_countsGeneration; }
+ /// The query bar's text, and the account the selector is scoped to
+ /// (empty for "All accounts"). Both are what a rule preview writes: the
+ /// bar so the user can see and edit what ran, and the selector because
+ /// runQuery() wraps the text in the selected account's scope, which would
+ /// double-scope a rule query that already names its own path.
+ QString queryTextForTesting() const;
+ QString selectedAccountForTesting() const;
+
+ /// Scopes the view to one account, as choosing it in the selector does.
+ /// A test for the rule preview needs this: with no account selected the
+ /// box already sits at "All accounts", so asserting that a preview leaves
+ /// it there passes whether or not the preview clears it.
+ void selectAccountForTesting(const QString &key);
+
+ /// Runs a rule preview without the dialog, which the offscreen platform
+ /// cannot click a button in.
+ void previewRuleQueryForTesting(const QString &query)
+ {
+ onRulePreviewRequested(query);
+ }
+
protected:
void closeEvent(QCloseEvent *event) override;
@@ -293,6 +314,11 @@ private slots:
/// Runs a query the user clicked on the placeholder pane.
void onPlaceholderQueryRequested(const QString &query);
+ /// 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.
+ void onRulePreviewRequested(const QString &query);
+
/// Opens the auto-tagging rules editor, or raises the one already open.
void showTagRulesDialog();
diff --git a/src/tagrulesdialog.cpp b/src/tagrulesdialog.cpp
index 285043a..4fca971 100644
--- a/src/tagrulesdialog.cpp
+++ b/src/tagrulesdialog.cpp
@@ -234,6 +234,11 @@ TagRulesDialog::TagRulesDialog(QWidget *parent)
buttons->addWidget(copyButton);
buttons->addWidget(deleteButton);
buttons->addStretch();
+ m_previewButton = new QPushButton(tr("&Preview in list"), this);
+ m_previewButton->setToolTip(
+ tr("Run this rule's query in the main window, to see which mail it "
+ "collects. This does not tag anything."));
+ buttons->addWidget(m_previewButton);
buttons->addWidget(refreshButton);
layout->addLayout(buttons);
@@ -251,6 +256,8 @@ TagRulesDialog::TagRulesDialog(QWidget *parent)
this, &TagRulesDialog::onCopyRule);
connect(deleteButton, &QPushButton::clicked,
this, &TagRulesDialog::onDeleteRule);
+ connect(m_previewButton, &QPushButton::clicked,
+ this, &TagRulesDialog::previewForTest);
connect(refreshButton, &QPushButton::clicked,
this, &TagRulesDialog::countsRequested);
connect(box, &QDialogButtonBox::accepted,
@@ -369,6 +376,23 @@ int TagRulesDialog::heightDemandedBelowListForTest() const
return m_splitter->widget(1)->minimumSizeHint().height();
}
+void TagRulesDialog::previewForTest()
+{
+ // Flush any half-typed edit first, so previewing shows what the rule
+ // says NOW rather than what it said when the row was selected.
+ applyEditsToCurrentRule();
+
+ const int index = currentIndex();
+ if (index < 0 || index >= m_working.size())
+ return;
+
+ const QString query = m_working.at(index).query;
+ if (query.isEmpty())
+ return;
+
+ emit previewRequested(query);
+}
+
int TagRulesDialog::conditionAreaHeightForTest() const
{
// The CAP itself, not a qMin against the scroll area's own size hint: a
diff --git a/src/tagrulesdialog.h b/src/tagrulesdialog.h
index de35a55..e8979fe 100644
--- a/src/tagrulesdialog.h
+++ b/src/tagrulesdialog.h
@@ -109,6 +109,10 @@ public:
/// the width the user had dragged.
void reloadListForTest();
+ /// Presses Preview, which is otherwise reachable only through a click on
+ /// a button whose geometry the offscreen platform does not guarantee.
+ void previewForTest();
+
/// The height the condition-row editor asks for. This is what squeezed
/// the rule list: a stretch factor only shares out space ABOVE each
/// widget's minimum, so every row added here came out of the list.
@@ -133,6 +137,15 @@ signals:
/// Asks the owner to run countQueries() through the worker.
void countsRequested();
+ /// Asks the owner to run one rule's query in the main window, so the user
+ /// can see WHICH mail a rule collects rather than how much.
+ ///
+ /// The query goes out exactly as stored: no `tag:new`, and no wrapping
+ /// parentheses. The post-new hook adds both when it applies a rule, and a
+ /// preview that copied them would match nothing outside a sync window,
+ /// since `tag:new` is set only on mail that has just arrived.
+ void previewRequested(const QString &query);
+
public slots:
/// Corpus counts, positionally paired with countQueries().
void setCounts(const QVector<int> &counts);
@@ -241,6 +254,7 @@ private:
/// still squeezed, because a stretch factor only shares out space above
/// each widget's minimum and the form's grew with every condition row.
QSplitter *m_splitter = nullptr;
+ QPushButton *m_previewButton = nullptr;
QVBoxLayout *m_rowsLayout = nullptr;
QVBoxLayout *m_exclusionsLayout = nullptr;
QLabel *m_exclusionsHeader = nullptr;
diff --git a/tests/test_tagrules.cpp b/tests/test_tagrules.cpp
index 268c41f..ed3a06d 100644
--- a/tests/test_tagrules.cpp
+++ b/tests/test_tagrules.cpp
@@ -20,6 +20,7 @@
#include <QTemporaryDir>
#include <QtTest>
+#include "config.h"
#include "mainwindow.h"
#include "rulequery.h"
#include "tagrules.h"
@@ -55,6 +56,8 @@ private slots:
void theWindowSizeIsSavedOnEveryWayOutOfTheDialog();
void aReloadDoesNotDiscardARestoredColumnWidth();
void manyConditionRowsDoNotSqueezeTheRuleList();
+ void previewEmitsTheRuleQueryAsStored();
+ void previewClearsTheAccountScope();
private:
QString writeRules(const QString &json);
@@ -867,5 +870,76 @@ void TestTagRules::manyConditionRowsDoNotSqueezeTheRuleList()
.arg(dialog.conditionAreaHeightForTest())));
}
+void TestTagRules::previewEmitsTheRuleQueryAsStored()
+{
+ // The query goes out EXACTLY as stored: no tag:new, no wrapping
+ // parentheses. The hook adds both when it applies a rule, and a preview
+ // that copied it would show nothing at all outside a sync window, since
+ // tag:new is only set on mail that has just arrived.
+ QTemporaryDir configHome;
+ QVERIFY(configHome.isValid());
+ qputenv("XDG_CONFIG_HOME", configHome.path().toUtf8());
+ QVERIFY(QDir().mkpath(configHome.filePath(QStringLiteral("mailrules"))));
+
+ QFile out(configHome.filePath(QStringLiteral("mailrules/rules.json")));
+ QVERIFY(out.open(QIODevice::WriteOnly));
+ out.write(R"({
+ "version": 1,
+ "rules": [
+ {"id": "promo", "query": "from:a.example.org or from:b.example.org",
+ "add": ["promo"], "stage": 50, "enabled": true}
+ ]
+ })");
+ out.close();
+
+ TagRulesDialog dialog;
+ QSignalSpy spy(&dialog, &TagRulesDialog::previewRequested);
+
+ dialog.selectRuleForTest(0);
+ dialog.previewForTest();
+
+ QCOMPARE(spy.count(), 1);
+ QCOMPARE(spy.first().at(0).toString(),
+ QStringLiteral("from:a.example.org or from:b.example.org"));
+}
+
+void TestTagRules::previewClearsTheAccountScope()
+{
+ // runQuery() wraps the bar's text in the selected account's scope. A rule
+ // query usually names its own path already (path:"work/**"), so previewing
+ // one while an account is selected would scope it twice and show nothing,
+ // which reads as "the rule matches no mail" rather than as a UI fault.
+ QTemporaryDir configHome;
+ QVERIFY(configHome.isValid());
+ qputenv("XDG_CONFIG_HOME", configHome.path().toUtf8());
+
+ // An account that can actually BE selected. With the default empty config
+ // the selector holds only "All accounts", so it sits at index 0 already
+ // and the assertion below passes whether or not the preview clears it:
+ // measured, the mutation removing the reset survived until this config
+ // was added.
+ const QString confPath = configHome.filePath(QStringLiteral("q.conf"));
+ QFile conf(confPath);
+ QVERIFY(conf.open(QIODevice::WriteOnly | QIODevice::Text));
+ conf.write("[account.work]\nmaildir=work-mail\n");
+ conf.close();
+
+ Config config;
+ config.load(confPath);
+ QCOMPARE(config.accounts().size(), 1);
+
+ MainWindow window(config);
+ window.selectAccountForTesting(QStringLiteral("work"));
+ QCOMPARE(window.selectedAccountForTesting(), QStringLiteral("work"));
+
+ window.previewRuleQueryForTesting(QStringLiteral("from:a.example.org"));
+
+ QCOMPARE(window.queryTextForTesting(),
+ QStringLiteral("from:a.example.org"));
+ QVERIFY2(window.selectedAccountForTesting().isEmpty(),
+ "a preview must run unscoped, or an account-scoped rule query "
+ "is wrapped twice and matches nothing");
+}
+
QTEST_MAIN(TestTagRules)
#include "test_tagrules.moc"