diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/mainwindow.cpp | 293 | ||||
| -rw-r--r-- | src/mainwindow.h | 105 | ||||
| -rw-r--r-- | src/messageview.cpp | 56 | ||||
| -rw-r--r-- | src/messageview.h | 39 | ||||
| -rw-r--r-- | src/threadlistmodel.cpp | 104 | ||||
| -rw-r--r-- | src/threadlistmodel.h | 14 |
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 ¤t, 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 ¤t, 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 ¤t, 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 ¤t, } 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 ¤t, 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. |
