aboutsummaryrefslogtreecommitdiffstats
path: root/src/mainwindow.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'src/mainwindow.cpp')
-rw-r--r--src/mainwindow.cpp225
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;
}