summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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"