aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/config.cpp19
-rw-r--r--src/config.h10
-rw-r--r--src/mainwindow.cpp171
-rw-r--r--src/mainwindow.h32
4 files changed, 214 insertions, 18 deletions
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: