diff options
| -rw-r--r-- | src/mainwindow.cpp | 141 | ||||
| -rw-r--r-- | src/mainwindow.h | 26 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 91 |
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". |
