summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md18
-rw-r--r--README.md8
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md48
-rw-r--r--src/config.cpp17
-rw-r--r--src/config.h15
-rw-r--r--src/mainwindow.cpp82
-rw-r--r--src/mainwindow.h19
-rw-r--r--tests/test_config.cpp80
-rw-r--r--tests/test_mainwindow.cpp252
9 files changed, 537 insertions, 2 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2603b4f..2eda4e0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,24 @@ point at which they are stable.
## [Unreleased]
+### Added
+
+- A tag change now syncs itself out, about two seconds after you stop making
+ changes, instead of waiting for the Sync button or your cron job. The delay is
+ a debounce, so tagging several threads in a row produces one sync rather than
+ one per thread, and a sync already running is never interrupted or queued
+ behind. Set `auto_sync_delay_ms` in `[general]` to change the delay, or to any
+ negative value to turn the behaviour off and get the previous one back.
+
+### Fixed
+
+- A sync you started no longer clears the message pane and the undo stack. It
+ now reconciles the thread list the way a background sync already did, so a
+ message stays on screen and open while the list updates around it. If the
+ thread has stopped matching the current query, which is what happens when the
+ message you are reading in Unread gets marked read, the pane keeps showing it
+ and offers "Show it anyway" instead of going blank.
+
## [0.15.0] - 2026-08-11
Sent mail becomes a place you can go. A Sent button beside Inbox, Unread and
diff --git a/README.md b/README.md
index 6dcbb66..0921791 100644
--- a/README.md
+++ b/README.md
@@ -124,6 +124,14 @@ 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. How long to wait after a tag change before syncing it out for you,
+; in milliseconds. Defaults to 2000. The delay is a debounce, so tagging several
+; threads in a row produces one sync after you stop, not one per thread. Zero
+; syncs immediately; any negative value turns this off, leaving changes for the
+; Sync button or your own cron job, which is how every release before 0.16.0
+; behaved. A sync already running, including one started by cron, is never
+; interrupted or queued behind: the change simply stays pending.
+; auto_sync_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
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 9957ba8..2663b44 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
@@ -127,7 +127,7 @@ taking that too literally.
| 68 | A forwarded subject gets no `passed` tag | workflow | S | open; no subject rule exists, measured 2026-08-11. Decision needed: display mark (XS) or write the flag (S, syncs out) |
| 69 | `passed` and `replied` read as words where every other state is a glyph | presentation | S | open; depends on 68 for what `passed` means |
| 70 | Pane icons are a private set where the main window uses the system theme | presentation | M | open |
-| 71 | A toolbar action does not sync, so the edit sits until the next cron run | workflow | S | open; user decision on the delay |
+| 71 | A toolbar action does not sync, so the edit sits until the next cron run | workflow | S | **done** 2026-08-11; 2s default, `auto_sync_delay_ms` |
| 72 | No khard/khal integration | workflow | ? | open, unspecified; the user places it after send, so v2 at the earliest |
| 73 | This backlog is past four thousand lines | maintenance | S | open |
@@ -4447,6 +4447,52 @@ lock is held. The undo stack has to survive it, which is what item 35 built.
**Size: S** once the delay is chosen.
+**Done 2026-08-11.** The delay is 2000ms by default, chosen by the user, and
+configurable as `auto_sync_delay_ms` in `[general]`. It follows
+`mark_read_delay_ms` exactly, including that zero and negative are not errors:
+zero syncs on the next trip through the event loop and any negative value
+restores the pre-0.16.0 behaviour, which is the switch for a user who wants only
+their cron job.
+
+Armed from `onTagsApplied`, where a write is CONFIRMED and the pending count is
+already current, rather than where one is sent: a sync scheduled for a write the
+worker went on to reject would run for nothing. It is a debounce and not a
+schedule, restarted by each confirmed edit, because "mark all read" confirms one
+write per thread in the view and an arm-per-edit timer would be the storm of
+syncs the debounce exists to prevent. Nothing is armed when the delay is
+negative, when no sync command is configured, or when the pending count is zero,
+which is the case where an edit was netted against its own inverse (item 28).
+
+The constraints held: `runAutoSync` skips rather than queues when
+`m_externalSyncBusy` or a local sync is running, and the edits stay pending
+rather than being lost. Item 35's refresh keeps the undo stack.
+
+Four tests in `test_mainwindow` and four in `test_config`, each mutation-checked:
+removing the `scheduleAutoSync()` call, honouring a negative delay, dropping the
+nothing-pending guard and dropping the already-running guard each failed a test.
+**Follow-up, found by hand testing the same day.** Reading a message in the
+Unread view, the automatic mark-read tagged it, the automatic sync fired two
+seconds later, and the message pane went blank. The stale-thread notice (item 35)
+exists for exactly this and was not the problem: `onSyncFinished` called
+`runCurrentQuery()` where the cron path calls `refreshCurrentQuery()`, and a
+re-run clears the model, the undo stack and the pane, so there was nothing left
+for the notice to describe. The two paths had no reason to differ; before item 71
+a local sync followed only a click on Sync, where blanking was at least
+explicable, so the difference went unnoticed. `onSyncFinished` now refreshes.
+
+Its test asserts on the UNDO STACK, not on the pane. Both paths issue a queued
+query that `test_mainwindow` has no worker to answer, so the pane ends up blank
+either way and an assertion on it passes against both; the undo stack is cleared
+by one and kept by the other, so it names which path ran. Mutation-checked by
+restoring `runCurrentQuery()`.
+
+Two traps met while writing them and worth keeping. The helper first wrote
+`general/auto_sync_delay_ms` and the key silently matched nothing, leaving the
+default in place, exactly the QSettings `[general]` behaviour recorded in
+CLAUDE.md. And a debounce assertion comparing `remainingTime()` before and after
+with `>` is FLAKY, since both reads can land in the same millisecond; assert the
+remaining time went back up near the full interval instead.
+
## 72. No khard/khal integration
**Observed (user, from the notes):** "investigate khard/khal integration (light
diff --git a/src/config.cpp b/src/config.cpp
index a81651f..b1f730c 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -271,6 +271,23 @@ void Config::load(const QString &path)
}
}
+ // Item 71. Same shape as mark_read_delay_ms above, including that zero and
+ // negative are not errors: 0 syncs on the next trip through the event loop
+ // and negative disables the automatic sync entirely, which is how a user who
+ // wants only their cron job turns this off.
+ const QVariant autoSync = settings.value(QStringLiteral("auto_sync_delay_ms"));
+ if (autoSync.isValid()) {
+ bool ok = false;
+ const int value = autoSync.toString().toInt(&ok);
+ if (ok) {
+ m_autoSyncDelayMs = value;
+ } else {
+ addProblem(QStringLiteral("Auto-sync delay '%1' is not a number; "
+ "using the default.")
+ .arg(autoSync.toString()));
+ }
+ }
+
// [completion] is an ordinary section, so this one DOES take its prefix.
// ',' separates entries and '|' separates a value from its description:
// two different characters because QSettings splits comma lists itself,
diff --git a/src/config.h b/src/config.h
index 4092141..ea0b055 100644
--- a/src/config.h
+++ b/src/config.h
@@ -213,6 +213,20 @@ public:
/// the behaviour so a thread stays unread until toggled by hand.
int markReadDelayMs() const { return m_markReadDelayMs; }
+ /// How long to wait after a tag edit before syncing it out on the user's
+ /// behalf. Item 71.
+ ///
+ /// Same three meanings as markReadDelayMs() above, and deliberately so: a
+ /// positive value is the debounce in milliseconds, 0 syncs on the next trip
+ /// through the event loop, and any negative value disables the behaviour so
+ /// edits wait for a manual sync or the user's cron job, which is what every
+ /// release before this one did.
+ ///
+ /// Defaults to 2000. The delay is a debounce, not a schedule: each edit
+ /// restarts it, so a burst of tagging produces one sync after the burst
+ /// rather than one per tag.
+ int autoSyncDelayMs() const { return m_autoSyncDelayMs; }
+
/// User-supplied mimetype completions, APPENDED to the built-in list.
/// Appending rather than replacing means a typo cannot leave completion
/// worse off than the defaults. Mimetypes are the only completion list a
@@ -250,6 +264,7 @@ private:
qreal m_messageZoom = 1.0;
bool m_completionOnFocus = false;
int m_markReadDelayMs = 2000;
+ int m_autoSyncDelayMs = 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 824bdca..04e951d 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -485,6 +485,13 @@ void MainWindow::buildUi()
connect(m_markReadTimer, &QTimer::timeout,
this, &MainWindow::markCurrentThreadRead);
+ m_autoSyncTimer = new QTimer(this);
+ // Named for the same reason: a test can assert that an edit armed the
+ // debounce without waiting out the delay or starting a real mbsync.
+ m_autoSyncTimer->setObjectName(QStringLiteral("autoSyncTimer"));
+ m_autoSyncTimer->setSingleShot(true);
+ connect(m_autoSyncTimer, &QTimer::timeout, this, &MainWindow::runAutoSync);
+
// The pane and its close button travel together: a QPlainTextEdit has
// nowhere to put one, and a pane that appears on a failed sync and can
// never be dismissed is worse than one that does not appear at all.
@@ -2194,7 +2201,22 @@ void MainWindow::onSyncFinished(bool success, int exitCode)
return;
}
- runCurrentQuery();
+ // refreshCurrentQuery(), NOT runCurrentQuery(). A sync this window
+ // started is not a query the user asked to re-run: runCurrentQuery()
+ // clears the model, the undo stack and the message pane, so a sync
+ // landing while a message was open read the user out of it. The cron
+ // path has reconciled instead since item 35, and there was never a
+ // reason for the two to differ.
+ //
+ // Item 71 is what made it matter. A local sync used to happen only
+ // when the user clicked Sync, where blanking was at least explicable;
+ // the automatic one fires two seconds after a tag edit, which is
+ // precisely when the user is still reading the message they tagged.
+ // Reconciling keeps the pane, and updateStaleThreadNotice() then offers
+ // "Show it anyway" for a thread that has stopped matching the query,
+ // which is the reported case: reading in Unread, the thread is marked
+ // read, and it no longer belongs to the view it was opened from.
+ refreshCurrentQuery();
// A sync is the usual way new tags enter the database.
requestAllTags();
} else if (exitCode == kSyncSkippedExitCode) {
@@ -2280,6 +2302,12 @@ void MainWindow::onTagsApplied(const TagChange &change)
updatePendingIndicator();
+ // Item 71. Armed here, where a write is CONFIRMED and the pending count is
+ // already up to date, for the same reason recordPendingEdit() is called
+ // here: a sync scheduled for a write the worker went on to reject would run
+ // for nothing.
+ scheduleAutoSync();
+
// 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.
@@ -2678,6 +2706,58 @@ void MainWindow::startSync()
setSyncBusy(true);
}
+void MainWindow::scheduleAutoSync()
+{
+ // Negative disables the behaviour entirely, per the config key, and that is
+ // the pre-0.16.0 behaviour: edits wait for a manual sync or the user's cron
+ // job. Checked before anything else so a disabled delay arms nothing.
+ const int delay = m_config.autoSyncDelayMs();
+ if (delay < 0)
+ return;
+
+ // No sync command means the Sync action is already disabled and startSync()
+ // would only put "No sync command configured" in the status bar. Arming a
+ // timer to say that on a delay, for something the user did not ask for, is
+ // worse than staying quiet.
+ if (!m_sync || !m_sync->isAvailable())
+ return;
+
+ // Nothing outstanding, nothing to carry. An edit netted against its own
+ // inverse leaves the count at zero (item 28), and syncing for it would run
+ // mbsync over a mail store that is already where the server left it.
+ if (pendingEditCount() == 0)
+ return;
+
+ // Restart, not stack. Tagging a multi-row selection confirms one write per
+ // thread and "mark all read" confirms one per thread in the view, so an
+ // armed-per-edit timer would be exactly the storm of syncs a debounce is
+ // for. The last edit of a burst decides when the single sync happens.
+ m_autoSyncTimer->start(delay);
+}
+
+void MainWindow::runAutoSync()
+{
+ // The user can have synced by hand, or undone the edit, in the delay. Both
+ // leave nothing to carry, and re-checking here rather than trusting the arm
+ // is what makes the debounce safe to restart freely.
+ if (pendingEditCount() == 0)
+ return;
+
+ // Skip rather than queue when a sync is already in flight, which item 71
+ // requires: the cron job holds the same lock, and mbsync's own answer to a
+ // second run is to fail on it. The edits are not lost by skipping. They stay
+ // pending, and the sync already running is very likely to carry them, since
+ // they reached the mail store at edit time.
+ //
+ // m_externalSyncBusy covers the cron job SyncMonitor can see. A lock taken
+ // between that poll and now is not visible here, and does not need to be:
+ // MailSync::start() fails on a second run and startSync() reports it.
+ if (m_externalSyncBusy || (m_sync && m_sync->isRunning()))
+ return;
+
+ startSync();
+}
+
void MainWindow::recordPendingEdit(const QString &messageId, const QString &tag,
bool added)
{
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 0912520..19635dd 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -390,6 +390,17 @@ private:
/// Redraws the unsynced-edits indicator from pendingEditCount().
void updatePendingIndicator();
+ /// Arms the debounce that syncs a confirmed tag edit out on its own
+ /// (item 71). Does nothing when the delay is negative, when no sync command
+ /// is configured, or when nothing is actually pending.
+ void scheduleAutoSync();
+
+ /// Starts the debounced automatic sync, unless a sync is already running
+ /// (local or external) or the edits it would carry are already gone.
+ ///
+ /// Q_INVOKABLE so a test can fire the debounce without waiting it out.
+ Q_INVOKABLE void runAutoSync();
+
/// Records one confirmed (message, tag) change, cancelling it against an
/// opposite change already outstanding for the same pair.
void recordPendingEdit(const QString &messageId, const QString &tag,
@@ -756,6 +767,14 @@ private:
/// nothing rather than marking the wrong one.
QString m_markReadThreadId;
+ /// Debounces the automatic sync that follows a tag edit (item 71).
+ ///
+ /// Single-shot and RESTARTED by every confirmed edit, for the same reason
+ /// m_markReadTimer is: tagging a multi-row selection confirms one write per
+ /// thread, and one sync per thread is exactly what a debounce exists to
+ /// prevent.
+ QTimer *m_autoSyncTimer = nullptr;
+
/// The optimistic update awaiting confirmation, kept so a worker error can
/// put the model back. Only the most recent one: mutations are sent from
/// the UI thread one user action at a time.
diff --git a/tests/test_config.cpp b/tests/test_config.cpp
index dad4ecd..95398e6 100644
--- a/tests/test_config.cpp
+++ b/tests/test_config.cpp
@@ -35,6 +35,10 @@ private slots:
void toolbarIconSizeIsActuallyRead();
void toolbarIconSizeIsClampedAndReported();
void toolbarIconSizeRejectsGarbage();
+ void autoSyncDelayDefaultsTo2000();
+ void autoSyncDelayIsActuallyRead();
+ void autoSyncDelayKeepsZeroAndNegative();
+ void autoSyncDelayRejectsGarbage();
void accountWithoutMaildirIsRejected();
void scopedQueryWrapsCorrectly();
void absentSyncCommandIsNoticeNotProblem();
@@ -268,6 +272,82 @@ void TestConfig::toolbarIconSizeRejectsGarbage()
QVERIFY(!config.problems().isEmpty());
}
+void TestConfig::autoSyncDelayDefaultsTo2000()
+{
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, QStringLiteral("[general]\n"));
+
+ Config config;
+ config.load(path);
+
+ QCOMPARE(config.autoSyncDelayMs(), 2000);
+ QVERIFY(config.problems().isEmpty());
+}
+
+void TestConfig::autoSyncDelayIsActuallyRead()
+{
+ // [general] keys are read WITHOUT the general/ prefix. A key that silently
+ // matched nothing would leave the 2000 default in place and pass every
+ // behavioural test in test_mainwindow, since those arm the timer at the
+ // default anyway.
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, QStringLiteral(
+ "[general]\n"
+ "auto_sync_delay_ms = 500\n"
+ ));
+
+ Config config;
+ config.load(path);
+
+ QCOMPARE(config.autoSyncDelayMs(), 500);
+ QVERIFY(config.problems().isEmpty());
+}
+
+void TestConfig::autoSyncDelayKeepsZeroAndNegative()
+{
+ // Neither is an error and neither may be clamped: 0 means sync on the next
+ // trip through the event loop, and negative disables the automatic sync,
+ // which is the only way to get the pre-0.16.0 behaviour back. Clamping
+ // either to the default would take that switch away.
+ QTemporaryDir dir;
+ const QString zero = writeIni(dir, QStringLiteral(
+ "[general]\n"
+ "auto_sync_delay_ms = 0\n"
+ ));
+
+ Config immediate;
+ immediate.load(zero);
+ QCOMPARE(immediate.autoSyncDelayMs(), 0);
+ QVERIFY(immediate.problems().isEmpty());
+
+ QTemporaryDir dir2;
+ const QString off = writeIni(dir2, QStringLiteral(
+ "[general]\n"
+ "auto_sync_delay_ms = -1\n"
+ ));
+
+ Config disabled;
+ disabled.load(off);
+ QCOMPARE(disabled.autoSyncDelayMs(), -1);
+ QVERIFY(disabled.problems().isEmpty());
+}
+
+void TestConfig::autoSyncDelayRejectsGarbage()
+{
+ // Falls back to the default and says so, matching mark_read_delay_ms.
+ QTemporaryDir dir;
+ const QString path = writeIni(dir, QStringLiteral(
+ "[general]\n"
+ "auto_sync_delay_ms = soon\n"
+ ));
+
+ Config config;
+ config.load(path);
+
+ QCOMPARE(config.autoSyncDelayMs(), 2000);
+ QVERIFY(!config.problems().isEmpty());
+}
+
void TestConfig::accountWithoutMaildirIsRejected()
{
QTemporaryDir dir;
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 818e5b4..18fe8c1 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -81,6 +81,11 @@ private slots:
void returnInTheQueryBarRunsTheQueryNotOpenThread();
void markReadTimerRestartsRatherThanStacking();
void markReadTimerIsNotArmedForAReadThread();
+ void aConfirmedEditArmsTheAutoSync();
+ void autoSyncDebouncesABurstOfEdits();
+ void autoSyncIsNotArmedWhenDisabledOrWithNothingPending();
+ void autoSyncSkipsWhileABackgroundSyncIsRunning();
+ void aSuccessfulSyncRefreshesRatherThanRerunningTheQuery();
void markReadCanBeDisabled();
void pendingEditCountSurvivesAQuery();
void aFailedSyncDoesNotClearThePendingCount();
@@ -3544,6 +3549,253 @@ void TestMainWindow::theSyncActionIsDisabledWhileABackgroundSyncHoldsTheLock()
MainWindow::setLocksPathForTesting(QStringLiteral("/proc/locks"));
}
+// Item 71. A confirmed tag edit arms a debounce that syncs it out, so an edit
+// no longer waits for a manual sync or the user's cron job.
+//
+// All four of these assert on the TIMER rather than on a sync actually running.
+// Starting a real one from a test would launch the configured command, and the
+// thing worth guarding here is the decision to sync, not QProcess.
+static QString writeSyncConfig(QTemporaryDir &dir, const QString &extra = {})
+{
+ QDir().mkpath(dir.filePath(QStringLiteral("qtmaildir")));
+ const QString conf = dir.filePath(QStringLiteral("qtmaildir/qtmaildir.conf"));
+ QSettings s(conf, QSettings::IniFormat);
+ // /bin/true exists, so Config keeps it and MailSync reports available. A
+ // config with no command disables the automatic sync by design, which would
+ // make every one of these tests pass against a stub.
+ s.setValue(QStringLiteral("sync/command"), QStringLiteral("/bin/true"));
+ // NOT "general/auto_sync_delay_ms": QSettings' INI backend treats a section
+ // literally named [general] as its own fallback section and strips it, so a
+ // prefixed lookup silently matches nothing. Writing it prefixed here left
+ // the default in place and the -1 case read 2000.
+ if (!extra.isEmpty())
+ s.setValue(QStringLiteral("auto_sync_delay_ms"), extra);
+ s.sync();
+ return conf;
+}
+
+void TestMainWindow::aConfirmedEditArmsTheAutoSync()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ config.load(writeSyncConfig(dir));
+ QCOMPARE(config.autoSyncDelayMs(), 2000);
+
+ MainWindow window(config);
+ auto *timer = window.findChild<QTimer *>(QStringLiteral("autoSyncTimer"));
+ QVERIFY2(timer, "no autoSyncTimer to observe");
+ QVERIFY2(!timer->isActive(), "the debounce is armed before any edit");
+
+ TagChange change;
+ change.messageIds = { QStringLiteral("m1") };
+ change.added = { QStringLiteral("flagged") };
+ QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
+ Q_ARG(TagChange, change)));
+
+ QVERIFY2(timer->isActive(), "a confirmed edit did not arm the automatic sync");
+ QCOMPARE(timer->interval(), 2000);
+}
+
+void TestMainWindow::autoSyncDebouncesABurstOfEdits()
+{
+ // The point of the debounce. "Mark all read" confirms one write per thread,
+ // and one sync per thread is what this prevents. A timer that STACKED would
+ // still be active here, so the assertion is on the count of timers and on
+ // the remaining interval having been reset, not merely on isActive().
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ config.load(writeSyncConfig(dir));
+
+ MainWindow window(config);
+ auto *timer = window.findChild<QTimer *>(QStringLiteral("autoSyncTimer"));
+ QVERIFY(timer);
+
+ TagChange first;
+ first.messageIds = { QStringLiteral("m1") };
+ first.added = { QStringLiteral("flagged") };
+ QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
+ Q_ARG(TagChange, first)));
+ QVERIFY(timer->isActive());
+
+ // Long enough that a restart is unambiguous. Comparing remainingTime()
+ // before and after with ">" was tried and is FLAKY: the two reads can land
+ // in the same millisecond bucket, and the test then fails against correct
+ // code. Assert instead that the remaining time went back up near the full
+ // interval, which a stacked or un-restarted timer cannot produce.
+ QTest::qWait(500);
+ const int afterWait = timer->remainingTime();
+ QVERIFY2(afterWait < 1800, "the timer did not start counting down");
+
+ TagChange second;
+ second.messageIds = { QStringLiteral("m2") };
+ second.added = { QStringLiteral("flagged") };
+ QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
+ Q_ARG(TagChange, second)));
+
+ QVERIFY2(timer->remainingTime() > 1800,
+ "the second edit did not restart the debounce, so a burst of edits "
+ "syncs on the schedule of the FIRST one");
+ QCOMPARE(window.findChildren<QTimer *>(QStringLiteral("autoSyncTimer")).size(),
+ 1);
+}
+
+void TestMainWindow::autoSyncIsNotArmedWhenDisabledOrWithNothingPending()
+{
+ // A negative delay is the switch that restores the pre-0.16.0 behaviour, so
+ // it must arm nothing at all.
+ QTemporaryDir off;
+ QVERIFY(off.isValid());
+ Config disabled;
+ disabled.load(writeSyncConfig(off, QStringLiteral("-1")));
+ QCOMPARE(disabled.autoSyncDelayMs(), -1);
+
+ MainWindow disabledWindow(disabled);
+ auto *disabledTimer =
+ disabledWindow.findChild<QTimer *>(QStringLiteral("autoSyncTimer"));
+ QVERIFY(disabledTimer);
+
+ TagChange change;
+ change.messageIds = { QStringLiteral("m1") };
+ change.added = { QStringLiteral("flagged") };
+ QVERIFY(QMetaObject::invokeMethod(&disabledWindow, "onTagsApplied",
+ Q_ARG(TagChange, change)));
+ QVERIFY2(!disabledTimer->isActive(),
+ "auto_sync_delay_ms = -1 still armed a sync");
+
+ // An edit netted against its own inverse leaves nothing outstanding (item
+ // 28), and syncing for it would run mbsync over an unchanged mail store.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ config.load(writeSyncConfig(dir));
+
+ MainWindow window(config);
+ auto *timer = window.findChild<QTimer *>(QStringLiteral("autoSyncTimer"));
+ QVERIFY(timer);
+
+ TagChange added;
+ added.messageIds = { QStringLiteral("m1") };
+ added.added = { QStringLiteral("unread") };
+ QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
+ Q_ARG(TagChange, added)));
+ QVERIFY2(timer->isActive(), "the first edit did not arm anything, so the "
+ "netting assertion below proves nothing");
+
+ timer->stop();
+ TagChange undone;
+ undone.messageIds = { QStringLiteral("m1") };
+ undone.removed = { QStringLiteral("unread") };
+ QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
+ Q_ARG(TagChange, undone)));
+ QVERIFY2(!timer->isActive(),
+ "an edit and its inverse left nothing pending but still armed a sync");
+}
+
+void TestMainWindow::autoSyncSkipsWhileABackgroundSyncIsRunning()
+{
+ // Item 71 requires skipping rather than queueing: the cron job holds the
+ // same lock and mbsync's answer to a second run is to fail on it. The edits
+ // are not lost, they stay pending.
+ //
+ // The locks path is redirected so this does not depend on whether a real
+ // sync is running on the machine, which is item 61's failure mode.
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ const QString locks = dir.filePath(QStringLiteral("locks"));
+ {
+ QFile f(locks);
+ QVERIFY(f.open(QIODevice::WriteOnly));
+ }
+ MainWindow::setLocksPathForTesting(locks);
+
+ Config config;
+ config.load(writeSyncConfig(dir));
+
+ MainWindow window(config);
+ auto *timer = window.findChild<QTimer *>(QStringLiteral("autoSyncTimer"));
+ QVERIFY(timer);
+
+ QMetaObject::invokeMethod(&window, "onExternalSyncStateChanged",
+ Q_ARG(SyncMonitor::State,
+ SyncMonitor::State::Running));
+
+ TagChange change;
+ change.messageIds = { QStringLiteral("m1") };
+ change.added = { QStringLiteral("flagged") };
+ QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
+ Q_ARG(TagChange, change)));
+
+ // Armed, because the edit is real and will still need carrying. The skip
+ // belongs to the moment the timer FIRES, not to arming it.
+ QVERIFY2(timer->isActive(), "the edit did not arm the debounce at all");
+
+ auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
+ QVERIFY(label);
+ QVERIFY(!label->isHidden());
+
+ QVERIFY(QMetaObject::invokeMethod(&window, "runAutoSync"));
+
+ // The edit is still pending: a skipped sync must not clear the indicator,
+ // which is the fault that would leave the user quitting on unsynced work.
+ QVERIFY2(!label->isHidden(),
+ "a skipped automatic sync cleared the pending indicator");
+
+ MainWindow::setLocksPathForTesting(QStringLiteral("/proc/locks"));
+}
+
+void TestMainWindow::aSuccessfulSyncRefreshesRatherThanRerunningTheQuery()
+{
+ // Reported by hand against item 71: reading a message in the Unread view,
+ // the automatic mark-read tags it, the automatic sync fires two seconds
+ // later, and the message pane went blank because the thread had stopped
+ // matching "tag:unread".
+ //
+ // The cause was not the stale-thread notice, which handles exactly this and
+ // has since item 35. It was that onSyncFinished() called runCurrentQuery()
+ // where the cron path calls refreshCurrentQuery(): a re-run clears the
+ // model, the undo stack and the pane, so there was nothing left for the
+ // notice to describe. Before item 71 a local sync only ever followed a
+ // click on Sync, which is why the difference went unnoticed.
+ //
+ // Asserted on the UNDO STACK rather than on the pane. Both paths issue a
+ // queued query this test has no worker to answer, so the pane ends up blank
+ // either way and an assertion on it would pass against both. The undo stack
+ // is cleared by runCurrentQuery() and deliberately kept by
+ // refreshCurrentQuery(), so it names which path actually ran.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(view);
+
+ window.findChild<QLineEdit *>()->setText(QStringLiteral("tag:unread"));
+ QMetaObject::invokeMethod(&window, "runCurrentQuery");
+ model->appendBatch({ makeThread(QStringLiteral("t1"),
+ { QStringLiteral("unread") }) });
+ QMetaObject::invokeMethod(&window, "onQueryFinished", Q_ARG(int, 1),
+ Q_ARG(quint64,
+ window.currentGenerationForTesting()));
+
+ // A real edit, so there is something on the undo stack to lose.
+ selectThreadRow(view, 0);
+ auto *markRead = window.findChild<QAction *>(QStringLiteral("mark_all_read"));
+ QVERIFY(markRead);
+ markRead->trigger();
+ QCOMPARE(window.undoDepthForTesting(), 1);
+
+ QMetaObject::invokeMethod(&window, "onSyncFinished",
+ Q_ARG(bool, true), Q_ARG(int, 0));
+
+ QVERIFY2(window.undoDepthForTesting() == 1,
+ "a successful sync cleared the undo stack, so it re-ran the query "
+ "instead of refreshing it, and a message open in the pane is read "
+ "out from under the user");
+}
+
void TestMainWindow::theStatusBarFollowsTheSyncPhase()
{
// Item 42: "Syncing..." said nothing about what was happening, while the