diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/config.cpp | 19 | ||||
| -rw-r--r-- | src/config.h | 10 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 171 | ||||
| -rw-r--r-- | src/mainwindow.h | 32 |
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: |
