aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/mainwindow.cpp141
-rw-r--r--src/mainwindow.h26
-rw-r--r--tests/test_mainwindow.cpp91
3 files changed, 254 insertions, 4 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 4377870..8b1c537 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -118,6 +118,21 @@ namespace {
/// Overridden only by setLocksPathForTesting(); "/proc/locks" in every real run.
QString g_locksPath = QStringLiteral("/proc/locks");
+/// A rule as single-tag operations: `+a`, `+b`, `-c`, each as (add, remove).
+/// Item 212: the worker's effective set is per MESSAGE, not per tag, so one
+/// `+promo -inbox` write reports a message that already had promo (it lost
+/// inbox) and its undo would strip promo from it. One operation per tag makes
+/// each effective set, and therefore each undo, exact.
+QList<QPair<QStringList, QStringList>> ruleOperations(const TagRule &rule)
+{
+ QList<QPair<QStringList, QStringList>> ops;
+ for (const QString &tag : rule.add)
+ ops.append({ { tag }, {} });
+ for (const QString &tag : rule.remove)
+ ops.append({ {}, { tag } });
+ return ops;
+}
+
} // namespace
/// Doc comment on the declaration. Separators and control characters are
@@ -2808,6 +2823,9 @@ void MainWindow::showTagRulesDialog(const TagRule &seed)
connect(dialog, &TagRulesDialog::previewRequested,
this, &MainWindow::onRulePreviewRequested);
+ connect(dialog, &TagRulesDialog::applyRequested,
+ this, &MainWindow::applyRuleToExistingMail);
+
connect(dialog, &TagRulesDialog::countsRequested, this, [this, dialog]() {
QMetaObject::invokeMethod(
m_worker, "requestMessageCounts", Qt::QueuedConnection,
@@ -2821,6 +2839,16 @@ void MainWindow::showTagRulesDialog(const TagRule &seed)
void MainWindow::onRuleCountsReady(const QVector<int> &counts,
quint64 generation)
{
+ // Item 212. Apply's count first: it shares this signal and the generation
+ // sequence, and needs no dialog, since the test and a closed dialog's
+ // last Apply both arrive here without one.
+ if (m_pendingApplyGeneration != 0
+ && generation == m_pendingApplyGeneration) {
+ m_pendingApplyGeneration = 0;
+ confirmRuleApply(counts);
+ return;
+ }
+
// Stale reply, or the dialog closed while the count was in flight. Both
// are ordinary rather than rare: counting every rule against a cold index
// takes seconds, which is long enough for the user to close the dialog or
@@ -2831,6 +2859,108 @@ void MainWindow::onRuleCountsReady(const QVector<int> &counts,
m_tagRulesDialog->setCounts(counts);
}
+void MainWindow::applyRuleToExistingMail(const TagRule &rule)
+{
+ if (!m_worker)
+ return;
+
+ // Refused rather than held: HeldEdit replays ids, and a backfill has none
+ // until the worker resolves the query. A deliberate deviation from the
+ // spec, recorded there.
+ if (aSyncHoldsTheWriteLock()) {
+ showTransientStatus(
+ tr("A sync is running; apply the rule again when it finishes."));
+ return;
+ }
+
+ // One count for the whole rule (what the confirmation names), then one per
+ // single-tag operation, in the order ruleOperations() lists them. The
+ // per-operation counts decide which writes are sent at all; see
+ // confirmRuleApply() for why the rule is split.
+ QStringList queries{ SearchTerm::changes(rule.query, rule.add, rule.remove) };
+ for (const auto &op : ruleOperations(rule))
+ queries.append(SearchTerm::changes(rule.query, op.first, op.second));
+
+ m_pendingApplyRule = rule;
+ m_pendingApplyGeneration = ++m_ruleCountGeneration;
+ QMetaObject::invokeMethod(
+ m_worker, "requestMessageCounts", Qt::QueuedConnection,
+ Q_ARG(QStringList, queries),
+ Q_ARG(quint64, m_pendingApplyGeneration));
+}
+
+void MainWindow::confirmRuleApply(const QVector<int> &counts)
+{
+ const TagRule rule = m_pendingApplyRule;
+ const int count = counts.value(0, 0);
+ if (count <= 0) {
+ showTransientStatus(
+ tr("Rule \"%1\" has nothing to change.").arg(rule.id));
+ return;
+ }
+
+ // The one confirmation for a reversible write, and why: the scope is the
+ // whole index, so the user cannot see what it will touch.
+ QStringList what;
+ if (!rule.add.isEmpty())
+ what.append(tr("Adds: %1.").arg(rule.add.join(QStringLiteral(", "))));
+ if (!rule.remove.isEmpty())
+ what.append(tr("Removes: %1.").arg(rule.remove.join(QStringLiteral(", "))));
+
+ QWidget *parent = m_tagRulesDialog ? static_cast<QWidget *>(m_tagRulesDialog)
+ : this;
+ QMessageBox box(parent);
+ box.setIcon(QMessageBox::Question);
+ box.setWindowTitle(tr("Apply rule"));
+ box.setText(tr("Apply rule \"%1\" to %n message(s)?", "", count).arg(rule.id));
+ box.setInformativeText(what.join(QStringLiteral(" "))
+ + QStringLiteral("\n")
+ + tr("You can undo this from the main window."));
+ box.setStandardButtons(QMessageBox::Yes | QMessageBox::Cancel);
+ box.setDefaultButton(QMessageBox::Cancel);
+ if (box.exec() != QMessageBox::Yes)
+ return;
+
+ // The box ran a nested event loop: a sync may have started meanwhile, and
+ // a read-write open would then block the worker behind its lock.
+ if (aSyncHoldsTheWriteLock()) {
+ showTransientStatus(
+ tr("A sync is running; apply the rule again when it finishes."));
+ return;
+ }
+
+ const QString description = tr("Apply rule \"%1\"").arg(rule.id);
+
+ // One write per single-tag operation, grouped so one Ctrl+Z reverts the
+ // whole rule (see ruleOperations() for why it is split). An operation
+ // whose count was 0 is NOT sent: applyTags() stays silent on an empty
+ // write, and an armed command that never hears back would steal the next
+ // write's confirmation. Commands are built with no ids; the worker
+ // resolves them and the confirmation fills each one (item 176).
+ const auto ops = ruleOperations(rule);
+ m_undoStack.beginMacro(description);
+ bool first = true;
+ for (int i = 0; i < ops.size(); ++i) {
+ if (counts.value(i + 1, 0) <= 0)
+ continue;
+ auto *command = new MessageTagCommand(this, {}, ops.at(i).first,
+ ops.at(i).second, description);
+ awaitTagConfirmation(command, first);
+ first = false;
+ QMetaObject::invokeMethod(
+ m_worker, "applyTagsToQuery", Qt::QueuedConnection,
+ Q_ARG(QString, rule.query), Q_ARG(QStringList, ops.at(i).first),
+ Q_ARG(QStringList, ops.at(i).second), Q_ARG(QString, description));
+ m_undoStack.push(command);
+ }
+ m_undoStack.endMacro();
+
+ // The rows a backfill touches are mostly not in the model, so the list is
+ // re-queried once the write has run rather than updated optimistically.
+ announceAction(tr("%1: %n message(s)", "", count).arg(description),
+ [this]() { refreshCurrentQuery(); });
+}
+
void MainWindow::showAbout()
{
QDialog dialog(this);
@@ -5877,11 +6007,14 @@ void MainWindow::onExternalSyncStateChanged(SyncMonitor::State state)
}
-void MainWindow::announceAction(const QString &text)
+void MainWindow::announceAction(const QString &text,
+ const std::function<void()> &onDone)
{
if (!aSyncHoldsTheWriteLock()) {
if (!m_worker) {
showTransientStatus(text);
+ if (onDone)
+ onDone();
return;
}
@@ -5899,8 +6032,10 @@ void MainWindow::announceAction(const QString &text)
m_statusTimer->stop();
m_statusLabel->setText(tr("%1, applying…").arg(text));
const quint64 ticket = ++m_announceTicket;
- QMetaObject::invokeMethod(m_worker, [this, text, ticket]() {
- QMetaObject::invokeMethod(this, [this, text, ticket]() {
+ QMetaObject::invokeMethod(m_worker, [this, text, ticket, onDone]() {
+ QMetaObject::invokeMethod(this, [this, text, ticket, onDone]() {
+ if (onDone)
+ onDone();
// A newer announcement, or an error, owns the label now.
if (ticket == m_announceTicket)
showTransientStatus(tr("%1, done").arg(text));
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 45d9288..5c9e87b 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -160,6 +160,13 @@ public:
/// synthesising a click on a status-bar label.
Q_INVOKABLE void showPendingChanges();
+ /// Item 212. Counts what `rule` would change across the whole index,
+ /// confirms with that count, then writes through the worker with exact
+ /// undo. The rule has already been saved by the dialog. Refused while a
+ /// sync holds the write lock, since a backfill has no ids to hold until
+ /// the worker resolves its query.
+ void applyRuleToExistingMail(const TagRule &rule);
+
/// Whether the undo stack still holds anything. Exposed so a test can show
/// that a rejected write did not take unrelated history down with it.
bool canUndo() const { return m_undoStack.canUndo(); }
@@ -780,7 +787,13 @@ private slots:
/// describes state lasting until the sync ends, and a message that expired
/// would leave rows showing a tag the database has not got and no
/// explanation of why.
- void announceAction(const QString &text);
+ ///
+ /// `onDone`, when given, runs once the worker has reached the write: the
+ /// caller's follow-up (a refresh), not a label, so it runs whichever
+ /// announcement owns the label by then. Not run on the sync-held branch;
+ /// no caller passes one while a sync holds the lock.
+ void announceAction(const QString &text,
+ const std::function<void()> &onDone = {});
/// Reacts to a sync started outside this window, by cron or by hand.
///
@@ -845,6 +858,11 @@ private slots:
void onRuleCountsReady(const QVector<int> &counts, quint64 generation);
private:
+ /// The count for applyRuleToExistingMail() arrived: confirm, then write.
+ /// `counts` holds the whole rule's count first, then one per single-tag
+ /// operation in the order ruleOperations() lists them.
+ void confirmRuleApply(const QVector<int> &counts);
+
void buildUi();
/// Pushes the selected account's identity into the card delegate, so the
@@ -1825,6 +1843,12 @@ private:
/// counter.
quint64 m_ruleCountGeneration = 0;
+ /// The rule awaiting its change count, and the generation that count
+ /// will carry. Shares m_ruleCountGeneration's sequence, so a pending
+ /// Apply and the dialog's Count matches can never be confused.
+ TagRule m_pendingApplyRule;
+ quint64 m_pendingApplyGeneration = 0;
+
/// The generation of a REFRESH query, run after a sync to bring the list
/// up to date without disturbing it.
///
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 71795fa..4887647 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -29,6 +29,7 @@
#include <QKeyEvent>
#include <QLabel>
#include <QLineEdit>
+#include <QMessageBox>
#include <QMenuBar>
#include <QMenu>
#include <QPushButton>
@@ -79,6 +80,7 @@
#include "tagstrip.h"
#include "threaddashboard.h"
#include "threadlistmodel.h"
+#include "tagrules.h"
#include "threadlistview.h"
#include "notmuchfixture.h"
@@ -296,6 +298,7 @@ private slots:
void aMalformedAccountIsReportedWithoutBlockingTheConstructor();
void aWorkerBackedWindowReturnsRealThreads();
void aWriteSaysItIsApplyingUntilTheWorkerHasRunIt();
+ void applyingARuleConfirmsWithTheCountAndUndoesExactly();
void aPurgeTakesTheRowsOutOfTheViewWithoutARefresh();
void theDashboardFollowsAWriteToTheConversationItShows();
void anEditHeldByASyncSaysSoInsteadOfClaimingItLanded();
@@ -9929,6 +9932,94 @@ void TestMainWindow::aWriteSaysItIsApplyingUntilTheWorkerHasRunIt()
qPrintable(status->text()), 15000);
}
+void TestMainWindow::applyingARuleConfirmsWithTheCountAndUndoesExactly()
+{
+ // Items 212 and 176. Two matching messages in OPPOSITE states: `had`
+ // already carries promo, `lacked` does not. The confirmation must count
+ // what the whole rule moves, and undo must leave `had` tagged.
+ WorkerBackedWindow backed;
+ // Friday, verified with `date -d 2026-08-14 +%A`.
+ const QString date = QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200");
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("inbox"), QStringLiteral("had@example.org"),
+ QStringLiteral("One"), QStringLiteral("deals@example.net"), date,
+ QStringLiteral("Body.")));
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("inbox"), QStringLiteral("lacked@example.org"),
+ QStringLiteral("Two"), QStringLiteral("deals@example.net"), date,
+ QStringLiteral("Body.")));
+ QVERIFY2(backed.build(), qPrintable(backed.error()));
+
+ const auto notmuch = [&backed](const QStringList &args) {
+ QProcess p;
+ QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
+ env.insert(QStringLiteral("NOTMUCH_CONFIG"),
+ backed.fixture().configPath());
+ p.setProcessEnvironment(env);
+ p.start(QStringLiteral("notmuch"), args);
+ p.waitForFinished();
+ return QString::fromUtf8(p.readAllStandardOutput()).trimmed();
+ };
+ notmuch({ QStringLiteral("tag"), QStringLiteral("+promo"), QStringLiteral("--"),
+ QStringLiteral("id:had@example.org") });
+ // Guards: the fixture state the assertions below depend on.
+ QCOMPARE(notmuch({ QStringLiteral("count"), QStringLiteral("tag:inbox") }),
+ QStringLiteral("2"));
+ QCOMPARE(notmuch({ QStringLiteral("count"),
+ QStringLiteral("tag:promo and id:had@example.org") }),
+ QStringLiteral("1"));
+
+ MainWindow window(backed.config());
+ auto *undo = window.findChild<QAction *>(QStringLiteral("undo"));
+ QVERIFY(undo);
+
+ TagRule rule;
+ rule.id = QStringLiteral("promo");
+ rule.query = QStringLiteral("from:deals@example.net");
+ rule.add = { QStringLiteral("promo") };
+ rule.remove = { QStringLiteral("inbox") };
+
+ // Answer the confirmation when it appears, recording what it said.
+ QString asked;
+ QTimer poll;
+ poll.setInterval(20);
+ QObject::connect(&poll, &QTimer::timeout, [&]() {
+ auto *box = qobject_cast<QMessageBox *>(QApplication::activeModalWidget());
+ if (!box)
+ return;
+ asked = box->text();
+ poll.stop();
+ box->button(QMessageBox::Yes)->click();
+ });
+ poll.start();
+
+ window.applyRuleToExistingMail(rule);
+ QTRY_VERIFY_WITH_TIMEOUT(!asked.isEmpty(), 15000);
+ // The whole rule's count: both messages still carry inbox, so both
+ // would move, even though `had` already has promo.
+ QVERIFY2(asked.contains(QStringLiteral("2")), qPrintable(asked));
+
+ QTRY_COMPARE_WITH_TIMEOUT(
+ notmuch({ QStringLiteral("count"), QStringLiteral("tag:inbox") }),
+ QStringLiteral("0"), 15000);
+ QTRY_COMPARE_WITH_TIMEOUT(
+ notmuch({ QStringLiteral("count"), QStringLiteral("tag:promo") }),
+ QStringLiteral("2"), 15000);
+
+ undo->trigger();
+ QTRY_COMPARE_WITH_TIMEOUT(
+ notmuch({ QStringLiteral("count"), QStringLiteral("tag:inbox") }),
+ QStringLiteral("2"), 15000);
+ QTRY_COMPARE_WITH_TIMEOUT(
+ notmuch({ QStringLiteral("count"),
+ QStringLiteral("tag:promo and id:lacked@example.org") }),
+ QStringLiteral("0"), 15000);
+ // `had` carried promo before the backfill, so undo must not take it off.
+ QCOMPARE(notmuch({ QStringLiteral("count"),
+ QStringLiteral("tag:promo and id:had@example.org") }),
+ QStringLiteral("1"));
+}
+
/// Item 181, from the user: "the thread dashboard doesn't update live with the
/// modifications applied to the list pane. If I mark the thread as read, the
/// dash still reports N unread".