summaryrefslogtreecommitdiffstats
path: root/src/notmuchworker.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-08 10:16:25 +0200
committerDanilo M. <danix@danix.xyz>2026-08-10 08:23:04 +0200
commitc49d1317f95e435e5b5af0d0352e6743a5d57025 (patch)
tree77ca9ad2ba5962193b8e650a1c57ebd3360b62fb /src/notmuchworker.cpp
parentbc9b22fda26ac47d5870099b281b63592996722d (diff)
downloadqtmaildir-c49d1317f95e435e5b5af0d0352e6743a5d57025.tar.gz
qtmaildir-c49d1317f95e435e5b5af0d0352e6743a5d57025.zip
feat(worker): load a thread as a reply tree with per-message depth
loadThread could not be extended to do this. It walks notmuch_query_search_messages, and a message obtained that way returns NULL from notmuch_message_get_replies (notmuch.h:1617-1628), so that walk cannot produce reply depth at all. The tree comes from notmuch_thread_get_toplevel_messages instead, and the pane keeps the flat list it wants. walkReplies takes raw notmuch_message_t*, against this file's rule that every handle is RAII-owned. Messages reached through a thread are freed with it (notmuch.h:1637), so an NmMessage wrapper would destroy memory the thread frees again. The NmThread in the caller is what keeps them alive. Every message in the thread gets a node regardless of the query: the list is where the reply count is read, and hiding unmatched replies would make that count disagree with the rows under it. Both tests mutation-checked. Flattening depth fails the depth assertion, and skipping the thread walk fails it too, so neither passes against the two mistakes the notmuch API invites.
Diffstat (limited to 'src/notmuchworker.cpp')
-rw-r--r--src/notmuchworker.cpp93
1 files changed, 93 insertions, 0 deletions
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
index 752a52c..47bbb62 100644
--- a/src/notmuchworker.cpp
+++ b/src/notmuchworker.cpp
@@ -69,6 +69,54 @@ bool collectMessageIds(notmuch_database_t *db, const QString &query,
return true;
}
+/// Walks a thread's reply structure depth-first, appending each message with
+/// its depth.
+///
+/// Takes RAW notmuch_message_t*, deliberately, against the rule that every
+/// handle in this file is RAII-owned. Messages reached through a thread belong
+/// to that thread and are freed with it (notmuch.h:1637), so wrapping one in
+/// NmMessage would call notmuch_message_destroy on memory the thread frees
+/// again. The NmThread in the caller is what keeps every pointer here alive,
+/// and this must not outlive it.
+///
+/// No match-set argument, unlike loadThread. A row is drawn for every message
+/// in the thread regardless of the query: the list is where the user goes to
+/// SEE the thread's shape, and hiding replies that did not match would make the
+/// reply count disagree with the rows beneath it.
+void walkReplies(notmuch_messages_t *messages, int depth,
+ QVector<MessageNode> *out)
+{
+ for (; notmuch_messages_valid(messages);
+ notmuch_messages_move_to_next(messages)) {
+
+ notmuch_message_t *message = notmuch_messages_get(messages);
+ if (!message)
+ continue;
+
+ MessageNode node;
+ node.messageId =
+ QString::fromUtf8(notmuch_message_get_message_id(message));
+ node.threadId =
+ QString::fromUtf8(notmuch_message_get_thread_id(message));
+ node.filePath =
+ QString::fromUtf8(notmuch_message_get_filename(message));
+ node.from =
+ QString::fromUtf8(notmuch_message_get_header(message, "from"));
+ node.subject =
+ QString::fromUtf8(notmuch_message_get_header(message, "subject"));
+ node.date =
+ QDateTime::fromSecsSinceEpoch(notmuch_message_get_date(message));
+ node.tags = tagsOf(message);
+ node.depth = depth;
+ out->append(node);
+
+ // NULL is a legitimate "no replies" here: notmuch_messages_valid
+ // accepts it and returns FALSE (notmuch.h:1630), so a leaf needs no
+ // guard of its own.
+ walkReplies(notmuch_message_get_replies(message), depth + 1, out);
+ }
+}
+
} // namespace
NotmuchWorker::NotmuchWorker(const QString &notmuchConfigPath, QObject *parent)
@@ -242,6 +290,51 @@ void NotmuchWorker::loadThread(const QString &threadId,
emit threadLoaded(result, generation);
}
+void NotmuchWorker::loadThreadTree(const QString &threadId,
+ const QString &matchQuery,
+ quint64 generation)
+{
+ // Accepted for signature symmetry with loadThread, and unused on purpose:
+ // see walkReplies on why every message in the thread gets a row.
+ Q_UNUSED(matchQuery);
+
+ if (!openReadOnly())
+ return;
+
+ const QString query = QStringLiteral("thread:%1").arg(threadId);
+ NmQuery nmQuery(notmuch_query_create(m_db, query.toUtf8().constData()));
+ if (!nmQuery) {
+ emit errorOccurred(
+ QStringLiteral("Cannot load thread %1").arg(threadId));
+ return;
+ }
+
+ // search_threads, not search_messages. The messages have to come from a
+ // notmuch_thread_t or notmuch_message_get_replies returns NULL for every
+ // one of them and the walk below produces a flat list at depth 0.
+ notmuch_threads_t *rawThreads = nullptr;
+ if (notmuch_query_search_threads(nmQuery.get(), &rawThreads)
+ != NOTMUCH_STATUS_SUCCESS) {
+ emit errorOccurred(
+ QStringLiteral("Cannot search thread %1").arg(threadId));
+ return;
+ }
+ NmThreads threads(rawThreads);
+
+ QVector<MessageNode> nodes;
+ if (notmuch_threads_valid(threads.get())) {
+ // Held for the whole walk: every message pointer inside belongs to this
+ // thread and dies with it.
+ NmThread thread(notmuch_threads_get(threads.get()));
+ if (thread) {
+ walkReplies(notmuch_thread_get_toplevel_messages(thread.get()), 0,
+ &nodes);
+ }
+ }
+
+ emit threadTreeLoaded(nodes, generation);
+}
+
void NotmuchWorker::applyTagsToThreads(const QStringList &threadIds,
const QStringList &add,
const QStringList &remove,