aboutsummaryrefslogtreecommitdiffstats
path: root/src/threadlistmodel.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-10 20:43:05 +0200
committerDanilo M. <danix@danix.xyz>2026-08-10 20:43:05 +0200
commited0e085377440a68cef63ade6dc2afd322c22df9 (patch)
treea8b4e5631a6eb5ff56341253f3775b45e3a4c026 /src/threadlistmodel.cpp
parent39a055fef99a3ce6877829753f384843b6a19177 (diff)
downloadqtmaildir-ed0e085377440a68cef63ade6dc2afd322c22df9.tar.gz
qtmaildir-ed0e085377440a68cef63ade6dc2afd322c22df9.zip
feat(view): follow a background sync without a keystroke
The thread list now updates itself when a sync finishes, whether it is empty or populated. New threads appear where the sort puts them, threads that stopped matching leave, and threads whose state changed repaint. Refreshing used to mean re-running the query, which cleared the model, the selection, the message pane and the undo stack, so 0.8.0 declined to do it on a cron timer and asked the user to press Enter instead. The result was a list that quietly disagreed with the database: mail indexed by cron never appeared, and an Unread view read to the end sat empty in front of it. ThreadListModel::reconcile() diffs a result against the current rows by thread id instead, so a surviving thread keeps its row, its persistent index and its loaded replies. Order comes from the result and is never imposed here, which is what makes the sort dropdown authoritative. The undo constraint this was sized around did not exist: no undo entry was ever keyed on a row. ThreadTagCommand stores thread ids and MessageTagCommand stores message ids, and applyTagChange() looks its target up by id, so an entry already survived its rows leaving the view. A thread read out of the current view now leaves the list, which is correct and would otherwise strand the reader, so MessageView grows a notice saying the open thread no longer matches, with a button that re-queries it. Recovery lists the whole conversation, expands it, and restores the message that was on screen rather than reopening at the first one. Ten defects were found building this, nine of them by hand testing: - SyncMonitor::start() polls synchronously, so an idle lock file emits stateChanged(Idle) from inside buildUi() and the first handler to touch a widget segfaults before the window exists. - QTreeView sets a current index when it takes focus with none set, and current drives loading, so new mail opened itself and was marked read without the user having looked at it. Selection is now required. - The notice outlived what it described, both when the pane was blanked and when another message replaced it. - Retiring the "Background sync completed" message left the bar claiming a sync was still running: silent means saying nothing new, not leaving a stale claim on screen. - A thread root sets both the thread id and the message id, so treating the message id as the message-row case discarded it for the commonest way to open a thread. - A freshly queried root does not know its own first message until the tree loads, so recovery selected nothing and left the pane blank. - A user query mid-recovery had its result hijacked by the pending selection. - MessageView emitted the recovery signal with its own members, so a direct connection handed MainWindow references that runCurrentQuery() then cleared by blanking the pane. The ids went empty mid-slot and no recovery ever ran. Every test passed against this, because reaching a slot through invokeMethod copies its arguments. A Qt signal argument is a reference until something copies it. Emitting a member to a slot that can re-enter the emitter is a use-after-write, and it presents as a wrong value rather than as a crash. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'src/threadlistmodel.cpp')
-rw-r--r--src/threadlistmodel.cpp104
1 files changed, 104 insertions, 0 deletions
diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp
index bdc7e96..21a6378 100644
--- a/src/threadlistmodel.cpp
+++ b/src/threadlistmodel.cpp
@@ -18,6 +18,8 @@
#include "threadlistmodel.h"
+#include <QSet>
+
#include <QBrush>
#include <QFont>
#include <QGuiApplication>
@@ -551,6 +553,108 @@ void ThreadListModel::appendBatch(const QVector<ThreadSummary> &batch)
endInsertRows();
}
+void ThreadListModel::reconcile(const QVector<ThreadSummary> &threads)
+{
+ // Removals first, walking BACKWARDS. Each beginRemoveRows renumbers
+ // everything after it, so a forward walk would delete by stale indices; a
+ // backward one only ever disturbs rows it has already passed.
+ //
+ // One signal per contiguous run rather than per row: a view rebuilds its
+ // selection and its persistent indexes on every one, and an Unread view
+ // emptied by a sync can drop dozens at once.
+ QSet<QString> wanted;
+ wanted.reserve(threads.size());
+ for (const ThreadSummary &summary : threads)
+ wanted.insert(summary.threadId);
+
+ for (int row = m_threads.size() - 1; row >= 0; --row) {
+ if (wanted.contains(m_threads.at(row).summary.threadId))
+ continue;
+ int first = row;
+ while (first > 0
+ && !wanted.contains(m_threads.at(first - 1).summary.threadId))
+ --first;
+ beginRemoveRows({}, first, row);
+ m_threads.remove(first, row - first + 1);
+ endRemoveRows();
+ row = first;
+ }
+
+ // What survived, by id, so the second pass can tell an arrival from a
+ // thread that merely moved.
+ QHash<QString, int> present;
+ present.reserve(m_threads.size());
+ for (int row = 0; row < m_threads.size(); ++row)
+ present.insert(m_threads.at(row).summary.threadId, row);
+
+ // Insertions, forwards, at the position the RESULT gives them. Walking the
+ // result in order means each new thread is placed against rows already
+ // agreed on, so the model ends in the result's order without this having to
+ // know what that order means.
+ for (int target = 0; target < threads.size(); ++target) {
+ const ThreadSummary &summary = threads.at(target);
+ const auto it = present.constFind(summary.threadId);
+
+ if (it == present.constEnd()) {
+ const int at = qMin(target, m_threads.size());
+ beginInsertRows({}, at, at);
+ m_threads.insert(at, ThreadNode{ summary, {}, {}, false });
+ endInsertRows();
+
+ // Every later row shifted by one, and the map is read again on the
+ // next iteration.
+ for (auto entry = present.begin(); entry != present.end(); ++entry) {
+ if (entry.value() >= at)
+ ++entry.value();
+ }
+ continue;
+ }
+
+ // A survivor that MOVED, which is neither an arrival nor a departure
+ // and is the commonest reordering there is: a new reply bumps an old
+ // thread to the front under newest-first. beginMoveRows, not a
+ // remove-and-insert pair, because a removed row takes its persistent
+ // index, its selection and its expansion with it, which is exactly what
+ // this method exists to keep.
+ //
+ // Always UPWARDS, and that is a property of the walk rather than an
+ // assumption about the data. Positions ahead of `target` are already
+ // final, so a survivor found at a later row is pulled forward and one
+ // found earlier cannot exist: it would have been placed on a previous
+ // iteration. A downward branch here would be unreachable, so there
+ // isn't one, and the destination needs no adjustment (Qt reads it
+ // before the source is removed, which only shifts a downward move).
+ int row = it.value();
+ if (row != target) {
+ Q_ASSERT(row > target);
+ beginMoveRows({}, row, row, {}, target);
+ m_threads.move(row, target);
+ endMoveRows();
+
+ // Every row between the two shifted one place later.
+ for (auto entry = present.begin(); entry != present.end(); ++entry) {
+ if (entry.value() >= target && entry.value() < row)
+ ++entry.value();
+ }
+ present[summary.threadId] = target;
+ row = target;
+ }
+
+ // Keep the ROW, replace the summary. The node is not reconstructed,
+ // because its children and its loaded flag are the expansion state this
+ // whole method exists to preserve.
+ if (m_threads.at(row).summary.tags != summary.tags
+ || m_threads.at(row).summary.subject != summary.subject
+ || m_threads.at(row).summary.authors != summary.authors
+ || m_threads.at(row).summary.date != summary.date
+ || m_threads.at(row).summary.totalCount != summary.totalCount
+ || m_threads.at(row).summary.matchedCount != summary.matchedCount) {
+ m_threads[row].summary = summary;
+ emit dataChanged(index(row, 0), index(row, 0));
+ }
+ }
+}
+
void ThreadListModel::clear()
{
beginResetModel();