summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-07 12:03:14 +0200
committerDanilo M. <danix@danix.xyz>2026-08-07 12:03:14 +0200
commit5a4d8f5f021dc98b2a7cc471125aa3040c02675c (patch)
tree181eccf726124f39ae6a1c14734cfa18ac42ed9f /src
parent0a9ef3c77c7c593f3568f25761aad5d1f55e0e33 (diff)
downloadqtmaildir-5a4d8f5f021dc98b2a7cc471125aa3040c02675c.tar.gz
qtmaildir-5a4d8f5f021dc98b2a7cc471125aa3040c02675c.zip
feat(tags): mark every thread in the view read, in one undoable step
An action removing "unread" from every thread in the current view, on the toolbar, the Message menu and Ctrl+Shift+U. It deliberately ignores the selection, which makes it the one action in the window that does, and it routes through the same funnel as every other tag change, so it is one write rather than one per thread. Disabled until the query reports its total. Threads arrive in batches, so before then the model holds only what has landed, and an action saying "all" must not silently skip the rest. A greyed control says "not yet" without needing a dialog or a stall the user cannot see. The state is also set at registration, since QAction starts enabled and a window that has not run a query has nothing to act on. Two things came out differently from the plan, both forced by existing code. It carries a default binding, because everyActionHasAShortcut requires every registered action to have one: an unbound action is unreachable from the keyboard, and that invariant is deliberate, so the action was given Ctrl+Shift+U rather than the invariant relaxed. And only the threads that are actually unread are sent, because sending the rest would inflate the pending-edit count with writes that change nothing, and the quit prompt reads that count. A view with nothing unread does nothing, pushes no command and says so: an undo entry that restores nothing is worse than none, since it absorbs a Ctrl+Z meant for the previous action. undoDepthForTesting() is new and exists for a reason worth recording: undo->isEnabled() cannot answer "was a command pushed", because the undo QAction is always enabled and tests canUndo() when triggered. The first version of the no-op test asserted on it and passed against a mutant with the unread filter removed. Closes item 43. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/keymap.cpp5
-rw-r--r--src/mainwindow.cpp74
-rw-r--r--src/mainwindow.h26
3 files changed, 105 insertions, 0 deletions
diff --git a/src/keymap.cpp b/src/keymap.cpp
index f27f9a9..a16a700 100644
--- a/src/keymap.cpp
+++ b/src/keymap.cpp
@@ -31,6 +31,7 @@ QStringList KeyMap::knownActions()
QStringLiteral("delete"),
QStringLiteral("spam"),
QStringLiteral("toggle_unread"),
+ QStringLiteral("mark_all_read"),
QStringLiteral("edit_tags"),
QStringLiteral("flag"),
QStringLiteral("focus_query"),
@@ -67,6 +68,10 @@ QList<QPair<QString, QString>> KeyMap::defaultBindings()
{ QStringLiteral("Ctrl+D"), QStringLiteral("delete") },
{ QStringLiteral("Ctrl+Shift+S"), QStringLiteral("spam") },
{ QStringLiteral("Ctrl+U"), QStringLiteral("toggle_unread") },
+ // Shifted against Ctrl+U, which toggles unread on the selection: this
+ // is the same idea applied to the whole view, and the wider-reaching
+ // action takes the harder chord rather than the easier one.
+ { QStringLiteral("Ctrl+Shift+U"), QStringLiteral("mark_all_read") },
{ QStringLiteral("Ctrl+I"), QStringLiteral("flag") },
{ QStringLiteral("Ctrl+T"), QStringLiteral("edit_tags") },
{ QStringLiteral("Ctrl+L"), QStringLiteral("focus_query") },
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index a3b5496..bb11261 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -677,6 +677,11 @@ void MainWindow::registerActions()
else
tagSelected({ QStringLiteral("unread") }, {}, tr("Mark unread"));
});
+ addAction(QStringLiteral("mark_all_read"), tr("Mark all &read"),
+ tr("Remove the unread tag from every thread in this view"),
+ [this]() {
+ markAllRead();
+ });
addAction(QStringLiteral("edit_tags"), tr("Edit &tags..."),
tr("Add or remove any tag on the selected threads"), [this]() {
editTagsOnSelection();
@@ -768,6 +773,13 @@ void MainWindow::registerActions()
// silently dead. KeyMap warns about unknown names, but only a check here
// catches the reverse: a known action nothing implements.
Q_ASSERT(m_actions.size() == KeyMap::knownActions().size());
+
+ // QAction starts enabled, so the view-wide actions have to be put into
+ // their real state here rather than waiting for the first query: a window
+ // that has not run one yet has an empty model and no complete result set,
+ // and offering "Mark all read" against nothing is a live control that does
+ // nothing.
+ updateViewWideActions();
}
void MainWindow::buildMenus()
@@ -791,6 +803,7 @@ void MainWindow::buildMenus()
messageMenu->addAction(m_actions.value(QStringLiteral("spam")));
messageMenu->addSeparator();
messageMenu->addAction(m_actions.value(QStringLiteral("toggle_unread")));
+ messageMenu->addAction(m_actions.value(QStringLiteral("mark_all_read")));
messageMenu->addAction(m_actions.value(QStringLiteral("edit_tags")));
messageMenu->addAction(m_actions.value(QStringLiteral("flag")));
@@ -876,6 +889,7 @@ void MainWindow::buildMenus()
toolBar->addSeparator();
toolBar->addAction(m_actions.value(QStringLiteral("archive")));
toolBar->addAction(m_actions.value(QStringLiteral("delete")));
+ toolBar->addAction(m_actions.value(QStringLiteral("mark_all_read")));
toolBar->addSeparator();
toolBar->addAction(m_actions.value(QStringLiteral("undo")));
}
@@ -1102,6 +1116,11 @@ void MainWindow::runCurrentQuery()
m_statusLabel->setText(tr("Searching..."));
+ // The result set is incomplete from here until queryFinished arrives, so
+ // anything claiming to act on the whole view must wait.
+ m_queryComplete = false;
+ updateViewWideActions();
+
QMetaObject::invokeMethod(m_worker, "runQuery", Qt::QueuedConnection,
Q_ARG(QString, query),
Q_ARG(quint64, m_generation));
@@ -1124,6 +1143,61 @@ void MainWindow::onQueryFinished(int total, quint64 generation)
// nothing.
m_defaultStatus = tr("%n thread(s)", "", total);
m_statusLabel->setText(m_defaultStatus);
+
+ // The model now holds every row the query matched, so "the whole view" is
+ // a thing that can honestly be acted on.
+ m_queryComplete = true;
+ updateViewWideActions();
+}
+
+void MainWindow::updateViewWideActions()
+{
+ // Threads arrive in batches of kBatchSize, so before the query reports its
+ // total the model holds only what has landed. An action that says "all"
+ // must not run against a partial set and silently skip the rest, and a
+ // disabled control says so without a dialog.
+ if (QAction *action = m_actions.value(QStringLiteral("mark_all_read")))
+ action->setEnabled(m_queryComplete && m_model->rowCount() > 0);
+}
+
+void MainWindow::markAllRead()
+{
+ // Every row, not the selection: this is the one action in the window that
+ // deliberately ignores what is selected.
+ QStringList threadIds;
+ const int rows = m_model->rowCount();
+ threadIds.reserve(rows);
+ for (int row = 0; row < rows; ++row) {
+ const ThreadSummary thread = m_model->threadAt(row);
+ // Only the threads that would actually change. Sending the rest would
+ // inflate the pending-edit count with writes that do nothing, and the
+ // quit prompt reads that count.
+ if (thread.isUnread())
+ threadIds.append(thread.threadId);
+ }
+
+ if (threadIds.isEmpty()) {
+ showTransientStatus(tr("Nothing unread in this view"));
+ return;
+ }
+
+ // An automatic mark-read armed for the open thread would fire after this
+ // and push a second, redundant command onto the stack.
+ m_markReadTimer->stop();
+ m_markReadThreadId.clear();
+
+ const QString description = tr("Mark all read");
+ sendThreadTagChange(threadIds, {}, { QStringLiteral("unread") },
+ description);
+
+ // ONE command for the batch, exactly as tagSelected does: a user who marks
+ // 400 threads read expects a single Ctrl+Z to put them back.
+ m_undoStack.push(new ThreadTagCommand(this, threadIds, {},
+ { QStringLiteral("unread") },
+ description));
+
+ showTransientStatus(
+ tr("%1: %n thread(s)", "", threadIds.size()).arg(description));
}
void MainWindow::showThreadContextMenu(const QPoint &pos)
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 3984e78..adb973c 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -119,6 +119,19 @@ public:
static void setLocksPathForTesting(const QString &path);
static QString locksPath();
+ /// How many commands are on the undo stack.
+ ///
+ /// A test seam. The undo QAction is always enabled and checks canUndo()
+ /// when triggered, so its enabled state says nothing about whether a
+ /// command was pushed, which is what "this did nothing" has to assert.
+ int undoDepthForTesting() const { return m_undoStack.count(); }
+
+ /// The generation a worker reply must carry to be accepted.
+ ///
+ /// A test seam: onQueryFinished() discards a reply whose generation is
+ /// stale, so a test standing in for the worker has to know the current one.
+ quint64 currentGenerationForTesting() const { return m_generation; }
+
protected:
void closeEvent(QCloseEvent *event) override;
@@ -232,6 +245,14 @@ private:
/// when the phase or its detail changes.
void feedSyncPhase(const QString &chunk);
+ /// Removes `unread` from every thread in the current view, as one write and
+ /// one undo entry, ignoring the selection.
+ void markAllRead();
+
+ /// Enables or disables the actions that claim to act on a whole view,
+ /// according to whether the result set is complete.
+ void updateViewWideActions();
+
/// Applies the sync progress bar and button state from BOTH sync sources.
///
/// One function of both, never two assignments: with a local and a
@@ -383,6 +404,11 @@ private:
QStringList m_knownTags;
quint64 m_generation = 0;
+
+ /// True once the running query has reported its total, so the model holds
+ /// the whole result set rather than the batches that have arrived so far.
+ /// Gates mark_all_read, which cannot honestly say "all" before then.
+ bool m_queryComplete = false;
QString m_lastQuery;
QString m_currentThreadId;