summaryrefslogtreecommitdiffstats
path: root/src/mainwindow.h
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-04 11:16:52 +0200
committerDanilo M. <danix@danix.xyz>2026-08-04 12:54:48 +0200
commit3b0a52b8620d3cead2f527d108ba64bfec8ee273 (patch)
treef231a877f4805a7dbcde4145a7e8ce9443156dc5 /src/mainwindow.h
parent272cb9de98ea387b1d73c55333e4058d1af5da47 (diff)
downloadqtmaildir-3b0a52b8620d3cead2f527d108ba64bfec8ee273.tar.gz
qtmaildir-3b0a52b8620d3cead2f527d108ba64bfec8ee273.zip
feat(sync): show unsynced edits and offer to sync on exit
Tagging changes the notmuch index at once, but the mail store only hears about it on the next sync, and nothing said so. Quitting with tagging outstanding was silent. Items 18 and 19 of the usability backlog, built together because the second needs the first's counter. The counter cannot be QUndoStack::isClean(), which is the obvious candidate and the wrong one: 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. m_pendingEdits is its own count, incremented where a write is CONFIRMED rather than where one is sent, so an optimistic update the worker later rejects cannot leave the indicator claiming an edit that never landed. Only a successful sync resets it: clearing on failure would assert the changes had reached the mail store when the sync is exactly what failed to put them there. It is shown in the status bar, hidden entirely at zero, and described as a lower bound rather than a guarantee, since an external notmuch run can carry changes over without this application noticing. On exit, sync_on_exit in [general] takes ask, always or never. Three values rather than a bool because "prompt me", "just do it" and "do nothing" are three behaviours and true/false expresses two; an unknown value warns by name, since a typo there silently changes what happens to unsynced work. The prompt offers three buttons for the same reason: a user who hit Quit by mistake needs a way back that is not "sync". A sync started at exit holds the window open until it finishes rather than being killed mid-run, and a sync that FAILS does not quit, because quitting there would discard the user's choice silently. With no sync command configured the prompt degrades to a plain warning instead of offering a sync that cannot run. This is not a destructive-action confirmation of the kind CLAUDE.md forbids. Those cover tag mutations, which keep undo instead of a dialog. This asks about losing work at the one point where undo cannot help. The tagsApplied lambda became a named slot, which is better structure and also what lets a test drive it: the worker is deliberately parentless because it moves to its own thread, so reaching it with findChild to emit the real signal cannot work, and contorting the test to try was the wrong instinct. Testing a modal needed its own care. A test that sends a close event hangs forever if an unexpected dialog opens, because the modal spins its own event loop; CloseProbe polls for activeModalWidget, closes it and records that one appeared, turning "a dialog opened" into an assertion rather than a hang. Also removes a stray qDebug left in the open_thread action by the earlier Enter-key investigation, which had reached two commits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'src/mainwindow.h')
-rw-r--r--src/mainwindow.h32
1 files changed, 32 insertions, 0 deletions
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: