aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-26 19:31:45 +0200
committerDanilo M. <danix@danix.xyz>2026-08-26 19:31:45 +0200
commit59a196ea849c64c51311f56bf62130e74d90b784 (patch)
treef322f955b0b5981dfafc85db72f224795941a4ad
parent1c034f6358f17c5c1d0eeaa04c42c33fac125d93 (diff)
downloadqtmaildir-59a196ea849c64c51311f56bf62130e74d90b784.tar.gz
qtmaildir-59a196ea849c64c51311f56bf62130e74d90b784.zip
feat: open the unsynced-changes count to see what it counts
Item 119, and item 146 which is the same request recorded again. The status bar's count answers "is my work safe to quit on" and could not say what the work was. The label opens a read-only list on a click. A QLabel has no clicked signal, so the press is taken by MainWindow's existing event filter rather than by replacing the label with a flat QToolButton, which would have brought the style's button metrics into a status bar the label already sits correctly in. The pointing-hand cursor is the affordance, since a status-bar label has room for nothing else. The layout is the user's own: a message appears once with its actions beneath it. PendingChangesDialog::rowsFor() does the grouping over a run of rows sharing an id, which the snapshot has already ordered, so the actions under one message keep the order they were made in. Read-only, deliberately. Retrying or discarding a change from here would be a new mutation path with its own undo question, and the count exists to be understood rather than edited. Three rules the tests pin, each of which is a way the list could disagree with the count it was opened from: - Grouping must not collapse: two actions on one message are two rows. - A thread row stays thread-scoped and reports how many messages it covered. - An id the index no longer holds still opens a run of its own, showing that its subject is unknown rather than folding its actions under the message above it. This is why the row carries startsMessage rather than inferring it from a non-empty subject. The queued call carrying QStringList, QList<bool> and QList<int> is covered by a test that drives it across a real thread, since a container whose metatype does not resolve is dropped at runtime and the slot runs with a default. Both survive on Qt 6.11; the test is what says so, and what would fail if that changed. Italian ships with it: five new strings, lupdate clean, lrelease 522 finished and 0 unfinished. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P88Q3MCSCSQxKDy7pmXh9F
-rw-r--r--src/CMakeLists.txt1
-rw-r--r--src/mainwindow.cpp76
-rw-r--r--src/mainwindow.h18
-rw-r--r--src/pendingchangesdialog.cpp121
-rw-r--r--src/pendingchangesdialog.h86
-rw-r--r--tests/CMakeLists.txt1
-rw-r--r--tests/test_mainwindow.cpp48
-rw-r--r--tests/test_notmuchworker.cpp35
-rw-r--r--tests/test_pendingchangesdialog.cpp149
-rw-r--r--translations/qtmaildir_it_IT.ts26
10 files changed, 561 insertions, 0 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 7cec9b3..591e95f 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -35,6 +35,7 @@ add_library(qtmaildir_lib STATIC
threadcidmap.cpp
messageview.cpp
messagedetailsdialog.cpp
+ pendingchangesdialog.cpp
mainwindow.cpp
querycompleter.cpp
rulequery.cpp
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 0c6092e..231a9a5 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -26,6 +26,7 @@
#include <QApplication>
#include <QCloseEvent>
#include <QKeyEvent>
+#include <QMouseEvent>
#include <QComboBox>
#include <QDialog>
#include <QDialogButtonBox>
@@ -65,6 +66,7 @@
#include "searchterm.h"
#include "tagchip.h"
#include "tagdialog.h"
+#include "pendingchangesdialog.h"
#include "savequerydialog.h"
#include "tagrulesdialog.h"
#include "threadlistmodel.h"
@@ -490,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);
}
@@ -682,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);
@@ -2618,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
@@ -5108,6 +5131,59 @@ QVector<PendingChange> MainWindow::pendingChangeSnapshot() const
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()
{
const int pending = pendingEditCount();
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 532a5ea..6321573 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -123,6 +123,13 @@ public:
QVector<PendingChange> pendingChangeSnapshot() const;
+ /// Opens the list behind the unsynced-changes count.
+ ///
+ /// Takes the snapshot, asks the worker to resolve its subjects, and shows
+ /// the dialog when they arrive. Q_INVOKABLE so a test can open it without
+ /// synthesising a click on a status-bar label.
+ Q_INVOKABLE void showPendingChanges();
+
/// Whether the undo stack still holds anything. Exposed so a test can show
/// that a rejected write did not take unrelated history down with it.
bool canUndo() const { return m_undoStack.canUndo(); }
@@ -618,6 +625,10 @@ private slots:
/// A tag mutation the worker has confirmed reached the database. Counts it
/// as unsynced, since reaching the index is not reaching the mail store.
void onTagsApplied(const TagChange &change);
+
+ /// The subjects for the pending-changes list arrived; show the dialog.
+ void onPendingSubjectsResolved(const QStringList &subjects,
+ const QList<int> &messageCounts);
void onAllTagsReady(const QStringList &tags);
/// The Maildir root, answered once at startup. Enables nothing on its own:
@@ -1575,6 +1586,13 @@ private:
};
QHash<QString, PendingEdit> m_pendingTagEdits;
+ /// The snapshot taken when the user clicked the indicator, held while the
+ /// worker resolves its subjects. Empty when no such request is in flight.
+ ///
+ /// One request at a time: a second click before the first answers replaces
+ /// it, which is right because both would show the same thing.
+ QVector<PendingChange> m_pendingChangeRequest;
+
/// Marks the open thread read once it has been on screen long enough.
///
/// Single-shot and RESTARTED on every selection change, never stacked:
diff --git a/src/pendingchangesdialog.cpp b/src/pendingchangesdialog.cpp
new file mode 100644
index 0000000..71e3bb0
--- /dev/null
+++ b/src/pendingchangesdialog.cpp
@@ -0,0 +1,121 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include "pendingchangesdialog.h"
+
+#include <QDialogButtonBox>
+#include <QGridLayout>
+#include <QLabel>
+#include <QScrollArea>
+#include <QVBoxLayout>
+
+QVector<PendingChangeRow> PendingChangesDialog::rowsFor(
+ const QVector<PendingChange> &changes)
+{
+ QVector<PendingChangeRow> rows;
+ rows.reserve(changes.size());
+
+ // A run is a stretch of changes sharing one id, which the snapshot has
+ // already grouped. Only the first row of a run carries a subject, so the
+ // actions read as belonging to the message above them.
+ //
+ // Compared against the PREVIOUS id rather than collected into a map: the
+ // snapshot's order is deliberate (the actions under one message keep the
+ // order they were made in), and a map would discard it.
+ QString previousId;
+ bool first = true;
+ for (const PendingChange &change : changes) {
+ const bool startsMessage = first || change.id != previousId;
+ rows.append(PendingChangeRow{
+ startsMessage ? change.subject : QString(),
+ change.action,
+ startsMessage,
+ startsMessage ? change.messageCount : -1 });
+ previousId = change.id;
+ first = false;
+ }
+ return rows;
+}
+
+PendingChangesDialog::PendingChangesDialog(
+ const QVector<PendingChange> &changes, QWidget *parent)
+ : QDialog(parent), m_rows(rowsFor(changes))
+{
+ setWindowTitle(tr("Unsynced changes"));
+
+ auto *layout = new QVBoxLayout(this);
+
+ auto *intro = new QLabel(
+ tr("Changes made here that a sync has not yet carried to the mail "
+ "store. This list is a snapshot taken when it was opened."),
+ this);
+ intro->setWordWrap(true);
+ layout->addWidget(intro);
+
+ auto *content = new QWidget;
+ auto *grid = new QGridLayout(content);
+ grid->setColumnStretch(0, 1);
+
+ int line = 0;
+ for (const PendingChangeRow &row : m_rows) {
+ if (row.startsMessage) {
+ // PlainText stated rather than left to Qt, for the reason
+ // MessageDetailsDialog states it on every value: a subject comes
+ // from a stranger, and a QLabel guesses under Qt::AutoText. Plain
+ // text cannot interpret markup, so there is nothing to escape.
+ QString text = row.subject;
+ if (text.isEmpty()) {
+ // The id no longer resolves. The row stays, because the count
+ // the user clicked has to equal the list they are shown.
+ text = tr("(no longer in the index)");
+ }
+ if (row.messageCount >= 0) {
+ text = tr("%1 (whole thread, %n message(s))", "",
+ row.messageCount).arg(text);
+ }
+ auto *subject = new QLabel(text, content);
+ subject->setTextFormat(Qt::PlainText);
+ subject->setWordWrap(true);
+ grid->addWidget(subject, line, 0);
+ }
+
+ auto *action = new QLabel(row.action, content);
+ action->setTextFormat(Qt::PlainText);
+ grid->addWidget(action, line, 1, Qt::AlignTop | Qt::AlignRight);
+ ++line;
+ }
+
+ if (m_rows.isEmpty()) {
+ grid->addWidget(new QLabel(tr("Nothing is waiting to be synced."),
+ content),
+ 0, 0);
+ }
+
+ grid->setRowStretch(line, 1);
+
+ auto *scroll = new QScrollArea(this);
+ scroll->setWidget(content);
+ scroll->setWidgetResizable(true);
+ layout->addWidget(scroll);
+
+ auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, this);
+ connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
+ layout->addWidget(buttons);
+
+ resize(600, 380);
+}
diff --git a/src/pendingchangesdialog.h b/src/pendingchangesdialog.h
new file mode 100644
index 0000000..5e47cbd
--- /dev/null
+++ b/src/pendingchangesdialog.h
@@ -0,0 +1,86 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#pragma once
+
+#include <QDialog>
+#include <QVector>
+
+#include "types.h"
+
+/// What one line of the dialog shows.
+///
+/// Separate from PendingChange because the two answer different questions.
+/// PendingChange is what is outstanding; this is what is drawn, and the
+/// difference is the grouping: a message with several actions contributes
+/// several rows here, only the first of which carries a subject.
+struct PendingChangeRow
+{
+ /// The subject, drawn only on the first row of a run sharing one id.
+ /// Empty on the rows beneath it, which is what puts the actions under
+ /// their message rather than beside a repeated subject.
+ QString subject;
+
+ /// What the user did. Every row has one; this is the point of the list.
+ QString action;
+
+ /// True when this row opens a new message, i.e. when `subject` is drawn.
+ /// Carried explicitly rather than inferred from a non-empty subject: a
+ /// message whose id no longer resolves has an EMPTY subject and still
+ /// opens a run of its own.
+ bool startsMessage = false;
+
+ /// How many messages a thread row covered, or -1 for a message row.
+ int messageCount = -1;
+};
+
+/// The list behind the unsynced-changes count (item 119).
+///
+/// Read-only, deliberately. This is an information window, not a place to
+/// retry or discard a change: either would be a new mutation path with its own
+/// undo question, and the count exists to answer "is my work safe to quit on"
+/// rather than to be edited.
+///
+/// A SNAPSHOT. The rows are built once, when the user opens it, and never
+/// refreshed underneath them: a dialog left open for twenty minutes shows what
+/// was true when it was opened, which is what the user clicked on.
+///
+/// Rows are exposed so the grouping can be asserted without rendering
+/// anything, which is how MessageDetailsDialog is tested and for the same
+/// reason: a pixel probe cannot tell a correct layout from a plausible one.
+class PendingChangesDialog : public QDialog
+{
+ Q_OBJECT
+public:
+ explicit PendingChangesDialog(const QVector<PendingChange> &changes,
+ QWidget *parent = nullptr);
+
+ /// The lines on display, in order. Exposed for testing without rendering.
+ QVector<PendingChangeRow> rows() const { return m_rows; }
+
+ /// Groups the changes into display rows: a subject on the first row of
+ /// each run sharing an id, the actions beneath it.
+ ///
+ /// Static and value-in, value-out so the grouping is testable with no
+ /// widget at all.
+ static QVector<PendingChangeRow> rowsFor(
+ const QVector<PendingChange> &changes);
+
+private:
+ QVector<PendingChangeRow> m_rows;
+};
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 69c57ee..2cb3651 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -52,6 +52,7 @@ add_qtmaildir_test(htmlbuilder)
add_qtmaildir_test(notmuchworker)
add_qtmaildir_test(tagcolors)
add_qtmaildir_test(cardlayout)
+add_qtmaildir_test(pendingchangesdialog)
add_qtmaildir_test(avatar)
add_qtmaildir_test(businesssenders)
add_qtmaildir_test(marks)
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index be19652..a572fd0 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -47,6 +47,7 @@
#include "config.h"
#include "keymap.h"
#include "mainwindow.h"
+#include "pendingchangesdialog.h"
#include "messageview.h"
#include "mimeparser.h"
#include "notmuchworker.h"
@@ -418,6 +419,7 @@ private slots:
void everyPendingChangeCanNameItsMessages();
void theSnapshotGroupsActionsUnderTheirMessage();
void theSnapshotKeepsAThreadActionThreadScoped();
+ void theIndicatorOpensItsListOnAClick();
void anEditDuringABackgroundSyncIsNotSentYet();
void aHeldEditIsSentWhenTheBackgroundSyncEnds();
void aHeldEditCountsAsUnsynced();
@@ -6574,6 +6576,52 @@ void TestMainWindow::theSnapshotKeepsAThreadActionThreadScoped()
QCOMPARE(rows.size(), window.pendingEditCount());
}
+void TestMainWindow::theIndicatorOpensItsListOnAClick()
+{
+ // The label is a QLabel and has no clicked signal, so the click is taken
+ // by an event filter. A test that called showPendingChanges() directly
+ // would pass with that filter never installed, which is the whole gesture.
+ const Config config;
+ MainWindow window(config);
+
+ auto *label = window.findChild<QLabel *>(QStringLiteral("pendingEdits"));
+ QVERIFY(label);
+
+ TagChange change;
+ change.messageIds = { QStringLiteral("click@example.org") };
+ change.added = { QStringLiteral("deleted") };
+ change.description = QStringLiteral("Delete");
+ QVERIFY(QMetaObject::invokeMethod(&window, "onTagsApplied",
+ Q_ARG(TagChange, change)));
+ QVERIFY(!label->isHidden());
+
+ // With no worker the dialog opens directly and modally, so it is closed
+ // from a timer rather than by driving exec() to return some other way.
+ // Polled rather than checked once: exec() parents the dialog and spins its
+ // own event loop, so a single-shot timer can fire before it exists.
+ bool sawDialog = false;
+ auto *poll = new QTimer(&window);
+ poll->setInterval(1);
+ QObject::connect(poll, &QTimer::timeout, &window, [&window, &sawDialog]() {
+ if (auto *dialog = window.findChild<PendingChangesDialog *>()) {
+ sawDialog = true;
+ QCOMPARE(dialog->rows().size(), 1);
+ QCOMPARE(dialog->rows().at(0).action, QStringLiteral("Delete"));
+ dialog->reject();
+ }
+ });
+ poll->start();
+
+ QMouseEvent press(QEvent::MouseButtonRelease, QPointF(1, 1),
+ QPointF(1, 1), Qt::LeftButton, Qt::LeftButton,
+ Qt::NoModifier);
+ QCoreApplication::sendEvent(label, &press);
+
+ // The subjects are resolved on the worker thread, so the dialog appears a
+ // round trip after the click rather than inside sendEvent().
+ QTRY_VERIFY_WITH_TIMEOUT(sawDialog, 15000);
+}
+
// Item 37. A tag edit made while a background sync holds notmuch's write lock
// used to stall the worker: the read-write open BLOCKS until the lock frees
// (measured 9.158s against a 12s hold, returning NOTMUCH_STATUS_SUCCESS), so
diff --git a/tests/test_notmuchworker.cpp b/tests/test_notmuchworker.cpp
index bcde45c..2e960be 100644
--- a/tests/test_notmuchworker.cpp
+++ b/tests/test_notmuchworker.cpp
@@ -58,6 +58,7 @@ private slots:
void applyTagsToThreadsSpansMultipleThreads();
void applyTagsToThreadsWithNoThreadsDoesNothing();
+ void pendingSubjectsCrossAQueuedCall();
void pendingSubjectsAnswerPositionally();
void aMissingPendingIdYieldsAnEmptySubject();
void requestAllTagsReturnsSortedTags();
@@ -964,6 +965,40 @@ void TestNotmuchWorker::applyTagsToThreadsWithNoThreadsDoesNothing()
QVERIFY(errors.isEmpty());
}
+void TestNotmuchWorker::pendingSubjectsCrossAQueuedCall()
+{
+ // The dialog reaches the worker over a QUEUED connection, and a container
+ // whose metatype is not registered under the name invokeMethod resolves is
+ // DROPPED at runtime with a warning, leaving the slot to run with a
+ // default. CLAUDE.md records that trap for Q_ENUM; QList<int> is the same
+ // trap in a different shape, and it is the type this signal answers with.
+ //
+ // Driven through invokeMethod on a real thread rather than by calling the
+ // slot directly: a direct call proves nothing about the queued path, which
+ // is the only one production uses.
+ NotmuchWorker worker(m_fixture.configPath());
+ QThread thread;
+ worker.moveToThread(&thread);
+ thread.start();
+
+ QSignalSpy spy(&worker, &NotmuchWorker::pendingSubjectsResolved);
+ QVERIFY(QMetaObject::invokeMethod(
+ &worker, "resolvePendingSubjects", Qt::QueuedConnection,
+ Q_ARG(QStringList, QStringList{ QStringLiteral("b1@example.org") }),
+ Q_ARG(QList<bool>, QList<bool>{ false })));
+
+ QVERIFY2(spy.wait(5000),
+ "the queued call never produced an answer: a container argument "
+ "was most likely dropped for want of a registered metatype");
+ QCOMPARE(spy.first().at(0).toStringList().size(), 1);
+ QVERIFY(!spy.first().at(0).toStringList().at(0).isEmpty());
+ // And the counts survived the crossing as a real list, not a default.
+ QCOMPARE(spy.first().at(1).value<QList<int>>().size(), 1);
+
+ thread.quit();
+ QVERIFY(thread.wait(5000));
+}
+
void TestNotmuchWorker::pendingSubjectsAnswerPositionally()
{
// Item 119. The dialog has already decided what its rows are and in what
diff --git a/tests/test_pendingchangesdialog.cpp b/tests/test_pendingchangesdialog.cpp
new file mode 100644
index 0000000..078f6a8
--- /dev/null
+++ b/tests/test_pendingchangesdialog.cpp
@@ -0,0 +1,149 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include <QtTest>
+
+#include "pendingchangesdialog.h"
+
+/// The grouping the user asked for, asserted on the ROWS rather than on a
+/// render. A pixel probe cannot tell a correct layout from a plausible one,
+/// which is why MessageDetailsDialog exposes its rows too.
+class TestPendingChangesDialog : public QObject
+{
+ Q_OBJECT
+private slots:
+ void aMessageIsDrawnOnceWithItsActionsBeneath();
+ void everyChangeKeepsARowOfItsOwn();
+ void aThreadRowCarriesItsMessageCount();
+ void anUnresolvedIdStillOpensItsRun();
+};
+
+void TestPendingChangesDialog::aMessageIsDrawnOnceWithItsActionsBeneath()
+{
+ // The layout: subject once, actions under it.
+ //
+ // Build fails Delete
+ // Mark read
+ // August digest Delete
+ const QVector<PendingChange> changes {
+ { QStringLiteral("a@example.org"), false, QStringLiteral("Delete"),
+ QStringLiteral("Build fails"), -1 },
+ { QStringLiteral("a@example.org"), false, QStringLiteral("Mark read"),
+ QStringLiteral("Build fails"), -1 },
+ { QStringLiteral("b@example.org"), false, QStringLiteral("Delete"),
+ QStringLiteral("August digest"), -1 },
+ };
+
+ const QVector<PendingChangeRow> rows =
+ PendingChangesDialog::rowsFor(changes);
+ QCOMPARE(rows.size(), 3);
+
+ // First row of the run carries the subject.
+ QVERIFY(rows.at(0).startsMessage);
+ QCOMPARE(rows.at(0).subject, QStringLiteral("Build fails"));
+ QCOMPARE(rows.at(0).action, QStringLiteral("Delete"));
+
+ // The second action of the same message carries NO subject, which is what
+ // puts it under the message rather than beside a repeated one.
+ QVERIFY(!rows.at(1).startsMessage);
+ QVERIFY2(rows.at(1).subject.isEmpty(),
+ "the subject was repeated instead of grouping the actions");
+ QCOMPARE(rows.at(1).action, QStringLiteral("Mark read"));
+
+ // A different message opens a new run.
+ QVERIFY(rows.at(2).startsMessage);
+ QCOMPARE(rows.at(2).subject, QStringLiteral("August digest"));
+}
+
+void TestPendingChangesDialog::everyChangeKeepsARowOfItsOwn()
+{
+ // Grouping must not COLLAPSE anything: the count the user clicked has to
+ // equal the number of rows they are shown, so two actions on one message
+ // are two rows however they are drawn.
+ const QVector<PendingChange> changes {
+ { QStringLiteral("a@example.org"), false, QStringLiteral("Delete"),
+ QStringLiteral("One"), -1 },
+ { QStringLiteral("a@example.org"), false, QStringLiteral("Mark read"),
+ QStringLiteral("One"), -1 },
+ { QStringLiteral("a@example.org"), false, QStringLiteral("Mark spam"),
+ QStringLiteral("One"), -1 },
+ };
+
+ const QVector<PendingChangeRow> rows =
+ PendingChangesDialog::rowsFor(changes);
+ QCOMPARE(rows.size(), changes.size());
+
+ // Exactly one of them opens the run, and every action survives.
+ int starts = 0;
+ QStringList actions;
+ for (const PendingChangeRow &row : rows) {
+ if (row.startsMessage)
+ ++starts;
+ actions.append(row.action);
+ }
+ QCOMPARE(starts, 1);
+ QCOMPARE(actions, QStringList({ QStringLiteral("Delete"),
+ QStringLiteral("Mark read"),
+ QStringLiteral("Mark spam") }));
+}
+
+void TestPendingChangesDialog::aThreadRowCarriesItsMessageCount()
+{
+ // A thread action reports how many messages it covered. The count belongs
+ // to the row that opens the run, since that is where the subject is drawn.
+ const QVector<PendingChange> changes {
+ { QStringLiteral("t1"), true, QStringLiteral("Delete thread"),
+ QStringLiteral("A conversation"), 4 },
+ { QStringLiteral("m1@example.org"), false, QStringLiteral("Delete"),
+ QStringLiteral("A message"), -1 },
+ };
+
+ const QVector<PendingChangeRow> rows =
+ PendingChangesDialog::rowsFor(changes);
+ QCOMPARE(rows.size(), 2);
+ QCOMPARE(rows.at(0).messageCount, 4);
+ // A message row claims no count: it stands for one message and saying "1"
+ // would read as a thread of one.
+ QCOMPARE(rows.at(1).messageCount, -1);
+}
+
+void TestPendingChangesDialog::anUnresolvedIdStillOpensItsRun()
+{
+ // A stale id resolves to an empty subject. The row must still OPEN a run,
+ // or its actions would be drawn as though they belonged to the message
+ // above them, which is worse than saying the subject is unknown.
+ //
+ // This is why startsMessage is carried rather than inferred from a
+ // non-empty subject.
+ const QVector<PendingChange> changes {
+ { QStringLiteral("a@example.org"), false, QStringLiteral("Delete"),
+ QStringLiteral("Known"), -1 },
+ { QStringLiteral("gone@example.org"), false, QStringLiteral("Delete"),
+ QString(), -1 },
+ };
+
+ const QVector<PendingChangeRow> rows =
+ PendingChangesDialog::rowsFor(changes);
+ QCOMPARE(rows.size(), 2);
+ QVERIFY2(rows.at(1).startsMessage,
+ "an unresolved id was folded into the message above it");
+ QVERIFY(rows.at(1).subject.isEmpty());
+}
+
+QTEST_MAIN(TestPendingChangesDialog)
+#include "test_pendingchangesdialog.moc"
diff --git a/translations/qtmaildir_it_IT.ts b/translations/qtmaildir_it_IT.ts
index 0d2c36f..85b978f 100644
--- a/translations/qtmaildir_it_IT.ts
+++ b/translations/qtmaildir_it_IT.ts
@@ -1622,6 +1622,32 @@ Il messaggio È stato inviato. Non inviarlo di nuovo.</translation>
</message>
</context>
<context>
+ <name>PendingChangesDialog</name>
+ <message>
+ <source>Unsynced changes</source>
+ <translation>Modifiche non sincronizzate</translation>
+ </message>
+ <message>
+ <source>Changes made here that a sync has not yet carried to the mail store. This list is a snapshot taken when it was opened.</source>
+ <translation>Modifiche fatte qui che una sincronizzazione non ha ancora portato all'archivio di posta. Questo elenco è un'istantanea presa al momento dell'apertura.</translation>
+ </message>
+ <message>
+ <source>(no longer in the index)</source>
+ <translation>(non più nell'indice)</translation>
+ </message>
+ <message numerus="yes">
+ <source>%1 (whole thread, %n message(s))</source>
+ <translation>
+ <numerusform>%1 (intera conversazione, %n messaggio)</numerusform>
+ <numerusform>%1 (intera conversazione, %n messaggi)</numerusform>
+ </translation>
+ </message>
+ <message>
+ <source>Nothing is waiting to be synced.</source>
+ <translation>Non c'è nulla in attesa di sincronizzazione.</translation>
+ </message>
+</context>
+<context>
<name>QObject</name>
<message>
<source>qtmaildir</source>