diff options
Diffstat (limited to 'src/mainwindow.cpp')
| -rw-r--r-- | src/mainwindow.cpp | 225 |
1 files changed, 206 insertions, 19 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 9166588..231a9a5 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -18,12 +18,15 @@ #include "mainwindow.h" +#include <algorithm> + #include "maildirname.h" #include <QAction> #include <QApplication> #include <QCloseEvent> #include <QKeyEvent> +#include <QMouseEvent> #include <QComboBox> #include <QDialog> #include <QDialogButtonBox> @@ -63,6 +66,7 @@ #include "searchterm.h" #include "tagchip.h" #include "tagdialog.h" +#include "pendingchangesdialog.h" #include "savequerydialog.h" #include "tagrulesdialog.h" #include "threadlistmodel.h" @@ -488,6 +492,20 @@ bool MainWindow::eventFilter(QObject *watched, QEvent *event) // cannot fail. } + // The unsynced-changes indicator opens its list on a click (item 119). A + // QLabel has no clicked signal, so the press is taken here rather than + // replacing the label with a flat QToolButton: a button would inherit the + // style's button metrics inside a status bar, and the label already sits + // correctly. + if (watched == m_pendingLabel + && event->type() == QEvent::MouseButtonRelease) { + auto *mouse = static_cast<QMouseEvent *>(event); + if (mouse->button() == Qt::LeftButton) { + showPendingChanges(); + return true; + } + } + return QMainWindow::eventFilter(watched, event); } @@ -520,6 +538,17 @@ MainWindow::MainWindow(const Config &config, QWidget *parent) buildUi(); registerActions(); + // Both need the delegate, which buildUi() just created. The list is loaded + // once at startup and again only on an explicit reload, never per repaint: + // the painting path runs on every row of every scroll. + loadBusinessSenders(); + applyCurrentAccountToDelegate(); + // A change takes effect without a restart. Its own connect, not the one in + // buildSavedQueryRow(), which belongs to the filter-buttons row and is + // rebuilt with it. + connect(m_accountBox, &QComboBox::currentIndexChanged, this, + [this]() { applyCurrentAccountToDelegate(); }); + // After registerActions(), not inside buildUi(): the query bar exists by // then but the action does not, so wiring this where the field is built // silently connected nothing and left Save query enabled on an empty @@ -669,6 +698,11 @@ void MainWindow::buildUi() // them together. m_pendingLabel = new QLabel(this); m_pendingLabel->setObjectName(QStringLiteral("pendingEdits")); + // Clickable, opening the list of what it counts (item 119). The cursor is + // the only affordance a status-bar label can carry, so it is what says + // this one can be opened. + m_pendingLabel->setCursor(Qt::PointingHandCursor); + m_pendingLabel->installEventFilter(this); m_pendingLabel->hide(); statusBar()->addPermanentWidget(m_pendingLabel); @@ -831,7 +865,8 @@ void MainWindow::buildUi() // delegate is confined to one column's rectangle. m_threadView = new ThreadListView(central); m_threadView->setModel(m_model); - m_threadView->setItemDelegate(new CardDelegate(this)); + m_cardDelegate = new CardDelegate(this); + m_threadView->setItemDelegate(m_cardDelegate); m_threadView->setHeaderHidden(true); m_threadView->setSelectionBehavior(QAbstractItemView::SelectRows); m_threadView->setSelectionMode(QAbstractItemView::ExtendedSelection); @@ -2577,6 +2612,20 @@ void MainWindow::wireWorker() connect(m_worker, &NotmuchWorker::messageCountsReady, this, &MainWindow::onRuleCountsReady); + // Sender counts feed the business-senders candidate list after a sync. + // The connection is queued, so the QHash argument must be a registered + // metatype; notmuchworker.cpp registers it beside SortOrder. + connect(m_worker, &NotmuchWorker::senderCountsReady, this, + [this](const QHash<QString, int> &counts) { + // Never applies anything: appendCandidates writes commented + // lines only, so nothing on screen changes until the user + // uncomments one. The list is then reloaded so an entry they + // uncommented by hand takes effect without a restart. + BusinessSenders::appendCandidates( + BusinessSenders::defaultPath(), counts); + loadBusinessSenders(); + }); + // The rules dialog is the only consumer, and it may have been closed while // the scan was in flight. No generation counter: the tree on disk does not // change under a query, so a late answer is still the right one. @@ -2590,6 +2639,8 @@ void MainWindow::wireWorker() // unrelated error would roll back a change that actually succeeded. connect(m_worker, &NotmuchWorker::tagsApplied, this, &MainWindow::onTagsApplied); + connect(m_worker, &NotmuchWorker::pendingSubjectsResolved, + this, &MainWindow::onPendingSubjectsResolved); // messagesMovedFrom rather than messagesMoved: the tags a move carries can // only be resolved once the origins are known, and that signal is the one @@ -2794,6 +2845,27 @@ void MainWindow::selectAccountForTesting(const QString &key) m_accountBox->setCurrentIndex(index); } +void MainWindow::loadBusinessSenders(const QString &path) +{ + m_businessSenders = BusinessSenders::load( + path.isEmpty() ? BusinessSenders::defaultPath() : path); + m_cardDelegate->setBusinessSenders(m_businessSenders); +} + +void MainWindow::applyCurrentAccountToDelegate() +{ + const QString key = m_accountBox->currentData().toString(); + if (key.isEmpty()) { + m_cardDelegate->setAccountAddress(QString()); + m_cardDelegate->setAccountLabel(QString()); + return; + } + const Account account = m_config.account(key); + m_cardDelegate->setAccountAddress(account.address); + m_cardDelegate->setAccountLabel( + account.name.isEmpty() ? account.key : account.name); +} + void MainWindow::onRulePreviewRequested(const QString &query) { // Unscoped, deliberately. runQuery() wraps the bar's text in the selected @@ -4265,7 +4337,6 @@ void MainWindow::onSyncFinished(bool success, int exitCode) // assert the edits had reached the mail store when the sync is exactly // what failed to put them there. m_pendingTagEdits.clear(); - m_unnettablePendingEdits = 0; // Only what this run actually carried, per the snapshot above. An // account added by flushHeldEdits() stays, because its edit reaches the @@ -4318,6 +4389,14 @@ void MainWindow::onSyncFinished(bool success, int exitCode) refreshCurrentQuery(); // A sync is the usual way new tags enter the database. requestAllTags(); + + // Propose new business-sender candidates from the mail this sync + // delivered. Scoped by scanQuery: a week of mail once the file + // exists, everything on the first run. + QMetaObject::invokeMethod( + m_worker, "countSenders", Qt::QueuedConnection, + Q_ARG(QString, + BusinessSenders::scanQuery(BusinessSenders::defaultPath()))); } else if (exitCode == kSyncSkippedExitCode) { // Skipped means the lock was never ours: some other run holds it. If // both started inside the same poll interval the monitor will have @@ -4386,17 +4465,9 @@ void MainWindow::onTagsApplied(const TagChange &change) // message are two independent changes and must not cancel each other. for (const QString &messageId : change.messageIds) { for (const QString &tag : change.added) - recordPendingEdit(messageId, tag, true); + recordPendingEdit(messageId, tag, true, change.description); for (const QString &tag : change.removed) - recordPendingEdit(messageId, tag, false); - } - - // A change carrying no message ids cannot be netted against anything, and - // must still register: losing an edit understates the indicator, which is - // the direction that costs the user work. - if (change.messageIds.isEmpty() - && !(change.added.isEmpty() && change.removed.isEmpty())) { - ++m_unnettablePendingEdits; + recordPendingEdit(messageId, tag, false, change.description); } updatePendingIndicator(); @@ -4753,7 +4824,6 @@ void MainWindow::onExternalSyncStateChanged(SyncMonitor::State state) // is the absence of evidence rather than evidence of success. if (MailSync::lastRunOutcome(m_config.syncLog()) == SyncOutcome::Ok) { m_pendingTagEdits.clear(); - m_unnettablePendingEdits = 0; // Cleared HERE, before flushHeldEdits() below, and the ordering is // load-bearing for the reason spelled out on the local path at @@ -4944,7 +5014,7 @@ void MainWindow::runAutoSync() } void MainWindow::recordPendingEdit(const QString &messageId, const QString &tag, - bool added) + bool added, const QString &action) { const QString key = messageId + QLatin1Char('\n') + tag; @@ -4953,12 +5023,12 @@ void MainWindow::recordPendingEdit(const QString &messageId, const QString &tag, // long session of tagging and untagging. const auto existing = m_pendingTagEdits.constFind(key); if (existing != m_pendingTagEdits.constEnd()) { - if (*existing != added) + if (existing->added != added) m_pendingTagEdits.erase(m_pendingTagEdits.find(key)); return; } - m_pendingTagEdits.insert(key, added); + m_pendingTagEdits.insert(key, PendingEdit{ added, action }); } QStringList MainWindow::pendingSyncChannels() const @@ -4996,6 +5066,14 @@ int MainWindow::pendingEditCount() const // an edit waiting on a lock is precisely the work quitting would lose. // Each held edit counts as one whatever its size, since it carries thread // ids rather than message ids and cannot be netted against the map. + // + // There is no fourth term. A counter for confirmed changes carrying no + // message ids stood here until item 119 looked for what it held and found + // nothing: NotmuchWorker::applyTags() is the only emitter of tagsApplied() + // and returns early on an empty id list, so the change that counter + // existed for cannot reach this window. Every pending change can name the + // messages it touches, which is what lets the indicator be opened and + // listed in full. const int held = int(m_heldEdits.size()); // Held MOVES count for exactly the same reason, and were missed. With no // tag edit queued the count was 0, so the indicator stayed hidden and @@ -5004,8 +5082,106 @@ int MainWindow::pendingEditCount() const // is item 106's data loss, and worse here, because a dropped move leaves // the file in the folder the user asked it out of. const int heldMoves = int(m_heldMoves.size()); - return m_pendingTagEdits.size() + m_unnettablePendingEdits + held - + heldMoves; + return m_pendingTagEdits.size() + held + heldMoves; +} + +QVector<PendingChange> MainWindow::pendingChangeSnapshot() const +{ + QVector<PendingChange> rows; + + // The netted per-(message, tag) edits. The key is `messageId\ntag`, built + // by recordPendingEdit(), so the id is everything before the first + // newline: a TAG may contain almost anything, but a message id cannot + // contain a newline and neither separator can be confused for the other. + for (auto it = m_pendingTagEdits.cbegin(); it != m_pendingTagEdits.cend(); + ++it) { + const QString id = it.key().section(QLatin1Char('\n'), 0, 0); + rows.append(PendingChange{ id, false, it->action, QString(), -1 }); + } + + // Held THREAD edits, which stay thread-scoped: a `*_thread` action is what + // made them, and reporting the messages instead would claim the user acted + // on each one. One row per thread the edit named, since a single edit can + // cover a multi-row selection. + for (const HeldEdit &edit : m_heldEdits) { + for (const QString &threadId : edit.threadIds) { + rows.append(PendingChange{ threadId, true, edit.change.description, + QString(), -1 }); + } + } + + // Held MOVES, which are message-scoped. A move is not a tag change and is + // queued separately for that reason, but it is the same kind of row here: + // one message, one action the user took. + for (const HeldMove &move : m_heldMoves) { + for (const QString &messageId : move.messageIds) + rows.append(PendingChange{ messageId, false, move.description, + QString(), -1 }); + } + + // Grouped by id so a message with several outstanding actions appears + // ONCE with its actions beneath it, which is the layout the user asked + // for. A stable sort, so the actions under one message keep the order + // they were made in rather than an arbitrary one; QHash has no order of + // its own, so without this the list reshuffles between openings. + std::stable_sort(rows.begin(), rows.end(), + [](const PendingChange &a, const PendingChange &b) { + return a.id < b.id; + }); + return rows; +} + +void MainWindow::showPendingChanges() +{ + // The snapshot is taken HERE, at the click, and is what the dialog shows + // however long it stays open. Nothing refreshes it: the count the user + // clicked is the list they get. + m_pendingChangeRequest = pendingChangeSnapshot(); + + if (m_pendingChangeRequest.isEmpty() || !m_worker) { + // Nothing to resolve. Shown anyway rather than silently ignoring the + // click, since a window saying "nothing is waiting" is an answer and a + // dead click is not. + PendingChangesDialog(m_pendingChangeRequest, this).exec(); + m_pendingChangeRequest.clear(); + return; + } + + QStringList ids; + QList<bool> areThreads; + ids.reserve(m_pendingChangeRequest.size()); + areThreads.reserve(m_pendingChangeRequest.size()); + for (const PendingChange &change : m_pendingChangeRequest) { + ids.append(change.id); + areThreads.append(change.isThread); + } + + QMetaObject::invokeMethod(m_worker, "resolvePendingSubjects", + Qt::QueuedConnection, + Q_ARG(QStringList, ids), + Q_ARG(QList<bool>, areThreads)); +} + +void MainWindow::onPendingSubjectsResolved(const QStringList &subjects, + const QList<int> &messageCounts) +{ + // Positional, so the two must line up. A mismatch means the answer is not + // this request's, which is not something to render half of. + if (m_pendingChangeRequest.isEmpty() + || subjects.size() != m_pendingChangeRequest.size() + || messageCounts.size() != m_pendingChangeRequest.size()) { + m_pendingChangeRequest.clear(); + return; + } + + QVector<PendingChange> changes = m_pendingChangeRequest; + m_pendingChangeRequest.clear(); + for (int i = 0; i < changes.size(); ++i) { + changes[i].subject = subjects.at(i); + changes[i].messageCount = messageCounts.at(i); + } + + PendingChangesDialog(changes, this).exec(); } void MainWindow::updatePendingIndicator() @@ -5828,13 +6004,24 @@ void MainWindow::restoreResolvedMessages(const QStringList &messageIds, // `<maildir>/<folder>`, and the account is resolved back from it // rather than captured above, where it belongs to the per-message loop // and is out of scope here. + // + // The destination FOLDER, taken from the key rather than from + // `origin` above: that is the finished TAG, `deleted-from:Inbox`, + // which never equals `Inbox` however the account spells it. The + // comparison was therefore always false and the `inbox` tag never came + // back, so a restored message sat in the inbox folder invisible to the + // Inbox view until the next hook run. The comment above says what this + // does; for one release the code did not do it. QStringList add; const QString destMaildir = it.key().section(QLatin1Char('/'), 0, 0); + const QString destFolder = it.key().section(QLatin1Char('/'), 1); for (const Account &candidate : m_config.accounts()) { if (candidate.maildir != destMaildir) continue; - if (origin.compare(candidate.inboxFolder(), Qt::CaseInsensitive) == 0) + if (destFolder.compare(candidate.inboxFolder(), + Qt::CaseInsensitive) == 0) { add.append(QStringLiteral("inbox")); + } break; } |
