summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md21
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md55
-rw-r--r--src/config.cpp19
-rw-r--r--src/config.h10
-rw-r--r--src/mainwindow.cpp171
-rw-r--r--src/mainwindow.h32
-rw-r--r--tests/test_config.cpp47
-rw-r--r--tests/test_mainwindow.cpp163
8 files changed, 498 insertions, 20 deletions
diff --git a/README.md b/README.md
index 5c7d81e..a52a211 100644
--- a/README.md
+++ b/README.md
@@ -119,6 +119,11 @@ identity.
; with Ctrl+U. Arrowing quickly through a list marks only the thread you stop
; on, never the ones you pass through.
; mark_read_delay_ms = 2000
+; Optional. What to do about unsynced tag changes when you quit. "ask" (the
+; default) offers to sync, quit anyway, or stay; "always" syncs without asking
+; and quits when it finishes; "never" quits silently. A sync that fails never
+; closes the window, so a failure cannot discard the changes quietly.
+; sync_on_exit = ask
[completion]
; Optional. Extra content types offered after mimetype:, APPENDED to the
@@ -243,6 +248,22 @@ an attachment, so it is visible without opening the thread. It comes from the
`attachment` tag notmuch applies while indexing, not from parsing the message,
and costs no extra query.
+## Unsynced changes
+
+Tagging changes the notmuch index at once, but the mail store only learns about
+it on the next sync. The status bar therefore counts the tag changes made here
+that a sync has not yet carried over, and clears the count when one succeeds. A
+**failed** sync leaves the count standing, since the changes really are still
+unsynced.
+
+The count is a lower bound rather than a guarantee: an external `notmuch new`
+from your own cron can carry changes over without this application noticing.
+
+Quitting with changes outstanding asks what to do, controlled by
+`[general] sync_on_exit`. See the configuration block above for the three
+values. When a sync started at exit fails, the window stays open and says so
+rather than quitting as though it had worked.
+
## Message details
The strip above the message pane says what it can say without guessing. A
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 7fedaf3..8f19d6b 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
@@ -56,8 +56,8 @@ taking that too literally.
| 15 | Attachments are parsed but unreachable from the UI | information | M | **done** |
| 16 | Delete on an already-deleted thread should undelete | behavior | S | open |
| 17 | No completion for tags in the query bar | workflow | M | **done** |
-| 18 | No visual cue that there are unsynced edits | feedback | S | open |
-| 19 | No prompt to sync on exit when edits are pending | behavior | S | open |
+| 18 | No visual cue that there are unsynced edits | feedback | S | **done** |
+| 19 | No prompt to sync on exit when edits are pending | behavior | S | **done** |
| 20 | Thread view does not match the user's mental model | presentation | ? | open, unspecified |
| 21 | Default shortcuts are not sensible enough | discoverability | S | open |
| 22 | Translatability audit and i18n wiring | correctness | M | open |
@@ -847,6 +847,27 @@ resets.
survives. Then sync and confirm it clears. Then make the sync fail and confirm
it does not.
+### Outcome (done)
+
+Built as specced. `m_pendingEdits` counts confirmed mutations, incremented in
+`onTagsApplied()` and reset only by `onSyncFinished(true, ...)`. The count is
+shown as a permanent status-bar widget, hidden entirely at zero.
+
+**Counted where a write is confirmed, not where one is sent.** An optimistic
+update the worker later rejects must not leave the indicator claiming an edit
+that never landed, so the increment sits in the `tagsApplied` handler.
+
+That handler was a lambda; it is now a named slot, which is both better
+structure and what lets a test drive it. An earlier draft tried to reach the
+worker with `findChild` to emit the real signal: the worker is deliberately
+parentless because it moves to its own thread, so that cannot work, and
+contorting the test to reach it was the wrong instinct. What matters is the
+counter's behaviour, not the signal's origin.
+
+**Both tests were verified by breaking the code.** Clearing the count on any
+sync outcome is caught, and so is clearing it where the undo stack is cleared,
+which is the naive design this item exists to avoid.
+
## 19. No prompt to sync on exit when edits are pending
**Observed (user, 2026-08-04):** quitting with unsynced edits is silent. The
@@ -891,6 +912,36 @@ for the reason item 18 documents.
each `sync_on_exit` value and confirm the behavior matches. Then quit with a
deliberately broken sync command and confirm the app neither hangs nor lies.
+### Outcome (done)
+
+Built as specced, with the user confirming the three-value key over their
+original on/off idea. `closeEvent()` consults the count, and a sync started for
+exit holds the window open until `finished` arrives rather than being killed
+mid-run.
+
+**A failed exit-sync does not quit.** It restores the window, shows the log and
+says what happened. Quitting there would discard the user's choice silently,
+which is the exact failure the prompt exists to prevent.
+
+**With no sync command configured the prompt degrades** to a warning offering
+Discard or Cancel, rather than offering a sync that cannot run.
+`MailSync::isAvailable()` is already false in that case.
+
+**Testing a modal needs care, and the first attempt was wrong.** A test that
+sends a close event hangs forever if an unexpected dialog opens, because the
+modal spins its own event loop. The first version appeared to pass in 19
+seconds; it had actually opened a real dialog on the user's screen, and the
+user dismissed it. `CloseProbe` now polls for `activeModalWidget()`, closes it,
+and records that one appeared, turning "a dialog opened" into an assertion
+instead of a hang or a prompt for whoever is watching.
+
+**One mutation test was a dud and is worth recording.** Removing the `Never`
+guard from `closeEvent` did NOT make the test fail, because the branches below
+match only `Ask` and `Always`, so `Never` fell through to closing anyway. The
+guard is redundant belt-and-braces rather than load-bearing. Confirming the
+probe really detects a prompt needed the config mutated to `ask` instead, which
+does fail it.
+
## 20. Thread view does not match the user's mental model
**Observed (user, 2026-08-04), in passing while deciding item 2:** "My view for
diff --git a/src/config.cpp b/src/config.cpp
index f5d2d15..8267835 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -98,6 +98,25 @@ void Config::load(const QString &path)
// Zero and negative are NOT errors and must not be clamped. Zero means mark
// read at once, and any negative value means never, which is how the
// behaviour is turned off.
+ // Three values, not a bool: "prompt me", "just do it" and "do nothing" are
+ // three distinct behaviours and true/false can only express two of them.
+ const QString syncExit =
+ settings.value(QStringLiteral("sync_on_exit"),
+ QStringLiteral("ask")).toString().trimmed().toLower();
+ if (syncExit == QStringLiteral("ask")) {
+ m_syncOnExit = SyncOnExit::Ask;
+ } else if (syncExit == QStringLiteral("always")) {
+ m_syncOnExit = SyncOnExit::Always;
+ } else if (syncExit == QStringLiteral("never")) {
+ m_syncOnExit = SyncOnExit::Never;
+ } else {
+ // Naming the accepted values, since a typo here silently changes what
+ // happens to unsynced work at exit.
+ addProblem(QStringLiteral("Unknown sync_on_exit '%1'; expected ask, "
+ "always or never. Using ask.")
+ .arg(syncExit));
+ }
+
const QVariant markRead = settings.value(QStringLiteral("mark_read_delay_ms"));
if (markRead.isValid()) {
bool ok = false;
diff --git a/src/config.h b/src/config.h
index 7c6eb63..ba9b7f6 100644
--- a/src/config.h
+++ b/src/config.h
@@ -102,6 +102,15 @@ public:
/// once it is known. The manual trigger works regardless.
bool completionOnFocus() const { return m_completionOnFocus; }
+ /// What to do about unsynced edits when the window closes.
+ enum class SyncOnExit {
+ Ask, ///< Prompt, offering to sync, quit anyway, or stay. The default.
+ Always, ///< Sync without asking, then quit once it finishes.
+ Never, ///< Quit silently, which is the behaviour before this existed.
+ };
+
+ SyncOnExit syncOnExit() const { return m_syncOnExit; }
+
/// How long an opened thread stays unread before it is marked read.
///
/// Three meanings, all deliberate: a positive value is the delay in
@@ -143,6 +152,7 @@ private:
qreal m_messageZoom = 1.0;
bool m_completionOnFocus = false;
int m_markReadDelayMs = 2000;
+ SyncOnExit m_syncOnExit = SyncOnExit::Ask;
QList<CompletionEntry> m_extraMimetypes;
QString m_startupQuery = QStringLiteral("Unread");
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 45db452..d8fba3e 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -145,6 +145,84 @@ void MainWindow::saveUiState() const
void MainWindow::closeEvent(QCloseEvent *event)
{
+ // A sync started for exit is still running: hold the window open. Its
+ // finished signal closes us, and asking again here would stack prompts.
+ if (m_syncingForExit) {
+ event->ignore();
+ return;
+ }
+
+ if (!m_closeApproved && m_pendingEdits > 0
+ && m_config.syncOnExit() != Config::SyncOnExit::Never) {
+
+ // Not a destructive-action confirmation, which CLAUDE.md forbids for
+ // tag mutations. Those get undo instead. This asks about LOSING work at
+ // the one point where undo cannot help, which is the opposite case.
+ const bool canSync = m_sync && m_sync->isAvailable();
+
+ if (!canSync) {
+ // Degrade to a warning rather than offering a sync that cannot run.
+ const auto answer = QMessageBox::warning(
+ this, tr("Unsynced changes"),
+ tr("%n tag change(s) have not been synced, and no sync command "
+ "is configured. Quit anyway?", "", m_pendingEdits),
+ QMessageBox::Discard | QMessageBox::Cancel,
+ QMessageBox::Cancel);
+ if (answer == QMessageBox::Cancel) {
+ event->ignore();
+ return;
+ }
+ } else if (m_config.syncOnExit() == Config::SyncOnExit::Ask) {
+ // Three buttons, not two: a user who hit Quit by mistake needs a
+ // way back that is not "sync".
+ QMessageBox box(this);
+ box.setIcon(QMessageBox::Question);
+ box.setWindowTitle(tr("Unsynced changes"));
+ box.setText(tr("%n tag change(s) have not been synced.", "",
+ m_pendingEdits));
+ box.setInformativeText(tr("Sync before quitting?"));
+ QPushButton *sync =
+ box.addButton(tr("Sync and quit"), QMessageBox::AcceptRole);
+ QPushButton *quit =
+ box.addButton(tr("Quit anyway"), QMessageBox::DestructiveRole);
+ box.addButton(QMessageBox::Cancel);
+ box.setDefaultButton(sync);
+ box.exec();
+
+ if (box.clickedButton() == sync) {
+ if (m_sync->start()) {
+ m_syncingForExit = true;
+ m_statusLabel->setText(tr("Syncing before quitting..."));
+ event->ignore();
+ return;
+ }
+ // Could not start after all: say so and stay, rather than
+ // quitting as though the sync had happened.
+ QMessageBox::warning(this, tr("Sync failed"),
+ tr("The sync could not be started, so "
+ "your changes are still unsynced."));
+ event->ignore();
+ return;
+ }
+ if (box.clickedButton() != quit) {
+ event->ignore(); // Cancel, or the dialog was dismissed.
+ return;
+ }
+ } else if (m_config.syncOnExit() == Config::SyncOnExit::Always) {
+ if (m_sync->start()) {
+ m_syncingForExit = true;
+ m_statusLabel->setText(tr("Syncing before quitting..."));
+ event->ignore();
+ return;
+ }
+ QMessageBox::warning(this, tr("Sync failed"),
+ tr("The sync could not be started, so your "
+ "changes are still unsynced."));
+ event->ignore();
+ return;
+ }
+ }
+
saveUiState();
QMainWindow::closeEvent(event);
}
@@ -243,6 +321,14 @@ void MainWindow::buildUi()
m_statusLabel = new QLabel(this);
statusBar()->addWidget(m_statusLabel);
+ // Beside the sync status rather than as a widget competing with it: the two
+ // say related things and reading them apart would be worse than reading
+ // them together.
+ m_pendingLabel = new QLabel(this);
+ m_pendingLabel->setObjectName(QStringLiteral("pendingEdits"));
+ m_pendingLabel->hide();
+ statusBar()->addPermanentWidget(m_pendingLabel);
+
// Query row.
auto *queryRow = new QHBoxLayout;
m_accountBox = new QComboBox(central);
@@ -422,10 +508,6 @@ void MainWindow::registerActions()
});
addAction(QStringLiteral("open_thread"), tr("&Open thread"),
tr("Focus the thread list"), [this]() {
- qDebug("[MW] open_thread action TRIGGERED (focus=%s)",
- QApplication::focusWidget()
- ? QApplication::focusWidget()->metaObject()->className()
- : "none");
m_threadView->setFocus();
});
addAction(QStringLiteral("archive"), tr("&Archive"),
@@ -739,20 +821,7 @@ void MainWindow::wireWorker()
// A confirmed write clears the pending revert: without this, a later
// unrelated error would roll back a change that actually succeeded.
connect(m_worker, &NotmuchWorker::tagsApplied,
- this, [this](const TagChange &change) {
- m_pendingChange = {};
- m_pendingThreadIds.clear();
-
- // A tag the user has just created is the one they are most likely to
- // type again, so do not wait for the next sync to offer it. A set
- // membership test, not a query.
- for (const QString &tag : change.added) {
- if (!m_knownTags.contains(tag)) {
- requestAllTags();
- break;
- }
- }
- });
+ this, &MainWindow::onTagsApplied);
m_workerThread.start();
@@ -939,16 +1008,82 @@ void MainWindow::onWorkerError(const QString &message)
void MainWindow::onSyncFinished(bool success, int exitCode)
{
if (success) {
+ // Only a SUCCESSFUL sync clears the count. Clearing on failure would
+ // assert the edits had reached the mail store when the sync is exactly
+ // what failed to put them there.
+ m_pendingEdits = 0;
+ updatePendingIndicator();
+
m_statusLabel->setText(tr("Sync complete"));
+
+ if (m_syncingForExit) {
+ // The work is safely across, so finish the quit the user asked for.
+ m_syncingForExit = false;
+ m_closeApproved = true;
+ close();
+ return;
+ }
+
runCurrentQuery();
// A sync is the usual way new tags enter the database.
requestAllTags();
} else {
m_statusLabel->setText(tr("Sync failed (exit %1)").arg(exitCode));
m_syncLog->show();
+
+ if (m_syncingForExit) {
+ // Do NOT quit: the edits are still unsynced and quitting now would
+ // discard the user's choice silently, which is the failure the
+ // whole prompt exists to prevent. Leave the window open with the
+ // log showing, so they can see what went wrong and decide.
+ m_syncingForExit = false;
+ QMessageBox::warning(
+ this, tr("Sync failed"),
+ tr("The sync failed (exit %1), so your changes are still "
+ "unsynced. The window has been left open.").arg(exitCode));
+ }
+ }
+}
+
+void MainWindow::onTagsApplied(const TagChange &change)
+{
+ m_pendingChange = {};
+ m_pendingThreadIds.clear();
+
+ // Counted here, where a write is CONFIRMED, rather than where one is sent:
+ // an optimistic update the worker later rejects must not leave the
+ // indicator claiming an edit that never landed.
+ ++m_pendingEdits;
+ updatePendingIndicator();
+
+ // A tag the user has just created is the one they are most likely to type
+ // again, so do not wait for the next sync to offer it. A set membership
+ // test, not a query.
+ for (const QString &tag : change.added) {
+ if (!m_knownTags.contains(tag)) {
+ requestAllTags();
+ break;
+ }
}
}
+void MainWindow::updatePendingIndicator()
+{
+ if (m_pendingEdits <= 0) {
+ m_pendingLabel->hide();
+ return;
+ }
+
+ // "Changes" and not "mutations": the unit the user thinks in is the tagging
+ // they did, not the writes it became.
+ m_pendingLabel->setText(tr("%n unsynced change(s)", "", m_pendingEdits));
+ m_pendingLabel->setToolTip(
+ tr("Tag changes made here that a sync has not yet carried to the mail "
+ "store. An external notmuch run can clear them without this count "
+ "noticing."));
+ m_pendingLabel->show();
+}
+
void MainWindow::scheduleMarkRead(const ThreadSummary &thread)
{
// Any pending timer belongs to a thread that is no longer on screen.
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 0eea164..b0a4cfd 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -88,6 +88,10 @@ private slots:
void onThreadLoaded(const QVector<MessageRef> &messages, quint64 generation);
void onWorkerError(const QString &message);
void onSyncFinished(bool success, int exitCode);
+
+ /// A tag mutation the worker has confirmed reached the database. Counts it
+ /// as unsynced, since reaching the index is not reaching the mail store.
+ void onTagsApplied(const TagChange &change);
void onAllTagsReady(const QStringList &tags);
private:
@@ -127,6 +131,19 @@ private:
/// the one on screen.
void markCurrentThreadRead();
+ /// Redraws the unsynced-edits indicator from m_pendingEdits.
+ void updatePendingIndicator();
+
+ /// Set once the user has answered the exit prompt, or once a sync started
+ /// for exit has finished. Stops closeEvent asking a second time, and is
+ /// what lets the deferred close through.
+ bool m_closeApproved = false;
+
+ /// True while a sync started by the exit prompt is running. The window
+ /// stays open until it finishes: killing the process mid-sync is exactly
+ /// the loss the prompt exists to prevent.
+ bool m_syncingForExit = false;
+
/// Sends a tag change for a set of threads without touching the undo stack.
/// Both tagSelected() and ThreadTagCommand route through this.
void sendThreadTagChange(const QStringList &threadIds,
@@ -158,6 +175,10 @@ private:
QComboBox *m_accountBox = nullptr;
QPushButton *m_syncButton = nullptr;
QLabel *m_statusLabel = nullptr;
+
+ /// Says how many tag changes have not been seen to reach the mail store.
+ /// Hidden entirely at zero rather than reading "0 unsynced", which is noise.
+ QLabel *m_pendingLabel = nullptr;
QPlainTextEdit *m_syncLog = nullptr;
/// Action name (as used in [keys]) to the QAction implementing it. Owned
@@ -178,6 +199,17 @@ private:
QString m_lastQuery;
QString m_currentThreadId;
+ /// Confirmed tag mutations not yet known to have reached the mail store.
+ ///
+ /// A count of its own rather than QUndoStack::isClean(), which cannot serve
+ /// here: the undo stack is CLEARED on every query, since its entries refer
+ /// to rows the new result set discards. Tag a thread, run any query, and the
+ /// stack is empty while the change is still unsynced.
+ ///
+ /// A lower bound on what is outstanding, never a guarantee: the user's cron
+ /// can run notmuch new without the application noticing.
+ int m_pendingEdits = 0;
+
/// Marks the open thread read once it has been on screen long enough.
///
/// Single-shot and RESTARTED on every selection change, never stacked:
diff --git a/tests/test_config.cpp b/tests/test_config.cpp
index a09738b..4d92891 100644
--- a/tests/test_config.cpp
+++ b/tests/test_config.cpp
@@ -45,6 +45,9 @@ private slots:
void markReadDelayIsActuallyRead();
void markReadDelayAcceptsZeroAndNegative();
void markReadDelayRejectsGarbage();
+ void syncOnExitDefaultsToAsk();
+ void syncOnExitReadsAllThreeValues();
+ void syncOnExitWarnsOnGarbage();
void extraMimetypesAppendToBuiltins();
void extraMimetypeDescriptionMayContainComma();
void malformedExtraMimetypeIsSkipped();
@@ -429,6 +432,50 @@ void TestConfig::markReadDelayRejectsGarbage()
QVERIFY(!config.problems().isEmpty());
}
+void TestConfig::syncOnExitDefaultsToAsk()
+{
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral("[general]\n")));
+ QCOMPARE(config.syncOnExit(), Config::SyncOnExit::Ask);
+ QVERIFY(config.problems().isEmpty());
+}
+
+void TestConfig::syncOnExitReadsAllThreeValues()
+{
+ // A bool could only carry two of these. Each is a distinct behaviour at
+ // exit, so each has to round-trip.
+ const QList<QPair<QString, Config::SyncOnExit>> cases = {
+ { QStringLiteral("ask"), Config::SyncOnExit::Ask },
+ { QStringLiteral("always"), Config::SyncOnExit::Always },
+ { QStringLiteral("never"), Config::SyncOnExit::Never },
+ // Case and surrounding space are the user's, not the parser's problem.
+ { QStringLiteral(" Always "), Config::SyncOnExit::Always },
+ };
+
+ for (const auto &testCase : cases) {
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral("[general]\nsync_on_exit=%1\n")
+ .arg(testCase.first)));
+ QCOMPARE(config.syncOnExit(), testCase.second);
+ QVERIFY2(config.problems().isEmpty(),
+ qPrintable(QStringLiteral("'%1' warned").arg(testCase.first)));
+ }
+}
+
+void TestConfig::syncOnExitWarnsOnGarbage()
+{
+ // Silently falling back would change what happens to unsynced work without
+ // telling the user, so a typo has to be named.
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeIni(dir, QStringLiteral("[general]\n"
+ "sync_on_exit=maybe\n")));
+ QCOMPARE(config.syncOnExit(), Config::SyncOnExit::Ask);
+ QVERIFY(!config.problems().isEmpty());
+}
+
void TestConfig::extraMimetypesAppendToBuiltins()
{
QTemporaryDir dir;
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index fb33462..d307a99 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -20,8 +20,10 @@
#include <QAction>
#include <QApplication>
+#include <QCloseEvent>
#include <QDir>
#include <QKeyEvent>
+#include <QLabel>
#include <QLineEdit>
#include <QFile>
#include <QSettings>
@@ -35,6 +37,7 @@
#include "keymap.h"
#include "mainwindow.h"
#include "messageview.h"
+#include "notmuchworker.h"
#include "threadlistmodel.h"
/// MainWindow is mostly wiring, and the parts that need a real database are
@@ -59,6 +62,10 @@ private slots:
void markReadTimerRestartsRatherThanStacking();
void markReadTimerIsNotArmedForAReadThread();
void markReadCanBeDisabled();
+ void pendingEditCountSurvivesAQuery();
+ void aFailedSyncDoesNotClearThePendingCount();
+ void closingWithNoPendingEditsDoesNotPrompt();
+ void syncOnExitNeverClosesSilently();
};
void TestMainWindow::everyKnownActionIsRegistered()
@@ -428,6 +435,162 @@ void TestMainWindow::markReadCanBeDisabled()
"a negative mark_read_delay_ms must disable the timer");
}
+void TestMainWindow::pendingEditCountSurvivesAQuery()
+{
+ // The defining property, and the reason this is a counter of its own rather
+ // than QUndoStack::isClean(): the undo stack is cleared on every query,
+ // because its entries refer to rows the new result set discards. Tag a
+ // thread, run any query, and the stack is empty while the change is still
+ // sitting unsynced in the database.
+ const Config config;
+ MainWindow window(config);
+
+ auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
+ QVERIFY(label);
+ QVERIFY2(label->isHidden(), "the indicator must start hidden at zero");
+
+ // Confirm a write the way the worker really does, by emitting the signal
+ // the window listens to. No test-only entry point on MainWindow.
+ TagChange change;
+ change.added = { QStringLiteral("deleted") };
+ change.description = QStringLiteral("Delete");
+ QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
+ Q_ARG(TagChange, change)));
+
+ QVERIFY2(!label->isHidden(), "a confirmed edit must show the indicator");
+ const QString afterEdit = label->text();
+ QVERIFY(!afterEdit.isEmpty());
+
+ // Now run a query, which clears the undo stack. The indicator must not
+ // follow it down.
+ window.findChild<QLineEdit *>()->setText(QStringLiteral("tag:inbox"));
+ QMetaObject::invokeMethod(&window, "runCurrentQuery");
+
+ QVERIFY2(!label->isHidden(),
+ "the indicator was cleared by a query, so it is tracking the undo "
+ "stack rather than unsynced state");
+ QCOMPARE(label->text(), afterEdit);
+}
+
+void TestMainWindow::aFailedSyncDoesNotClearThePendingCount()
+{
+ // A failed sync means the edits are still unsynced. Clearing here would
+ // assert the opposite, and the user would quit believing their tagging had
+ // been carried over.
+ const Config config;
+ MainWindow window(config);
+
+ auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
+ QVERIFY(label);
+
+ TagChange change;
+ change.added = { QStringLiteral("flagged") };
+ QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
+ Q_ARG(TagChange, change)));
+ QVERIFY(!label->isHidden());
+ const QString afterEdit = label->text();
+
+ QMetaObject::invokeMethod(&window, "onSyncFinished",
+ Q_ARG(bool, false), Q_ARG(int, 1));
+ QVERIFY2(!label->isHidden(), "a FAILED sync cleared the pending count");
+ QCOMPARE(label->text(), afterEdit);
+
+ // A successful one does clear it, so this is not "never clears".
+ QMetaObject::invokeMethod(&window, "onSyncFinished",
+ Q_ARG(bool, true), Q_ARG(int, 0));
+ QVERIFY2(label->isHidden(), "a successful sync must clear the indicator");
+}
+
+/// Closes a window and reports whether it accepted, failing rather than hanging
+/// if a modal appears.
+///
+/// A modal spins its own event loop, so a test that simply sends a close event
+/// blocks forever when a dialog it did not expect opens. This arms a timer that
+/// closes any active modal and records that one was there, which turns "a
+/// dialog appeared" into an assertion instead of a hung run.
+struct CloseProbe
+{
+ bool accepted = false;
+ bool sawModal = false;
+
+ void run(MainWindow *window)
+ {
+ QTimer poll;
+ poll.setInterval(50);
+ int ticks = 0;
+ QObject::connect(&poll, &QTimer::timeout, [this, &poll, &ticks]() {
+ if (QWidget *modal = QApplication::activeModalWidget()) {
+ sawModal = true;
+ modal->close();
+ poll.stop();
+ return;
+ }
+ if (++ticks > 20) // one second is ample for a synchronous close
+ poll.stop();
+ });
+ poll.start();
+
+ QCloseEvent event;
+ QApplication::sendEvent(window, &event);
+ accepted = event.isAccepted();
+ poll.stop();
+ }
+};
+
+void TestMainWindow::closingWithNoPendingEditsDoesNotPrompt()
+{
+ // Nothing outstanding means nothing to ask about. If a prompt fires here it
+ // is keying off something other than there being work to lose, and every
+ // quit would carry a dialog.
+ const Config config;
+ MainWindow window(config);
+
+ CloseProbe probe;
+ probe.run(&window);
+
+ QVERIFY2(!probe.sawModal, "a clean window prompted on close");
+ QVERIFY2(probe.accepted, "a clean window refused to close");
+}
+
+void TestMainWindow::syncOnExitNeverClosesSilently()
+{
+ // "never" is the behaviour that existed before the prompt did, and it has
+ // to stay reachable for anyone who does not want to be asked. It must hold
+ // whether or not a sync command is configured, so this covers both: the
+ // no-command path has its own dialog, and "never" must skip that one too.
+ QTemporaryDir dir;
+ const QString path = dir.filePath(QStringLiteral("qtmaildir.conf"));
+ {
+ QFile file(path);
+ QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text));
+ file.write("[general]\nsync_on_exit=never\n\n[sync]\ncommand=/bin/true\n");
+ }
+
+ Config config;
+ config.load(path);
+ QCOMPARE(config.syncOnExit(), Config::SyncOnExit::Never);
+
+ MainWindow window(config);
+
+ // Give it something to lose, so this is not passing for the same reason
+ // the previous test does.
+ TagChange change;
+ change.added = { QStringLiteral("deleted") };
+ QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
+ Q_ARG(TagChange, change)));
+ auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
+ QVERIFY(label);
+ QVERIFY(!label->isHidden());
+
+ CloseProbe probe;
+ probe.run(&window);
+
+ QVERIFY2(!probe.sawModal,
+ "sync_on_exit=never prompted anyway");
+ QVERIFY2(probe.accepted,
+ "sync_on_exit=never must close without prompting");
+}
+
// Constructing a MainWindow needs a QApplication and a platform plugin. The
// test has no display under ctest, so it runs offscreen unless the caller
// asked for something else.