summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-11 19:41:16 +0200
committerDanilo M. <danix@danix.xyz>2026-08-11 19:41:16 +0200
commit64d3138ba923071069da6c9bc458a25a9cc7d27f (patch)
tree0c35032240b7dcd2f92f613aec02147cc56ee8eb
parent2c3a3d4da8ceb21bd1e2f7be16fcaa7473b6bbe6 (diff)
downloadqtmaildir-64d3138ba923071069da6c9bc458a25a9cc7d27f.tar.gz
qtmaildir-64d3138ba923071069da6c9bc458a25a9cc7d27f.zip
feat(sync): sync a tag change automatically after a short delay
Item 71. A tag edit reached the notmuch index at edit time and then sat there until the user clicked Sync or their cron job fired, so "mark all read" updated the view while the change itself waited, sometimes for ten minutes. A confirmed edit now arms a debounce that runs the existing sync path. The delay is auto_sync_delay_ms in [general], defaulting to 2000, and 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 disables the behaviour, which is the switch for a user who wants only their cron job. It is 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. A debounce rather than a schedule, restarted by each 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 no sync command is configured or when the pending count is zero, the case where an edit was netted against its own inverse. When the timer fires with a sync already running, local or cron, it skips rather than queues: mbsync's own answer to a second run is to fail on it, and the edits stay pending rather than being lost. Also fixes a pane blanked out from under the reader, found by hand testing this feature. onSyncFinished called runCurrentQuery() where the cron path calls refreshCurrentQuery(), and a re-run clears the model, the undo stack and the message pane. The stale-thread notice handles a thread that stops matching the query and has since item 35, but a re-run left nothing for it to describe. The two paths had no reason to differ; before this item a local sync only followed a click on Sync, so the difference went unnoticed. Reading a message in the Unread view, having it marked read, and watching the pane go blank two seconds later is what surfaced it. Its test asserts on the undo stack rather than the pane: both paths issue a queued query test_mainwindow has no worker to answer, so the pane ends up blank either way and an assertion on it would pass against both, while the undo stack is cleared by one and kept by the other. Nine tests, four in test_config and five in test_mainwindow, each mutation-checked: removing the schedule call, honouring a negative delay, dropping the nothing-pending guard, dropping the already-running guard, and restoring runCurrentQuery() each fail a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-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