summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
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.