aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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>