aboutsummaryrefslogtreecommitdiffstats
path: root/src/mainwindow.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'src/mainwindow.cpp')
-rw-r--r--src/mainwindow.cpp407
1 files changed, 385 insertions, 22 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 0a9f9d8..7cd2769 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -154,7 +154,7 @@ void MainWindow::closeEvent(QCloseEvent *event)
return;
}
- if (!m_closeApproved && m_pendingEdits > 0
+ if (!m_closeApproved && pendingEditCount() > 0
&& m_config.syncOnExit() != Config::SyncOnExit::Never) {
// Not a destructive-action confirmation, which CLAUDE.md forbids for
@@ -167,7 +167,7 @@ void MainWindow::closeEvent(QCloseEvent *event)
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),
+ "is configured. Quit anyway?", "", pendingEditCount()),
QMessageBox::Discard | QMessageBox::Cancel,
QMessageBox::Cancel);
if (answer == QMessageBox::Cancel) {
@@ -181,7 +181,7 @@ void MainWindow::closeEvent(QCloseEvent *event)
box.setIcon(QMessageBox::Question);
box.setWindowTitle(tr("Unsynced changes"));
box.setText(tr("%n tag change(s) have not been synced.", "",
- m_pendingEdits));
+ pendingEditCount()));
box.setInformativeText(tr("Sync before quitting?"));
QPushButton *sync =
box.addButton(tr("Sync and quit"), QMessageBox::AcceptRole);
@@ -189,6 +189,15 @@ void MainWindow::closeEvent(QCloseEvent *event)
box.addButton(tr("Quit anyway"), QMessageBox::DestructiveRole);
box.addButton(QMessageBox::Cancel);
box.setDefaultButton(sync);
+
+ // The default is set correctly and Qt agrees (isDefault() and
+ // hasFocus() are both true on it), but qt6ct-style draws no
+ // visible default-button decoration, so Enter's target is
+ // invisible on this desktop. Naming it in the text costs nothing
+ // and does not fight the theme.
+ // ponytail: text, not a styled button. Restyling the button means
+ // overriding the user's theme, which is worse than a sentence.
+ sync->setText(tr("Sync and quit (default)"));
box.exec();
if (box.clickedButton() == sync) {
@@ -325,8 +334,29 @@ void MainWindow::buildUi()
// The status label is created first: the sync wiring below can report into
// it before the rest of the UI exists.
m_statusLabel = new QLabel(this);
+ m_statusLabel->setObjectName(QStringLiteral("statusMessage"));
statusBar()->addWidget(m_statusLabel);
+ // Transient messages describe an EVENT and go stale: "Sync complete" reads
+ // as the present tense until something else overwrites it. State messages,
+ // the selection count above all, describe what is true right now and must
+ // not expire while it stays true, so only showTransientStatus() arms this.
+ //
+ // ponytail: one timer beside the label, not QStatusBar::showMessage().
+ // That would mean moving off addWidget() and reworking the permanent
+ // widgets beside it, for the same behaviour.
+ m_statusTimer = new QTimer(this);
+ m_statusTimer->setObjectName(QStringLiteral("statusTimer"));
+ m_statusTimer->setSingleShot(true);
+ m_statusTimer->setInterval(kStatusMessageMs);
+ connect(m_statusTimer, &QTimer::timeout, this, [this]() {
+ // Only take back a message this timer armed. Anything written since is
+ // newer and more relevant than the default.
+ if (m_statusLabel->text() == m_transientMessage)
+ m_statusLabel->setText(m_defaultStatus);
+ m_transientMessage.clear();
+ });
+
// 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.
@@ -409,6 +439,7 @@ void MainWindow::buildUi()
m_syncLogPane->hide();
m_syncButton = new QPushButton(tr("Sync"), central);
+ m_syncButton->setObjectName(QStringLiteral("syncButton"));
m_sync = new MailSync(m_config.syncCommand(), this);
m_syncButton->setEnabled(m_sync->isAvailable());
if (!m_sync->isAvailable()) {
@@ -417,7 +448,7 @@ void MainWindow::buildUi()
}
connect(m_syncButton, &QPushButton::clicked, this, [this]() {
if (!m_sync->start()) {
- m_statusLabel->setText(tr("Sync already running"));
+ showTransientStatus(tr("Sync already running"));
return;
}
// Fresh run, fresh output: leaving the previous run's lines in place
@@ -430,6 +461,15 @@ void MainWindow::buildUi()
m_syncLog->appendPlainText(chunk.trimmed());
});
+ // Syncs this window did not start. The user's cron runs the same script
+ // every ten minutes, so mail arrives and tags change while the window sits
+ // idle, and until now nothing here noticed.
+ m_syncMonitor = new SyncMonitor(SyncMonitor::defaultLockPath(),
+ QStringLiteral("/proc/locks"), this);
+ connect(m_syncMonitor, &SyncMonitor::stateChanged,
+ this, &MainWindow::onExternalSyncStateChanged);
+ m_syncMonitor->start();
+
queryRow->addWidget(m_accountBox);
queryRow->addWidget(m_queryEdit, 1);
queryRow->addWidget(m_syncButton);
@@ -488,6 +528,16 @@ void MainWindow::buildUi()
&QItemSelectionModel::currentRowChanged,
this, &MainWindow::onThreadSelected);
+ // Separate from currentRowChanged: a selection can grow without current
+ // moving at all. Ctrl+click adds a row and leaves current where it was, and
+ // selectAll() emits no currentRowChanged whatsoever (verified against
+ // Qt 6.11). Both are multi-select gestures that have to blank the pane and
+ // cancel a pending mark-read, so neither can rely on the current-index
+ // signal to notice them.
+ connect(m_threadView->selectionModel(),
+ &QItemSelectionModel::selectionChanged,
+ this, &MainWindow::onSelectionChanged);
+
m_messageView = new MessageView(central);
m_messageView->setTagColors(&m_tagColors);
connect(m_messageView, &MessageView::statusMessage,
@@ -564,8 +614,29 @@ void MainWindow::registerActions()
tagSelected({}, { QStringLiteral("inbox") }, tr("Archive"));
});
addAction(QStringLiteral("delete"), tr("&Delete"),
- tr("Add the deleted tag"), [this]() {
- tagSelected({ QStringLiteral("deleted") }, {}, tr("Delete"));
+ tr("Add or remove the deleted tag"), [this]() {
+ // A toggle, like toggle_unread: pressing Delete twice is the natural
+ // way to say "no, put it back", and adding a tag that is already there
+ // is a no-op the user cannot see.
+ //
+ // One direction for the WHOLE selection. Toggling each thread
+ // independently would leave one keystroke with the selection in two
+ // states, which is worse than either outcome, so undelete only when
+ // every selected thread is already deleted.
+ const QModelIndexList rows =
+ m_threadView->selectionModel()->selectedRows();
+ bool allDeleted = !rows.isEmpty();
+ for (const QModelIndex &index : rows) {
+ if (!m_model->threadAt(index.row()).isDeleted()) {
+ allDeleted = false;
+ break;
+ }
+ }
+
+ if (allDeleted)
+ tagSelected({}, { QStringLiteral("deleted") }, tr("Undelete"));
+ else
+ tagSelected({ QStringLiteral("deleted") }, {}, tr("Delete"));
});
addAction(QStringLiteral("spam"), tr("Mark &spam"),
tr("Add spam and remove inbox"), [this]() {
@@ -645,7 +716,7 @@ void MainWindow::registerActions()
if (m_undoStack.canUndo())
m_undoStack.undo();
else
- m_statusLabel->setText(tr("Nothing to undo"));
+ showTransientStatus(tr("Nothing to undo"));
});
addAction(QStringLiteral("sync"), tr("&Sync"),
tr("Run the configured sync command"), [this]() {
@@ -659,6 +730,29 @@ void MainWindow::registerActions()
m_queryEdit->setFocus();
m_queryCompleter->triggerCompletion();
});
+ addAction(QStringLiteral("clear_pane"), tr("Clear &message pane"),
+ tr("Blank the message pane without changing the selection"),
+ [this]() {
+ // A view change, not a mail change: the selection, the query and the
+ // undo stack are all left alone.
+ //
+ // m_currentThreadId is cleared with the pane, not merely alongside it.
+ // A threadLoaded still in flight for that id would otherwise paint the
+ // thread straight back, which is the queued-reply race documented in
+ // CLAUDE.md.
+ m_currentThreadId.clear();
+ m_messageView->clear();
+ m_markReadTimer->stop();
+ m_markReadThreadId.clear();
+ });
+ addAction(QStringLiteral("select_all"), tr("Select &all threads"),
+ tr("Select every thread in the current result list"), [this]() {
+ // A registered action rather than the view's built-in SelectAll key, so
+ // it reaches the Edit menu, the shortcut reference and [keys] the same
+ // way every other binding does. That is the whole point: multi-select
+ // already worked, it was simply invisible.
+ m_threadView->selectAll();
+ });
addAction(QStringLiteral("quit"), tr("&Quit"),
tr("Quit qtmaildir"), [this]() { close(); });
@@ -680,6 +774,8 @@ void MainWindow::buildMenus()
editMenu->addSeparator();
editMenu->addAction(m_actions.value(QStringLiteral("focus_query")));
editMenu->addAction(m_actions.value(QStringLiteral("complete_query")));
+ editMenu->addSeparator();
+ editMenu->addAction(m_actions.value(QStringLiteral("select_all")));
auto *messageMenu = menuBar()->addMenu(tr("&Message"));
messageMenu->addAction(m_actions.value(QStringLiteral("archive")));
@@ -731,6 +827,29 @@ void MainWindow::buildMenus()
action->setIcon(icon);
}
+ // Right-click on the thread list. Built from the same registered QActions
+ // as the menu bar, never from parallel copies: a [keys] override then shows
+ // the right shortcut here too, and an action cannot end up doing one thing
+ // from the menu bar and another from the context menu.
+ //
+ // Every entry applies to the whole selection already, since they all funnel
+ // through tagSelected(), so this needs no multi-row special casing.
+ m_threadContextMenu = new QMenu(this);
+ m_threadContextMenu->setObjectName(QStringLiteral("threadContextMenu"));
+ m_threadContextMenu->addAction(m_actions.value(QStringLiteral("archive")));
+ m_threadContextMenu->addAction(m_actions.value(QStringLiteral("delete")));
+ m_threadContextMenu->addAction(m_actions.value(QStringLiteral("spam")));
+ m_threadContextMenu->addSeparator();
+ m_threadContextMenu->addAction(m_actions.value(QStringLiteral("toggle_unread")));
+ m_threadContextMenu->addAction(m_actions.value(QStringLiteral("flag")));
+ m_threadContextMenu->addAction(m_actions.value(QStringLiteral("edit_tags")));
+ m_threadContextMenu->addSeparator();
+ m_threadContextMenu->addAction(m_actions.value(QStringLiteral("select_all")));
+
+ m_threadView->setContextMenuPolicy(Qt::CustomContextMenu);
+ connect(m_threadView, &QTableView::customContextMenuRequested,
+ this, &MainWindow::showThreadContextMenu);
+
// The frequent subset only. A toolbar holding every action is as
// unreadable as no toolbar.
auto *toolBar = addToolBar(tr("Main"));
@@ -787,6 +906,18 @@ void MainWindow::showShortcutReference()
"</tr></table>")
.arg(left, right));
+ // Mouse selection is view behaviour, not an action, so it cannot appear in
+ // the table above however the table is generated. Said here because it is
+ // otherwise undiscoverable: nothing in the UI hints that a thread list
+ // takes more than one row at a time.
+ auto *selectionNote = new QLabel(
+ tr("<b>Thread list:</b> <tt>Ctrl</tt>+click adds or removes a single "
+ "row, <tt>Shift</tt>+click extends the selection to a range. Tag, "
+ "archive and delete all apply to every selected thread."),
+ &dialog);
+ selectionNote->setTextFormat(Qt::RichText);
+ selectionNote->setWordWrap(true);
+
auto *note = new QLabel(
tr("Rebind any of these in the <tt>[keys]</tt> section of "
"<tt>qtmaildir.conf</tt>, using the action name."),
@@ -799,6 +930,7 @@ void MainWindow::showShortcutReference()
auto *layout = new QVBoxLayout(&dialog);
layout->addWidget(label);
+ layout->addWidget(selectionNote);
layout->addWidget(note);
layout->addStretch();
layout->addWidget(buttons);
@@ -970,7 +1102,74 @@ void MainWindow::onQueryFinished(int total, quint64 generation)
{
if (generation != m_generation)
return;
- m_statusLabel->setText(tr("%n thread(s)", "", total));
+ // The query's own result is what the bar says when nothing more pressing
+ // is happening, so a transient message falls back to it rather than to
+ // nothing.
+ m_defaultStatus = tr("%n thread(s)", "", total);
+ m_statusLabel->setText(m_defaultStatus);
+}
+
+void MainWindow::showThreadContextMenu(const QPoint &pos)
+{
+ const QModelIndex index = m_threadView->indexAt(pos);
+ if (!index.isValid())
+ return; // Right-click on empty space below the rows.
+
+ // Right-clicking a row that is already part of the selection must leave
+ // that selection alone: the actions apply to every selected thread, so
+ // collapsing to the clicked row here would silently narrow a deliberate
+ // multi-row selection to one. Right-clicking outside it selects that row
+ // instead, which is what every other list does.
+ if (!m_threadView->selectionModel()->isRowSelected(index.row()))
+ m_threadView->selectRow(index.row());
+
+ m_threadContextMenu->popup(m_threadView->viewport()->mapToGlobal(pos));
+}
+
+void MainWindow::onSelectionChanged()
+{
+ const int selected = m_threadView->selectionModel()->selectedRows().size();
+ if (selected <= 1) {
+ // Clearing the count here would wipe whatever the last action reported
+ // ("Archive: 3 threads"), which is the more useful message once the
+ // selection is gone. Only a count this function wrote is taken back.
+ if (m_statusLabel->text() == m_selectionMessage)
+ m_statusLabel->clear();
+ m_selectionMessage.clear();
+
+ // Collapsing a multi-row selection back to one row has to load that
+ // row here, and cannot be left to onThreadSelected. currentRowChanged
+ // is emitted BEFORE the selection model is updated (verified against
+ // Qt 6.11), so when a click collapses three rows to one, that handler
+ // still sees three selected, takes the multi-select branch and returns
+ // without loading anything. Only this signal sees the real count.
+ const QModelIndex current = m_threadView->currentIndex();
+ if (current.isValid()
+ && m_model->threadAt(current.row()).threadId != m_currentThreadId) {
+ onThreadSelected(current, QModelIndex());
+ }
+ return;
+ }
+
+ // The count is the part that actually teaches multi-select: it acknowledges
+ // the selection while it is being built, rather than only after an action
+ // has already been applied to it.
+ m_selectionMessage = tr("%n thread(s) selected", "", selected);
+ m_statusLabel->setText(m_selectionMessage);
+
+ // State, not an event: it must persist while the selection does. Cancel any
+ // transient message still counting down, or that timer fires and replaces a
+ // count that is still true.
+ m_statusTimer->stop();
+ m_transientMessage.clear();
+
+ // Ctrl+click and selectAll() reach a multi-row selection without moving
+ // current, so onThreadSelected never runs and its guard never fires. The
+ // pane and the pending timer have to be dealt with here as well.
+ m_markReadTimer->stop();
+ m_markReadThreadId.clear();
+ m_currentThreadId.clear();
+ m_messageView->clear();
}
void MainWindow::onThreadSelected(const QModelIndex &current,
@@ -979,6 +1178,31 @@ void MainWindow::onThreadSelected(const QModelIndex &current,
if (!current.isValid())
return;
+ // A selection spanning more than one row is aimed at a bulk action, not at
+ // reading. current follows the keyboard cursor as the selection extends, so
+ // without this every row swept through would be rendered and, worse,
+ // queued to be marked read: a selection gesture must not mutate mail.
+ //
+ // The count read here is deliberately not trusted on its own. This signal
+ // is emitted BEFORE the selection model is updated (verified against
+ // Qt 6.11), so a Ctrl+click that takes the selection from one row to two
+ // arrives here still reporting one. onSelectionChanged() always follows and
+ // sees the true count, and it is what finally blanks the pane and cancels
+ // the timer; this branch only catches the case where the count is already
+ // stale in the other direction.
+ //
+ // The stop() is not redundant with the guard. Clicking one row arms a timer
+ // legitimately and only then does the selection grow, so the timer already
+ // running for that first row has to be cancelled here or it fires behind a
+ // pane that no longer shows the thread.
+ if (m_threadView->selectionModel()->selectedRows().size() > 1) {
+ m_markReadTimer->stop();
+ m_markReadThreadId.clear();
+ m_currentThreadId.clear();
+ m_messageView->clear();
+ return;
+ }
+
const ThreadSummary thread = m_model->threadAt(current.row());
m_currentThreadId = thread.threadId;
m_messageView->setTags(thread.tags);
@@ -995,6 +1219,14 @@ void MainWindow::onThreadLoaded(const QVector<MessageRef> &messages,
if (generation != m_generation || messages.isEmpty())
return;
+ // A load started while the selection was still a single row can land after
+ // it has grown: loadThread crosses to the worker on a queued connection, so
+ // the reply arrives after onSelectionChanged() has already blanked the
+ // pane. Without this it would paint a thread back over the blank, and the
+ // pane would only look right once a third row made the count stale-proof.
+ if (m_threadView->selectionModel()->selectedRows().size() > 1)
+ return;
+
MimeParser parser;
QList<ThreadRenderItem> items;
items.reserve(messages.size());
@@ -1067,10 +1299,11 @@ void MainWindow::onSyncFinished(bool success, int exitCode)
// 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;
+ m_pendingTagEdits.clear();
+ m_unnettablePendingEdits = 0;
updatePendingIndicator();
- m_statusLabel->setText(tr("Sync complete"));
+ showTransientStatus(tr("Sync complete"));
if (m_syncingForExit) {
// The work is safely across, so finish the quit the user asked for.
@@ -1084,11 +1317,17 @@ void MainWindow::onSyncFinished(bool success, int exitCode)
// A sync is the usual way new tags enter the database.
requestAllTags();
} 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
+ // latched this lock period as local, which would swallow the report
+ // when that other run finishes. Hand it back.
+ m_localSyncHoldsLock = false;
+
// Not a failure: another run holds the lock and is doing the work.
// The user's cron fires every ten minutes, so a click landing inside
// one is routine and must not raise an error or the log pane.
- m_statusLabel->setText(tr("A sync is already running (started "
- "elsewhere); this one was skipped"));
+ showTransientStatus(tr("A sync is already running (started "
+ "elsewhere); this one was skipped"));
if (m_syncingForExit) {
// The other run is syncing, but this application cannot see when
@@ -1125,10 +1364,33 @@ void MainWindow::onTagsApplied(const TagChange &change)
m_pendingChange = {};
m_pendingThreadIds.clear();
- // Counted here, where a write is CONFIRMED, rather than where one is sent:
+ // Recorded 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;
+ //
+ // NET state, not a count of writes. An edit and its inverse leave the mail
+ // store where it started, so they must leave the indicator at zero: the
+ // automatic mark-read followed by Ctrl+U used to read as 2 unsynced
+ // changes when nothing was outstanding. What the user needs to know is
+ // whether quitting now would strand work.
+ //
+ // Keyed per (message, tag): removing `unread` and adding `flagged` on one
+ // 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);
+ 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;
+ }
+
updatePendingIndicator();
// A tag the user has just created is the one they are most likely to type
@@ -1142,28 +1404,129 @@ void MainWindow::onTagsApplied(const TagChange &change)
}
}
+void MainWindow::onExternalSyncStateChanged(SyncMonitor::State state)
+{
+ if (state == SyncMonitor::State::Running) {
+ // A sync this window started is already reported by setSyncBusy().
+ // Remember that this particular lock period is ours, because the
+ // release at the end of it must be ignored too: the process exits, and
+ // therefore isRunning() goes false, BEFORE the monitor's next poll sees
+ // the lock gone. Testing isRunning() again on that poll would report a
+ // local sync as an external one, stamping "background sync completed"
+ // over the local run's own result up to two seconds later.
+ m_localSyncHoldsLock = (m_sync && m_sync->isRunning());
+ if (m_localSyncHoldsLock)
+ return;
+
+ m_externalSyncBusy = true;
+ updateSyncControls();
+ m_statusLabel->setText(tr("Background sync running..."));
+ return;
+ }
+
+ // The release of a lock this window took. onSyncFinished() has already
+ // said what happened, including for a failure, so there is nothing to add.
+ if (m_localSyncHoldsLock) {
+ m_localSyncHoldsLock = false;
+ m_externalSyncBusy = false;
+ updateSyncControls();
+ return;
+ }
+
+ // Cleared for Idle AND for Unknown. Unknown means /proc/locks could not be
+ // read, so nothing is observed; leaving the button disabled there would
+ // strand it permanently on a platform that cannot see the lock at all.
+ m_externalSyncBusy = false;
+ updateSyncControls();
+
+ // Deliberately reports rather than refreshes. runCurrentQuery() clears the
+ // undo stack, the selection and the message pane, which is right for a
+ // query the user typed and hostile for one fired by a cron timer: with a
+ // sync every ten minutes it would discard undo history and close the thread
+ // being read, up to six times an hour, with no action from the user.
+ //
+ // Unknown is not worth reporting either. It means the lock table could not
+ // be read, so nothing was observed, and "sync finished" would be a claim
+ // this cannot support.
+ if (state == SyncMonitor::State::Idle) {
+ showTransientStatus(
+ tr("Background sync completed. Press Enter in the query bar to "
+ "refresh."));
+ }
+}
+
+void MainWindow::showTransientStatus(const QString &text)
+{
+ m_transientMessage = text;
+ m_statusLabel->setText(text);
+ m_statusTimer->start();
+}
+
void MainWindow::setSyncBusy(bool busy)
{
+ m_localSyncBusy = busy;
+ updateSyncControls();
+
+ if (busy)
+ m_statusLabel->setText(tr("Syncing..."));
+}
+
+void MainWindow::updateSyncControls()
+{
+ // ONE function of both states, deliberately. Two independent assignments,
+ // one per sync path, means whichever fires second wins: a background sync
+ // ending would re-enable the button in the middle of a local run, and a
+ // local run ending would re-enable it while cron still holds the lock.
+ const bool busy = m_localSyncBusy || m_externalSyncBusy;
+
m_syncProgress->setVisible(busy);
+
// Disabled rather than left clickable: MailSync::start() already refuses a
- // second run, but a button that looks live and does nothing is worse than
- // one that shows it is unavailable.
+ // second run and the script exits 75 when another holds the lock, but a
+ // button that looks live and does nothing is worse than one that shows it
+ // is unavailable.
+ //
+ // Note this reads Running specifically, not "not Idle". Unknown means
+ // /proc/locks could not be read and nothing was observed, so the button
+ // stays usable: permanently disabling it where the lock cannot be seen is
+ // worse than occasionally offering a run that gets skipped.
m_syncButton->setEnabled(!busy && m_sync && m_sync->isAvailable());
+}
- if (busy)
- m_statusLabel->setText(tr("Syncing..."));
+void MainWindow::recordPendingEdit(const QString &messageId, const QString &tag,
+ bool added)
+{
+ const QString key = messageId + QLatin1Char('\n') + tag;
+
+ // A tag put back the way it was is not an outstanding change. Erase rather
+ // than store the new direction, or the ledger grows without bound over a
+ // long session of tagging and untagging.
+ const auto existing = m_pendingTagEdits.constFind(key);
+ if (existing != m_pendingTagEdits.constEnd()) {
+ if (*existing != added)
+ m_pendingTagEdits.erase(m_pendingTagEdits.find(key));
+ return;
+ }
+
+ m_pendingTagEdits.insert(key, added);
+}
+
+int MainWindow::pendingEditCount() const
+{
+ return m_pendingTagEdits.size() + m_unnettablePendingEdits;
}
void MainWindow::updatePendingIndicator()
{
- if (m_pendingEdits <= 0) {
+ const int pending = pendingEditCount();
+ if (pending <= 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->setText(tr("%n unsynced change(s)", "", pending));
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 "
@@ -1244,7 +1607,7 @@ void MainWindow::editTagsOnSelection()
const QModelIndexList rows =
m_threadView->selectionModel()->selectedRows();
if (rows.isEmpty()) {
- m_statusLabel->setText(tr("Select a thread first"));
+ showTransientStatus(tr("Select a thread first"));
return;
}
@@ -1294,7 +1657,7 @@ void MainWindow::tagSelected(const QStringList &add, const QStringList &remove,
m_undoStack.push(new ThreadTagCommand(this, threadIds, add, remove,
description));
- m_statusLabel->setText(
+ showTransientStatus(
tr("%1: %n thread(s)", "", threadIds.size()).arg(description));
}