diff options
Diffstat (limited to 'src/mainwindow.cpp')
| -rw-r--r-- | src/mainwindow.cpp | 171 |
1 files changed, 153 insertions, 18 deletions
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. |
