aboutsummaryrefslogtreecommitdiffstats
path: root/src
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
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')
-rw-r--r--src/mainwindow.cpp293
-rw-r--r--src/mainwindow.h105
-rw-r--r--src/messageview.cpp56
-rw-r--r--src/messageview.h39
-rw-r--r--src/threadlistmodel.cpp104
-rw-r--r--src/threadlistmodel.h14
6 files changed, 600 insertions, 11 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index b7efec5..bc5fa92 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -626,6 +626,8 @@ void MainWindow::buildUi()
this, [this](const QString &text) { m_statusLabel->setText(text); });
connect(m_messageView, &MessageView::queryRequested,
this, &MainWindow::onPlaceholderQueryRequested);
+ connect(m_messageView, &MessageView::staleThreadRecoveryRequested,
+ this, &MainWindow::recoverStaleThread);
m_splitter = new QSplitter(Qt::Horizontal, central);
m_splitter->addWidget(m_threadView);
@@ -867,6 +869,7 @@ void MainWindow::registerActions()
// CLAUDE.md.
m_currentThreadId.clear();
m_currentMessageId.clear();
+ m_currentMessageThreadId.clear();
m_messageView->clear();
showPlaceholderPane();
m_markReadTimer->stop();
@@ -901,6 +904,7 @@ void MainWindow::registerActions()
m_currentThreadId.clear();
m_currentMessageId.clear();
+ m_currentMessageThreadId.clear();
m_messageView->clear();
showPlaceholderPane();
m_markReadTimer->stop();
@@ -1480,6 +1484,15 @@ void MainWindow::runCurrentQuery()
// Kept so loadThread() can work out which messages of a thread matched.
m_lastQuery = query;
+ // A query the user ran abandons any recovery still in flight. Recovery
+ // spans two round-trips, so a query typed in the middle of one would
+ // otherwise have its result hijacked: the pending selection finds its
+ // thread in a result the user asked for something else from, and the view
+ // jumps. recoverStaleThread() sets the target AFTER calling this, so its
+ // own query does not clear it.
+ m_recoverThreadId.clear();
+ m_recoverMessageId.clear();
+
++m_generation;
m_model->clear();
m_messageView->clear();
@@ -1513,6 +1526,17 @@ void MainWindow::onThreadsReady(const QVector<ThreadSummary> &threads,
{
if (generation != m_generation)
return; // Superseded by a newer query.
+
+ // A refresh accumulates instead of appending. Its batches must not reach
+ // the model one at a time: reconcile() decides what to REMOVE from what the
+ // result does not contain, so applying the first batch alone would delete
+ // every row after it, then the next batch would put some back. The list
+ // would churn and every expanded thread would collapse.
+ if (generation == m_refreshGeneration) {
+ m_refreshThreads.append(threads);
+ return;
+ }
+
m_model->appendBatch(threads);
}
@@ -1520,6 +1544,30 @@ void MainWindow::onQueryFinished(int total, quint64 generation)
{
if (generation != m_generation)
return;
+
+ // The refresh's result is complete only now, so this is where it lands.
+ // One reconcile for the whole set, not one per batch.
+ if (generation == m_refreshGeneration) {
+ m_refreshGeneration = 0;
+ m_model->reconcile(m_refreshThreads);
+ m_refreshThreads.clear();
+
+ // The count in the status bar describes the current view and has just
+ // changed, but a refresh is meant to be silent, so it updates the
+ // FALLBACK text without stamping over whatever the bar is showing.
+ m_defaultStatus = tr("%n thread(s)", "", total);
+
+ // A refresh leaves the view complete exactly as a query does: every
+ // matching row is present, so view-wide actions stay honest.
+ m_queryComplete = true;
+ updateViewWideActions();
+
+ // The open thread may have stopped matching, which the user has to be
+ // told about: the pane keeps rendering it while the list no longer
+ // offers it anywhere.
+ updateStaleThreadNotice();
+ return;
+ }
// 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.
@@ -1530,6 +1578,11 @@ void MainWindow::onQueryFinished(int total, quint64 generation)
// a thing that can honestly be acted on.
m_queryComplete = true;
updateViewWideActions();
+
+ // A recovery's own thread:<id> query landing. The rows exist now, so the
+ // thread can be expanded; the message inside it is selected once its
+ // replies arrive.
+ applyPendingRecovery();
}
void MainWindow::updateViewWideActions()
@@ -1692,6 +1745,7 @@ void MainWindow::onSelectionChanged()
m_markReadThreadId.clear();
m_currentThreadId.clear();
m_currentMessageId.clear();
+ m_currentMessageThreadId.clear();
m_messageView->clear();
showPlaceholderPane();
}
@@ -1702,6 +1756,29 @@ void MainWindow::onThreadSelected(const QModelIndex &current,
if (!current.isValid())
return;
+ // A current index the user did not put there. QTreeView gives itself one
+ // when it takes FOCUS with none set (verified against Qt 6.11: inserting
+ // rows does not do it, focusing the view does), and it sets current WITHOUT
+ // selecting. Before item 35b nothing could reach that state, because a
+ // populated list always had a current row; now a refresh can drop mail into
+ // a view the user read empty, and coming back to the window from another
+ // desktop would open the new message and mark it read two seconds later
+ // without them ever having looked at it.
+ //
+ // Every real route here (a click, an arrow key, selectRowAt) selects the
+ // row as well, so requiring a selection separates the user's intent from
+ // Qt's housekeeping without weakening any of them.
+ if (!m_threadView->selectionModel()->isSelected(current))
+ return;
+
+ // The notice belongs to whatever the pane is showing, and it is about to
+ // show something else. Retired here rather than only in MessageView::clear()
+ // because selecting a row RE-RENDERS the pane instead of blanking it, so
+ // the bar would otherwise sit over a message it does not describe. That is
+ // the second half of the reported defect: the pane had moved on and the
+ // notice had not.
+ m_messageView->setStaleThread(QString(), QString());
+
// 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,
@@ -1724,6 +1801,7 @@ void MainWindow::onThreadSelected(const QModelIndex &current,
m_markReadThreadId.clear();
m_currentThreadId.clear();
m_currentMessageId.clear();
+ m_currentMessageThreadId.clear();
m_messageView->clear();
showPlaceholderPane();
return;
@@ -1747,6 +1825,9 @@ void MainWindow::onThreadSelected(const QModelIndex &current,
m_currentThreadId.clear();
m_currentMessageId = node.messageId;
+ // Remembered for the stale notice: the pane shows one message, but the
+ // thread it came from is what the refreshed list is checked against.
+ m_currentMessageThreadId = node.threadId;
m_messageView->setTags(node.tags);
QMetaObject::invokeMethod(m_worker, "loadMessage", Qt::QueuedConnection,
Q_ARG(QString, node.messageId),
@@ -1780,6 +1861,7 @@ void MainWindow::onThreadSelected(const QModelIndex &current,
}
m_currentMessageId.clear();
+ m_currentMessageThreadId.clear();
QMetaObject::invokeMethod(m_worker, "loadThread", Qt::QueuedConnection,
Q_ARG(QString, m_currentThreadId),
Q_ARG(QString, m_lastQuery),
@@ -1836,6 +1918,10 @@ void MainWindow::onThreadTreeLoaded(const QVector<MessageNode> &nodes,
// expansions can be in flight at once, and pairing them by order would
// attach one thread's replies to the other.
m_model->setThreadMessages(nodes.first().threadId, nodes);
+
+ // A stale-thread recovery waits for exactly this: the message it wants to
+ // select does not exist as a row until the replies land.
+ applyPendingRecovery();
}
void MainWindow::onThreadLoaded(const QVector<MessageRef> &messages,
@@ -2137,6 +2223,169 @@ void MainWindow::onTagsApplied(const TagChange &change)
}
}
+void MainWindow::refreshCurrentQuery()
+{
+ // The null guard is not defensive padding, it is a reachable path found by
+ // this item's own test crashing the constructor. SyncMonitor::start() polls
+ // SYNCHRONOUSLY (src/syncmonitor.cpp:52), so a machine whose lock file is
+ // idle at that moment emits stateChanged(Idle) from inside buildUi(), while
+ // m_model and the worker are still null. Nothing to refresh at that point
+ // anyway: the startup query has not run.
+ if (!m_model || !m_worker)
+ return;
+
+ // m_lastQuery, not the text in the query bar: the bar holds whatever the
+ // user has typed since, which may be a query they never ran. Refreshing to
+ // that would execute a search they did not ask for.
+ if (m_lastQuery.isEmpty())
+ return;
+
+ // Nothing is cleared. No m_model->clear(), no m_undoStack.clear(), no
+ // m_messageView->clear(): that list is exactly what runCurrentQuery()
+ // destroys and what makes it unusable on a cron timer.
+ m_refreshGeneration = ++m_generation;
+ m_refreshThreads.clear();
+
+ const auto sort = m_sortOrder->currentIndex() == 1
+ ? NotmuchWorker::OldestFirst
+ : NotmuchWorker::NewestFirst;
+ QMetaObject::invokeMethod(m_worker, "runQuery", Qt::QueuedConnection,
+ Q_ARG(QString, m_lastQuery),
+ Q_ARG(quint64, m_refreshGeneration),
+ Q_ARG(NotmuchWorker::SortOrder, sort));
+}
+
+void MainWindow::updateStaleThreadNotice()
+{
+ // Which thread the pane is showing depends on what was selected: a thread
+ // row sets m_currentThreadId, a message row clears it and sets
+ // m_currentMessageId instead, so the message case has to be resolved back
+ // to its thread. Reading only m_currentThreadId would leave a reader who is
+ // three replies deep with no notice at all, which is the commonest way to
+ // be deep in a thread in the first place.
+ // The message id is carried whenever there IS one, whichever row kind put
+ // it there. A thread ROOT sets both: the root card is the thread's first
+ // message and the pane renders that message alone, so treating the message
+ // id as the message-row case only threw it away for the commonest way to
+ // open a thread, and recovery then had nothing to reopen.
+ QString threadId = m_currentThreadId;
+ const QString messageId = m_currentMessageId;
+ if (threadId.isEmpty())
+ threadId = m_currentMessageThreadId;
+
+ if (threadId.isEmpty()) {
+ m_messageView->setStaleThread(QString(), QString());
+ return;
+ }
+
+ // Present means matching: the model holds exactly the query's result after
+ // a reconcile.
+ for (int row = 0; row < m_model->rowCount(QModelIndex()); ++row) {
+ if (m_model->threadAt(row).threadId == threadId) {
+ m_messageView->setStaleThread(QString(), QString());
+ return;
+ }
+ }
+
+ m_messageView->setStaleThread(threadId, messageId);
+}
+
+void MainWindow::recoverStaleThread(const QString &threadId,
+ const QString &messageId)
+{
+ if (threadId.isEmpty())
+ return;
+
+ // thread:<id> lists the WHOLE conversation rather than the single message,
+ // which is what the user asked for: eight messages, with the fourth
+ // selected, matching what the pane already shows.
+ m_queryEdit->setText(QStringLiteral("thread:%1").arg(threadId));
+ runCurrentQuery();
+
+ // Set AFTER the query, which clears any pending recovery: this one is the
+ // query's own reason for running and must survive it.
+ //
+ // Remembered across the two queued round-trips this takes: the query has to
+ // come back before the thread can be expanded, and the expansion before the
+ // message row exists to select.
+ m_recoverThreadId = threadId;
+ m_recoverMessageId = messageId;
+}
+
+void MainWindow::applyPendingRecovery()
+{
+ if (m_recoverThreadId.isEmpty())
+ return;
+
+ for (int row = 0; row < m_model->rowCount(QModelIndex()); ++row) {
+ const QModelIndex thread = m_model->index(row, 0, QModelIndex());
+ if (m_model->threadAt(row).threadId != m_recoverThreadId)
+ continue;
+
+ // Expanded in every case, and FIRST. The user was reading a
+ // conversation, so bringing it back collapsed hides the thing they
+ // asked to get back to, whether their message was the root or a reply.
+ // Expanding is also what asks the worker for the replies, so it has to
+ // happen before any attempt to find one.
+ m_threadView->expand(thread);
+
+ // The thread's first message IS the root card rather than a child row:
+ // setThreadMessages drops depth 0 because the root stands for it, so
+ // looking for it among the children finds nothing and the selection
+ // would silently land nowhere.
+ //
+ // selectRowAt(), not setCurrentIndex(): a current index without a
+ // selection is what QTreeView sets by itself on focus, and
+ // onThreadSelected() deliberately ignores that, so pointing at the row
+ // renders nothing and leaves the pane blank.
+ if (m_recoverMessageId.isEmpty()
+ || m_model->data(thread, ThreadListModel::MessageIdRole).toString()
+ == m_recoverMessageId) {
+ selectRowAt(thread);
+ m_recoverThreadId.clear();
+ m_recoverMessageId.clear();
+ return;
+ }
+
+ // A reply cannot be selected until the replies exist. The expand above
+ // asked for them, and this runs again when they arrive.
+ //
+ // The thread is selected NOW rather than waiting, because a freshly
+ // queried row does not know its own first message either: the root's
+ // MessageIdRole is empty until the tree loads
+ // (`src/threadlistmodel.cpp`), so the root check above cannot match yet
+ // and returning here would leave the user looking at a collapsed thread
+ // and a blank pane until the replies happen to arrive. Selecting the
+ // thread renders its first message immediately, which is the right
+ // answer outright when that is what they were reading, and is refined
+ // to the correct reply on the next pass when it is not.
+ //
+ // The target is deliberately NOT cleared: this pass is provisional.
+ if (m_model->rowCount(thread) == 0) {
+ selectRowAt(thread);
+ return;
+ }
+
+ for (int child = 0; child < m_model->rowCount(thread); ++child) {
+ const QModelIndex reply = m_model->index(child, 0, thread);
+ if (m_model->messageAt(reply).messageId != m_recoverMessageId)
+ continue;
+ selectRowAt(reply);
+ m_recoverThreadId.clear();
+ m_recoverMessageId.clear();
+ return;
+ }
+
+ // The thread came back without the message: it was deleted, or moved
+ // between accounts. Land on the thread rather than leaving the user
+ // with nothing selected.
+ selectRowAt(thread);
+ m_recoverThreadId.clear();
+ m_recoverMessageId.clear();
+ return;
+ }
+}
+
void MainWindow::onExternalSyncStateChanged(SyncMonitor::State state)
{
if (state == SyncMonitor::State::Running) {
@@ -2154,6 +2403,7 @@ void MainWindow::onExternalSyncStateChanged(SyncMonitor::State state)
m_externalSyncBusy = true;
updateSyncControls();
m_statusLabel->setText(tr("Background sync running..."));
+ m_announcedExternalSync = true;
return;
}
@@ -2177,19 +2427,40 @@ void MainWindow::onExternalSyncStateChanged(SyncMonitor::State state)
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.
+ // Refreshes, unconditionally, and says nothing about it.
+ //
+ // 0.8.0 refused to refresh here because 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. The status bar
+ // asked the user to press Enter instead. That made the list quietly stale:
+ // new mail indexed by cron never appeared, and an Unread view read to the
+ // end stayed empty in front of it.
+ //
+ // The answer is not to weigh the cost, it is to remove it.
+ // refreshCurrentQuery() reconciles the result into the model instead of
+ // resetting it, so a surviving thread keeps its row, its expansion and its
+ // selection, and the message being read stays on screen. Nothing has to be
+ // preserved by declining to run.
//
- // 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.
+ // No status message: a refresh that changes nothing must be invisible, and
+ // one that adds mail is announced by the mail appearing. Six "sync
+ // completed" messages an hour are noise reporting the expected.
+ //
+ // Unknown is not refreshed. It means the lock table could not be read, so
+ // no sync was observed, and refreshing on it would re-query on every failed
+ // poll rather than after a sync.
+ // Retire our own running message, and only that one. The refresh below says
+ // nothing, which is right for a sync that changed nothing, but "says
+ // nothing" must not mean "leaves 'Background sync running...' on screen
+ // after it stopped". Anything else in the bar belongs to the user (a
+ // selection count, a tag result) and is left alone.
+ if (m_announcedExternalSync) {
+ m_announcedExternalSync = false;
+ m_statusLabel->setText(m_defaultStatus);
+ }
+
if (state == SyncMonitor::State::Idle) {
- showTransientStatus(
- tr("Background sync completed. Press Enter in the query bar to "
- "refresh."));
+ refreshCurrentQuery();
// Item 54. A cron sync carries the edits to the mail store exactly as a
// local one does, so the count it cleared has to be cleared here too.
diff --git a/src/mainwindow.h b/src/mainwindow.h
index ccac5ad..a7ea5c3 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -155,6 +155,16 @@ public:
/// reopened, so a test standing in for the worker needs the current value.
quint64 statsGenerationForTesting() const { return m_statsGeneration; }
+ /// Whether a stale-thread recovery is still waiting for its result.
+ ///
+ /// A test seam. The recovery target is cleared as a matter of course by any
+ /// query the user runs, so "is it still set immediately after the button"
+ /// is the only way to see that it survived the slot that set it.
+ bool hasPendingRecoveryForTesting() const
+ {
+ return !m_recoverThreadId.isEmpty();
+ }
+
protected:
void closeEvent(QCloseEvent *event) override;
@@ -165,6 +175,23 @@ protected:
private slots:
void runCurrentQuery();
+
+ /// Brings back a thread that stopped matching, and restores the reader's
+ /// place inside it.
+ ///
+ /// Runs `thread:<id>` so the whole conversation is listed rather than the
+ /// single message, then expands it and selects `messageId` once the rows
+ /// exist. Both steps are queued round-trips to the worker, so the ids are
+ /// remembered in m_recoverThreadId / m_recoverMessageId and acted on as the
+ /// replies arrive.
+ ///
+ /// A slot because MessageView's notice connects to it, and because the
+ /// sequencing above is only testable by driving it through the same entry
+ /// point the button uses.
+ void recoverStaleThread(const QString &threadId, const QString &messageId);
+
+ /// Selects the remembered message once its thread's rows have loaded.
+ void applyPendingRecovery();
void onThreadsReady(const QVector<ThreadSummary> &threads, quint64 generation);
void onQueryFinished(int total, quint64 generation);
void onThreadSelected(const QModelIndex &current, const QModelIndex &previous);
@@ -389,6 +416,27 @@ private:
/// Sends every edit held while the lock was busy, oldest first.
void flushHeldEdits();
+ /// Re-runs the current query and reconciles the result into the model.
+ ///
+ /// The non-destructive counterpart to `runCurrentQuery()`, and what a sync
+ /// fires: nothing is cleared, so the selection, the expanded threads, the
+ /// undo stack and the message being read all survive. New threads appear
+ /// where the sort puts them and threads that stopped matching leave.
+ ///
+ /// Does nothing when no query has run yet, since there is nothing to
+ /// re-run.
+ void refreshCurrentQuery();
+
+ /// Shows or hides the message pane's "no longer matches" notice.
+ ///
+ /// Called after a refresh, which is the only thing that can remove a row
+ /// from under a reader. A thread read out of an Unread view is the ordinary
+ /// case: the pane keeps rendering it, correctly, while the list no longer
+ /// offers it anywhere, and without this the message quietly becomes an
+ /// orphan with no route back.
+ void updateStaleThreadNotice();
+
+
/// A tag change not yet sent to the worker, because a sync held the write
/// lock when the user made it.
///
@@ -513,6 +561,63 @@ private:
/// and reopened, so an old answer cannot fill in a newer dialog.
quint64 m_statsGeneration = 0;
+ /// The generation of a REFRESH query, run after a sync to bring the list
+ /// up to date without disturbing it.
+ ///
+ /// Numbered from the same counter as an ordinary query, so a refresh and a
+ /// user query can never share an id, but tracked separately because the two
+ /// consume their results differently: an ordinary query appends into a
+ /// cleared model as batches arrive, while a refresh accumulates every batch
+ /// and reconciles once at the end. Zero when no refresh is in flight.
+ ///
+ /// A user query started while a refresh is running silently supersedes it:
+ /// the refresh's batches are still collected but its result is dropped, for
+ /// the same reason the generation counter exists at all. Reconciling it
+ /// would fight the query the user just typed.
+ quint64 m_refreshGeneration = 0;
+
+ /// Threads collected from a refresh query, complete only once its
+ /// queryFinished arrives.
+ ///
+ /// Held rather than applied per batch because reconcile() needs the WHOLE
+ /// result to tell a thread that stopped matching from one that simply has
+ /// not arrived yet. Reconciling batch by batch would delete every row the
+ /// first batch did not contain, emptying the list and refilling it, which
+ /// is the reset this exists to avoid.
+ QVector<ThreadSummary> m_refreshThreads;
+
+ /// The thread and message a stale-thread recovery is waiting to select.
+ ///
+ /// Recovery spans two queued round-trips (the query, then the reply walk),
+ /// so the target cannot be a local variable. Cleared once the selection
+ /// lands, or by any query the user runs in the meantime: that is them
+ /// choosing to go somewhere else, and restoring a selection into a result
+ /// they did not ask for would yank the view.
+ QString m_recoverThreadId;
+ QString m_recoverMessageId;
+
+ /// The thread the pane's current MESSAGE belongs to.
+ ///
+ /// Selecting a message row clears m_currentThreadId (the pane shows one
+ /// message, not a conversation), so without this a reader three replies
+ /// deep has no thread to check against the refreshed list, and the stale
+ /// notice never appears for them. Not obtainable from the model after the
+ /// fact: ThreadListModel::threadIdForMessage() searches the rows, and by
+ /// the time this is needed the thread has left them.
+ QString m_currentMessageThreadId;
+
+ /// True while the status bar is showing this window's own "Background sync
+ /// running..." message.
+ ///
+ /// A refresh after a cron sync is deliberately silent, so it writes nothing
+ /// to the bar. That left the running message standing after the sync
+ /// finished, because the "completed" message it replaced was the only thing
+ /// that ever cleared it. Silence means saying nothing NEW, not leaving a
+ /// stale claim on screen: this marks the one string the Idle branch is
+ /// entitled to retire, so it cannot overwrite a selection count or anything
+ /// else the user is actually reading.
+ bool m_announcedExternalSync = false;
+
/// Holds the sync log and its close button, so the pane can be dismissed.
QWidget *m_syncLogPane = nullptr;
QPlainTextEdit *m_syncLog = nullptr;
diff --git a/src/messageview.cpp b/src/messageview.cpp
index b804d37..b6c3fa3 100644
--- a/src/messageview.cpp
+++ b/src/messageview.cpp
@@ -188,6 +188,47 @@ MessageView::MessageView(QWidget *parent)
blockedRow->addWidget(m_loadRemoteButton);
blockedRow->addStretch();
+ // The stale-thread notice, deliberately the same shape as the row above:
+ // a sentence and a button, above the message, leaving it readable. The
+ // user asked for this rather than for a dialog, and a dialog would be
+ // wrong anyway, since nothing here needs an answer before the message can
+ // be read.
+ m_staleBar = new QWidget(this);
+ m_staleBar->setObjectName(QStringLiteral("staleThreadBar"));
+ m_staleLabel = new QLabel(
+ tr("This thread no longer matches the current query."), m_staleBar);
+ m_staleButton = new QPushButton(tr("Show it anyway"), m_staleBar);
+ m_staleButton->setObjectName(QStringLiteral("staleThreadButton"));
+ connect(m_staleButton, &QPushButton::clicked, this, [this] {
+ if (m_staleThreadId.isEmpty())
+ return;
+
+ // COPIES, not the members themselves, and this is load-bearing rather
+ // than tidy. A direct connection passes these by reference all the way
+ // into MainWindow::recoverStaleThread(), which calls runCurrentQuery(),
+ // which blanks the pane, which calls setStaleThread() and assigns to
+ // the very members those references name. The ids then read as empty
+ // for the rest of the slot, so the recovery target was stored as an
+ // empty string and nothing was ever recovered: the thread came back
+ // collapsed with a blank pane, which is exactly the reported symptom.
+ //
+ // Invisible to a test that reaches the slot through invokeMethod,
+ // because that copies the arguments; it needs the real signal.
+ const QString threadId = m_staleThreadId;
+ const QString messageId = m_staleMessageId;
+
+ // The message on screen goes with the request. Recovering the thread
+ // alone would reopen it at its first message, and the user was reading
+ // message four of eight.
+ emit staleThreadRecoveryRequested(threadId, messageId);
+ });
+ auto *staleRow = new QHBoxLayout(m_staleBar);
+ staleRow->setContentsMargins(0, 0, 0, 0);
+ staleRow->addWidget(m_staleLabel);
+ staleRow->addWidget(m_staleButton);
+ staleRow->addStretch();
+ m_staleBar->hide();
+
m_attachmentBar = new QWidget(this);
m_attachmentBar->setObjectName(QStringLiteral("attachmentBar"));
new QHBoxLayout(m_attachmentBar);
@@ -200,6 +241,7 @@ MessageView::MessageView(QWidget *parent)
auto *layout = new QVBoxLayout(this);
layout->addLayout(headerRow);
layout->addLayout(blockedRow);
+ layout->addWidget(m_staleBar);
layout->addWidget(m_view, 1);
layout->addWidget(m_attachmentBar);
layout->addWidget(m_tagStrip);
@@ -279,6 +321,12 @@ void MessageView::clear()
m_blockedLabel->hide();
m_loadRemoteButton->hide();
+ // The stale notice describes the message that WAS rendered, so it goes with
+ // it, for the same reason as the blocked-content bar above. Left behind, it
+ // sits over a blank pane naming a thread that is no longer shown, and its
+ // button offers to recover a thread the user has navigated away from.
+ setStaleThread(QString(), QString());
+
// clear() does not go through render(), so the bar has to be emptied
// here or the previous thread's attachments stay offered.
rebuildAttachmentBar();
@@ -654,6 +702,14 @@ void MessageView::saveAttachment(const Attachment &attachment)
emit statusMessage(tr("Saved %1").arg(written));
}
+void MessageView::setStaleThread(const QString &threadId,
+ const QString &messageId)
+{
+ m_staleThreadId = threadId;
+ m_staleMessageId = messageId;
+ m_staleBar->setVisible(!threadId.isEmpty());
+}
+
void MessageView::toggleHtml()
{
const bool anyHtml = std::any_of(
diff --git a/src/messageview.h b/src/messageview.h
index 135c85d..63fd6d8 100644
--- a/src/messageview.h
+++ b/src/messageview.h
@@ -104,6 +104,28 @@ public:
qreal zoomFactor() const;
void setZoomFactor(qreal factor);
+ /// Shows or hides the notice saying the rendered thread no longer matches
+ /// the current query.
+ ///
+ /// Modelled on the remote-content bar rather than on a dialog: the message
+ /// stays readable underneath, and the way back is one click. Passing an
+ /// empty id hides it.
+ ///
+ /// The pane does not decide this for itself. It renders whatever it was
+ /// last given and has no idea what the thread list holds, so the window
+ /// tells it after a refresh.
+ /// `messageId` is the message on screen, empty when a whole thread is
+ /// rendered. It rides along so recovery can restore the reader's place
+ /// rather than reopening the thread at its first message.
+ void setStaleThread(const QString &threadId, const QString &messageId);
+
+ /// The thread the stale notice offers to bring back, empty when hidden.
+ QString staleThreadId() const { return m_staleThreadId; }
+
+ /// The message the stale notice would restore, empty when a whole thread
+ /// is rendered or the notice is hidden.
+ QString staleMessageId() const { return m_staleMessageId; }
+
public slots:
void toggleHtml();
void loadRemoteContent();
@@ -125,6 +147,16 @@ signals:
/// app" is a boundary worth keeping shut rather than arguing about.
void queryRequested(const QString &query);
+ /// The user asked to see a thread that stopped matching the current query.
+ ///
+ /// Carries the thread id and the message that was on screen, because
+ /// recovering the thread alone would land the user on its first message
+ /// rather than the one they were reading. The window runs the query,
+ /// expands the thread and restores the selection; the view knows none of
+ /// that.
+ void staleThreadRecoveryRequested(const QString &threadId,
+ const QString &messageId);
+
protected:
/// Turns Ctrl+wheel over the body into zoom, and Ctrl+middle-click into a
/// reset. Both events are delivered to the web view's internal QQuickWidget
@@ -185,6 +217,13 @@ private:
QLabel *m_headerLabel = nullptr;
QLabel *m_blockedLabel = nullptr;
QPushButton *m_loadRemoteButton = nullptr;
+
+ /// The stale-thread notice and the thread it offers to restore.
+ QWidget *m_staleBar = nullptr;
+ QLabel *m_staleLabel = nullptr;
+ QPushButton *m_staleButton = nullptr;
+ QString m_staleThreadId;
+ QString m_staleMessageId;
QPushButton *m_detailsButton = nullptr;
QWidget *m_attachmentBar = nullptr;
TagStrip *m_tagStrip = nullptr;
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();
diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h
index f381ffa..01dd9d0 100644
--- a/src/threadlistmodel.h
+++ b/src/threadlistmodel.h
@@ -180,6 +180,20 @@ public:
void appendBatch(const QVector<ThreadSummary> &batch);
void clear();
+ /// Brings the model to `threads` without resetting it.
+ ///
+ /// Used by the automatic refresh after a sync, where clear() plus
+ /// appendBatch() is the wrong tool: a reset invalidates every index, so the
+ /// selection, the expanded threads and the message being read all go with
+ /// it. Rows are matched by thread id, so a surviving thread keeps its
+ /// identity, its persistent index and its loaded replies.
+ ///
+ /// Order comes from `threads` and is never imposed here. The worker sorts
+ /// the query, so a new thread lands at the front under newest-first and at
+ /// the back under oldest-first; forcing new rows to the top would
+ /// contradict the sort the user selected.
+ void reconcile(const QVector<ThreadSummary> &threads);
+
ThreadSummary threadAt(int row) const;
/// Fills in a thread's message rows once the worker has walked its tree.