MailJDTourDownloadDocsQualityTestsCoverage

Which source lines and branches the tests execute. Lines 91.5 %, branches 75.0 % (minimum 85 % and 72 %). main @ 399ffe3d · 21 October 2026 · pipeline 7238.

GCC Code Coverage Report


Directory: src/
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 61.0% 178 / 0 / 292
Functions: 77.1% 27 / 0 / 35
Branches: 47.7% 163 / 0 / 342

ui/MailThreadModel.cpp
Line Branch Exec Source
1 #include "MailThreadModel.h"
2
3 #include <QColor>
4 #include <QDataStream>
5 #include <QFont>
6 #include <QIODevice>
7 #include <QRegularExpression>
8 #include <QPersistentModelIndex>
9 #include <QPointer>
10 #include <QThreadPool>
11
12 #include "service/ImapResponseParser.h"
13 #include "ui/MailListModel.h"
14 #include "ui/ThemeManager.h"
15
16 1053 MailThreadModel::MailThreadModel(QObject *parent)
17
1/1
✓ Branch 4 → 5 taken 1053 times.
1053 : QAbstractItemModel(parent), m_prepared(prepareRoots({})) {}
18
19 2068 MailThreadModel::~MailThreadModel() = default;
20
21 67 void MailThreadModel::setHeaders(const QList<MailHeader> &headers) {
22
2/2
✓ Branch 3 → 4 taken 67 times.
✓ Branch 4 → 5 taken 67 times.
67 setBuiltRoots(ThreadBuilder::buildThreads(headers));
23 67 }
24
25 67 void MailThreadModel::setBuiltRoots(std::vector<std::unique_ptr<ThreadNode>> roots) {
26
2/2
✓ Branch 4 → 5 taken 67 times.
✓ Branch 5 → 6 taken 67 times.
67 setPrepared(prepareRoots(std::move(roots)));
27 67 }
28
29 2663 void MailThreadModel::setPrepared(Prepared prepared) {
30
1/3
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 10 taken 2663 times.
✗ Branch 5 → 6 not taken.
2663 if (!prepared) prepared = prepareRoots({});
31
1/1
✓ Branch 10 → 11 taken 2663 times.
2663 beginResetModel();
32 2663 auto previous = std::exchange(m_prepared, std::move(prepared));
33 2663 m_suggestionCache.clear();
34
1/1
✓ Branch 14 → 15 taken 2663 times.
2663 endResetModel();
35 2663 }
36
37 2 void MailThreadModel::appendHeaders(const QList<MailHeader> &headers) {
38
1/1
✓ Branch 2 → 3 taken 2 times.
2 auto snapshot = snapshotHeaders();
39
1/1
✓ Branch 3 → 4 taken 2 times.
2 snapshot.append(headers);
40
1/1
✓ Branch 4 → 5 taken 2 times.
2 setHeaders(snapshot);
41 2 }
42
43
2/2
✓ Branch 3 → 4 taken 2591 times.
✓ Branch 4 → 5 taken 2591 times.
2591 void MailThreadModel::clear() { setPrepared(prepareRoots({})); }
44
45 20 const MailHeader *MailThreadModel::headerAt(const QModelIndex &index) const {
46 // T-79.B1/H7: nodes own their headers, so this pointer stays valid for
47 // the lifetime of the node (until the next tree rebuild) regardless of
48 // what happens to any flat header list elsewhere.
49 20 auto *node = nodeFromIndex(index);
50
1/2
✓ Branch 3 → 4 taken 20 times.
✗ Branch 3 → 5 not taken.
20 return node ? &node->header : nullptr;
51 }
52
53 3 qint64 MailThreadModel::uidAt(const QModelIndex &index) const {
54 3 auto *node = nodeFromIndex(index);
55
1/2
✓ Branch 3 → 4 taken 3 times.
✗ Branch 3 → 5 not taken.
3 return node ? node->header.uid : -1;
56 }
57
58 4116 QModelIndex MailThreadModel::indexForUid(qint64 uid, qint64 folderId) const {
59 4116 auto *node = m_prepared->uidIndex.value(MailKey{folderId, uid}, nullptr);
60
2/2
✓ Branch 5 → 6 taken 4111 times.
✓ Branch 5 → 7 taken 5 times.
4116 return node ? indexFromNode(node) : QModelIndex();
61 }
62
63 497 void MailThreadModel::updateFlags(qint64 uid, quint32 flags, qint64 folderId) {
64 497 auto *node = m_prepared->uidIndex.value(MailKey{folderId, uid}, nullptr);
65
2/2
✓ Branch 5 → 6 taken 487 times.
✓ Branch 5 → 7 taken 10 times.
497 if (!node) return;
66 10 const int unreadDelta = int(!(flags & MailFlag::Seen)) - int(!node->header.isSeen());
67 10 node->header.flags = flags;
68
1/1
✓ Branch 9 → 10 taken 10 times.
10 const QPersistentModelIndex changed(indexFromNode(node));
69 10 QList<QPersistentModelIndex> ancestors;
70
1/2
✓ Branch 10 → 11 taken 10 times.
✗ Branch 10 → 18 not taken.
10 if (unreadDelta) {
71
2/2
✓ Branch 17 → 12 taken 9 times.
✓ Branch 17 → 18 taken 10 times.
19 for (auto *ancestor = node->parent; ancestor; ancestor = ancestor->parent) {
72 9 ancestor->cachedUnreadDescendants += unreadDelta;
73
2/2
✓ Branch 13 → 14 taken 9 times.
✓ Branch 14 → 15 taken 9 times.
9 ancestors.append(QPersistentModelIndex(indexFromNode(ancestor)));
74 }
75 }
76
1/1
✓ Branch 18 → 19 taken 10 times.
10 QPointer<MailThreadModel> self(this);
77
4/4
✓ Branch 20 → 21 taken 10 times.
✓ Branch 21 → 22 taken 10 times.
✓ Branch 22 → 23 taken 10 times.
✓ Branch 23 → 24 taken 10 times.
10 emit dataChanged(changed, QModelIndex(changed).siblingAtColumn(ColumnCount - 1));
78 // Notify collapsed ancestors too. Persistent indexes protect against a reset
79 // triggered by a consumer of an earlier notification.
80
4/4
✓ Branch 25 → 26 taken 10 times.
✓ Branch 26 → 27 taken 10 times.
✓ Branch 43 → 28 taken 9 times.
✓ Branch 43 → 44 taken 10 times.
19 for (const auto &ancestor : ancestors) {
81
1/2
✗ Branch 30 → 31 not taken.
✓ Branch 30 → 32 taken 9 times.
9 if (!self) return;
82
3/3
✓ Branch 32 → 33 taken 9 times.
✓ Branch 33 → 34 taken 8 times.
✓ Branch 33 → 41 taken 1 time.
9 if (ancestor.isValid())
83
5/5
✓ Branch 34 → 35 taken 8 times.
✓ Branch 35 → 36 taken 8 times.
✓ Branch 36 → 37 taken 8 times.
✓ Branch 37 → 38 taken 8 times.
✓ Branch 38 → 39 taken 8 times.
8 emit dataChanged(ancestor, QModelIndex(ancestor).siblingAtColumn(ColumnCount - 1),
84 {Qt::DisplayRole, Qt::FontRole, ThreadCountRole});
85 }
86 10 }
87
88 55 void MailThreadModel::updateLabels(qint64 uid, const QStringList &labels, qint64 folderId) {
89 55 auto *node = m_prepared->uidIndex.value(MailKey{folderId, uid}, nullptr);
90
2/2
✓ Branch 5 → 6 taken 54 times.
✓ Branch 5 → 7 taken 1 time.
55 if (!node) return;
91 1 node->header.labels = labels;
92
1/1
✓ Branch 11 → 12 taken 1 time.
1 emit dataChanged(indexFromNode(node), indexFromNode(node, ColumnCount - 1));
93 }
94
95 149 void MailThreadModel::removeByUid(qint64 uid, qint64 folderId) {
96 149 const auto *removed = m_prepared->uidIndex.value(MailKey{folderId, uid}, nullptr);
97
2/2
✓ Branch 5 → 6 taken 144 times.
✓ Branch 5 → 7 taken 5 times.
149 if (!removed) return;
98
1/1
✓ Branch 7 → 8 taken 5 times.
5 const auto snapshot = snapshotHeaders(removed);
99
1/1
✓ Branch 8 → 9 taken 5 times.
5 setHeaders(snapshot);
100 5 }
101
102 7 QList<MailHeader> MailThreadModel::snapshotHeaders(const ThreadNode *excluded) const {
103 // Structural rebuilds still need a complete input. Keep that temporary input
104 // only for the rebuild instead of retaining another complete header list.
105 7 QList<MailHeader> snapshot;
106
1/1
✓ Branch 4 → 5 taken 7 times.
7 snapshot.reserve(m_prepared->uidIndex.size());
107 ✗ std::function<void(const ThreadNode *)> visit = [&](const ThreadNode *node) {
108
2/2
✓ Branch 2 → 3 taken 12 times.
✓ Branch 2 → 4 taken 5 times.
17 if (node != excluded) snapshot.append(node->header);
109
3/3
✓ Branch 8 → 9 taken 1 time.
✓ Branch 11 → 6 taken 1 time.
✓ Branch 11 → 12 taken 17 times.
18 for (const auto &child : node->children) visit(child.get());
110
1/1
✓ Branch 5 → 6 taken 7 times.
24 };
111
3/3
✓ Branch 11 → 12 taken 16 times.
✓ Branch 14 → 9 taken 16 times.
✓ Branch 14 → 15 taken 7 times.
23 for (const auto &root : m_prepared->roots) visit(root.get());
112 7 return snapshot;
113 7 }
114
115 // --- QAbstractItemModel interface ---
116
117 43 QModelIndex MailThreadModel::index(int row, int column,
118 const QModelIndex &parent) const {
119
3/6
✓ Branch 2 → 3 taken 43 times.
✗ Branch 2 → 5 not taken.
✓ Branch 3 → 4 taken 43 times.
✗ Branch 3 → 5 not taken.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 43 times.
43 if (row < 0 || column < 0 || column >= ColumnCount)
120 ✗ return {};
121
122
2/2
✓ Branch 7 → 8 taken 28 times.
✓ Branch 7 → 16 taken 15 times.
43 if (!parent.isValid()) {
123 // Root level
124
1/2
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 28 times.
28 if (row >= static_cast<int>(m_prepared->roots.size()))
125 ✗ return {};
126 28 return createIndex(row, column, m_prepared->roots[row].get());
127 }
128
129 // Child level
130 15 auto *parentNode = nodeFromIndex(parent);
131
3/6
✓ Branch 17 → 18 taken 15 times.
✗ Branch 17 → 20 not taken.
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 21 taken 15 times.
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 24 taken 15 times.
15 if (!parentNode || row >= static_cast<int>(parentNode->children.size()))
132 ✗ return {};
133 15 return createIndex(row, column, parentNode->children[row].get());
134 }
135
136 27 QModelIndex MailThreadModel::parent(const QModelIndex &child) const {
137
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 27 times.
27 if (!child.isValid())
138 ✗ return {};
139
140 27 auto *node = nodeFromIndex(child);
141
3/4
✓ Branch 6 → 7 taken 27 times.
✗ Branch 6 → 8 not taken.
✓ Branch 7 → 8 taken 14 times.
✓ Branch 7 → 9 taken 13 times.
27 if (!node || !node->parent)
142 14 return {};
143
144 13 return indexFromNode(node->parent);
145 }
146
147 18 int MailThreadModel::rowCount(const QModelIndex &parent) const {
148
2/2
✓ Branch 3 → 4 taken 14 times.
✓ Branch 3 → 7 taken 4 times.
18 if (!parent.isValid())
149 14 return static_cast<int>(m_prepared->roots.size());
150
151 4 auto *node = nodeFromIndex(parent);
152 // Only column 0 items have children
153
1/2
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 4 times.
4 if (parent.column() != 0)
154 ✗ return 0;
155
1/2
✓ Branch 11 → 12 taken 4 times.
✗ Branch 11 → 14 not taken.
4 return node ? static_cast<int>(node->children.size()) : 0;
156 }
157
158 3 int MailThreadModel::columnCount(const QModelIndex &parent) const {
159 Q_UNUSED(parent)
160 3 return ColumnCount;
161 }
162
163 4118 QVariant MailThreadModel::data(const QModelIndex &index, int role) const {
164 4118 auto *node = nodeFromIndex(index);
165
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 4118 times.
4118 if (!node)
166 ✗ return {};
167
168 4118 const auto &h = node->header;
169 4118 int col = index.column();
170
171
2/2
✓ Branch 6 → 7 taken 8 times.
✓ Branch 6 → 143 taken 4110 times.
4118 if (role == Qt::DisplayRole) {
172
2/8
✓ Branch 7 → 8 taken 1 time.
✗ Branch 7 → 23 not taken.
✓ Branch 7 → 24 taken 7 times.
✗ Branch 7 → 95 not taken.
✗ Branch 7 → 96 not taken.
✗ Branch 7 → 100 not taken.
✗ Branch 7 → 132 not taken.
✗ Branch 7 → 143 not taken.
8 switch (col) {
173 1 case Star:
174
3/6
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 13 taken 1 time.
✓ Branch 18 → 19 taken 1 time.
✗ Branch 18 → 20 not taken.
✗ Branch 20 → 21 not taken.
✓ Branch 20 → 22 taken 1 time.
2 return h.isFlagged() ? QStringLiteral("★") : QStringLiteral("☆");
175 ✗ case Attachment:
176 ✗ return {}; // painted by dedicated delegate
177 7 case Subject: {
178
1/3
✗ Branch 25 → 26 not taken.
✓ Branch 25 → 27 taken 7 times.
✗ Branch 26 → 28 not taken.
7 QString subject = h.subject.isEmpty() ? tr("(No subject)") : h.subject;
179 // T-547: Show unseen count for root nodes with children
180
5/6
✓ Branch 28 → 29 taken 4 times.
✓ Branch 28 → 32 taken 3 times.
✓ Branch 30 → 31 taken 4 times.
✗ Branch 30 → 32 not taken.
✓ Branch 33 → 34 taken 4 times.
✓ Branch 33 → 58 taken 3 times.
7 if (node->depth == 0 && !node->children.empty()) {
181 4 int total = node->cachedDescendants;
182 4 int unseen = node->cachedUnreadDescendants;
183
2/2
✓ Branch 34 → 35 taken 1 time.
✓ Branch 34 → 48 taken 3 times.
4 if (unseen > 0)
184
3/3
✓ Branch 39 → 40 taken 1 time.
✓ Branch 41 → 42 taken 1 time.
✓ Branch 42 → 43 taken 1 time.
3 subject += QStringLiteral(" (%1/%2)").arg(unseen).arg(total);
185 else
186
2/2
✓ Branch 52 → 53 taken 3 times.
✓ Branch 53 → 54 taken 3 times.
6 subject += QStringLiteral(" (%1)").arg(total);
187 }
188 // T-128/T-136: Strip Re:/Fwd: for child messages
189
2/2
✓ Branch 58 → 59 taken 3 times.
✓ Branch 58 → 77 taken 4 times.
7 if (node->depth > 0) {
190 static const QRegularExpression rePrefix(
191 4 QStringLiteral("^(Re|Fwd|Aw|Wg):\\s*"),
192
4/7
✓ Branch 59 → 60 taken 2 times.
✓ Branch 59 → 72 taken 1 time.
✓ Branch 61 → 62 taken 2 times.
✗ Branch 61 → 72 not taken.
✓ Branch 66 → 67 taken 2 times.
✗ Branch 274 → 275 not taken.
✗ Branch 274 → 276 not taken.
7 QRegularExpression::CaseInsensitiveOption);
193
1/1
✓ Branch 73 → 74 taken 3 times.
3 subject = subject.replace(rePrefix, QString());
194 }
195 // Show ↩ prefix for answered mails
196
3/3
✓ Branch 78 → 79 taken 1 time.
✓ Branch 78 → 86 taken 6 times.
✓ Branch 82 → 83 taken 1 time.
8 if (h.isAnswered()) subject.prepend(QStringLiteral("↩ "));
197 // Bug 2: Visual indentation for threaded replies
198
2/2
✓ Branch 86 → 87 taken 3 times.
✓ Branch 86 → 92 taken 4 times.
7 if (node->depth > 0)
199
2/2
✓ Branch 88 → 89 taken 3 times.
✓ Branch 89 → 90 taken 3 times.
3 subject.prepend(QString(node->depth * 4, QChar(' ')));
200 7 return subject;
201 7 }
202 ✗ case From:
203 ✗ return h.from;
204 ✗ case Date:
205 // 67.B4: same humanized format as the flat list
206 ✗ return MailListModel::formatDate(h.date);
207 ✗ case Size:
208 ✗ if (h.size < 1024)
209 ✗ return QStringLiteral("%1 B").arg(h.size);
210 ✗ if (h.size < 1024 * 1024)
211 ✗ return QStringLiteral("%1 KB").arg(h.size / 1024.0, 0, 'f', 1);
212 ✗ return QStringLiteral("%1 MB").arg(h.size / (1024.0 * 1024.0), 0, 'f', 1);
213 ✗ case Suggestion: {
214 ✗ auto it = m_suggestionCache.constFind(MailKey{h.folderId, h.uid});
215 ✗ return it != m_suggestionCache.constEnd() ? it->text : QString();
216 }
217 }
218 }
219
220 // T-508/T-547: Bold font for unread messages.
221 // For root nodes with children, also check if any descendant is unread
222 // so collapsed threads show as bold when they contain unread mail.
223
2/2
✓ Branch 143 → 144 taken 5 times.
✓ Branch 143 → 161 taken 4105 times.
4110 if (role == Qt::FontRole) {
224 5 bool unread = !h.isSeen();
225
4/8
✓ Branch 145 → 146 taken 5 times.
✗ Branch 145 → 150 not taken.
✓ Branch 146 → 147 taken 5 times.
✗ Branch 146 → 150 not taken.
✓ Branch 148 → 149 taken 5 times.
✗ Branch 148 → 150 not taken.
✓ Branch 151 → 152 taken 5 times.
✗ Branch 151 → 153 not taken.
5 if (!unread && node->depth == 0 && !node->children.empty())
226 5 unread = node->cachedUnreadDescendants > 0;
227
2/2
✓ Branch 153 → 154 taken 2 times.
✓ Branch 153 → 160 taken 3 times.
5 if (unread) {
228 // 67.B4: medium weight (paired with the accent unread dot)
229
1/1
✓ Branch 154 → 155 taken 2 times.
2 QFont font;
230
1/1
✓ Branch 155 → 156 taken 2 times.
2 font.setWeight(QFont::DemiBold);
231
1/1
✓ Branch 156 → 157 taken 2 times.
2 return font;
232 2 }
233 3 return {}; // Use view's default font for read messages
234 }
235
236
1/4
✗ Branch 161 → 162 not taken.
✓ Branch 161 → 191 taken 4105 times.
✗ Branch 162 → 163 not taken.
✗ Branch 162 → 191 not taken.
4105 if (role == Qt::ForegroundRole && col == Star) {
237 ✗ auto &tm = ThemeManager::instance();
238 ✗ return h.isFlagged() ? QColor(tm.color(QStringLiteral("@star_active")))
239 ✗ : QColor(tm.color(QStringLiteral("@star_inactive")));
240 }
241
242 // T-232: Suggestion column color by confidence (shared palette)
243
1/4
✗ Branch 191 → 192 not taken.
✓ Branch 191 → 205 taken 4105 times.
✗ Branch 192 → 193 not taken.
✗ Branch 192 → 205 not taken.
4105 if (role == Qt::ForegroundRole && col == Suggestion) {
244 ✗ auto it = m_suggestionCache.constFind(MailKey{h.folderId, h.uid});
245 ✗ return ThemeManager::confidenceColor(
246 ✗ it != m_suggestionCache.constEnd() ? it->confidence : 0.0);
247 }
248
249 // Text alignment for Star column
250
1/4
✗ Branch 205 → 206 not taken.
✓ Branch 205 → 208 taken 4105 times.
✗ Branch 206 → 207 not taken.
✗ Branch 206 → 208 not taken.
4105 if (role == Qt::TextAlignmentRole && col == Star) {
251 ✗ return static_cast<int>(Qt::AlignCenter);
252 }
253
254
1/2
✗ Branch 208 → 209 not taken.
✓ Branch 208 → 220 taken 4105 times.
4105 if (role == SortRole) {
255 ✗ switch (col) {
256 ✗ case Star:
257 ✗ return h.isFlagged() ? 1 : 0;
258 ✗ case Subject:
259 ✗ return h.subject;
260 ✗ case From:
261 ✗ return h.from;
262 ✗ case Date:
263 ✗ return h.date;
264 ✗ case Size:
265 ✗ return h.size;
266 ✗ case Suggestion:
267 ✗ return 0.0;
268 }
269 }
270
271
2/2
✓ Branch 220 → 221 taken 4 times.
✓ Branch 220 → 222 taken 4101 times.
4105 if (role == FlagsRole)
272 4 return h.flags;
273
1/2
✗ Branch 222 → 223 not taken.
✓ Branch 222 → 224 taken 4101 times.
4101 if (role == HasAttachmentsRole)
274 ✗ return h.hasAttachments;
275
1/2
✗ Branch 224 → 225 not taken.
✓ Branch 224 → 237 taken 4101 times.
4101 if (role == LabelsRole) {
276 ✗ QStringList filtered;
277 ✗ for (const auto &label : h.labels) {
278 ✗ if (!ImapResponseParser::isInternalKeyword(label))
279 ✗ filtered.append(label);
280 }
281 ✗ return filtered;
282 ✗ }
283
2/2
✓ Branch 237 → 238 taken 3 times.
✓ Branch 237 → 239 taken 4098 times.
4101 if (role == ThreadCountRole)
284 3 return node->cachedDescendants;
285
1/2
✓ Branch 239 → 240 taken 4098 times.
✗ Branch 239 → 241 not taken.
4098 if (role == DepthRole) // T-128
286 4098 return node->depth;
287
288 ✗ return {};
289 }
290
291 ✗ QVariant MailThreadModel::headerData(int section, Qt::Orientation orientation,
292 int role) const {
293 ✗ if (orientation != Qt::Horizontal || role != Qt::DisplayRole)
294 ✗ return {};
295
296 ✗ switch (section) {
297 ✗ case Star:
298 ✗ return QStringLiteral("★");
299 ✗ case Attachment:
300 ✗ return QStringLiteral("");
301 ✗ case Subject:
302 ✗ return tr("Subject");
303 ✗ case Suggestion:
304 ✗ return QStringLiteral("→");
305 ✗ case From:
306 ✗ return tr("From");
307 ✗ case Date:
308 ✗ return tr("Date");
309 ✗ case Size:
310 ✗ return tr("Size");
311 }
312 ✗ return {};
313 }
314
315 // Sprint 76 (T-76.B3): refresh translated headers on a live language switch.
316 ✗ void MailThreadModel::retranslateUi() {
317 ✗ if (columnCount() > 0)
318 ✗ emit headerDataChanged(Qt::Horizontal, 0, columnCount() - 1);
319 ✗ }
320
321 // ═══════════════════════════════════════════════════════
322 // Drag & Drop (T-102)
323 // ═══════════════════════════════════════════════════════
324
325 ✗ Qt::ItemFlags MailThreadModel::flags(const QModelIndex &index) const {
326 ✗ auto defaultFlags = QAbstractItemModel::flags(index);
327 ✗ if (index.isValid())
328 ✗ return defaultFlags | Qt::ItemIsDragEnabled;
329 ✗ return defaultFlags;
330 }
331
332 ✗ QStringList MailThreadModel::mimeTypes() const {
333 ✗ return {QStringLiteral("application/x-mailjd-uids")};
334 ✗ }
335
336 ✗ QMimeData *MailThreadModel::mimeData(const QModelIndexList &indexes) const {
337 ✗ QSet<qint64> uidSet;
338 ✗ for (const auto &idx : indexes) {
339 ✗ auto *node = nodeFromIndex(idx);
340 ✗ if (node)
341 ✗ uidSet.insert(node->header.uid);
342 }
343
344 ✗ QByteArray encoded;
345 ✗ QDataStream stream(&encoded, QIODevice::WriteOnly);
346 ✗ for (qint64 uid : uidSet)
347 ✗ stream << uid;
348
349 ✗ auto *mimeData = new QMimeData;
350 ✗ mimeData->setData(QStringLiteral("application/x-mailjd-uids"), encoded);
351 ✗ mimeData->setText(QString("%1 Mail(s)").arg(uidSet.size()));
352 ✗ return mimeData;
353 ✗ }
354
355 ✗ Qt::DropActions MailThreadModel::supportedDragActions() const {
356 ✗ return Qt::MoveAction;
357 }
358
359 // --- Private helpers ---
360
361 4187 ThreadNode *MailThreadModel::nodeFromIndex(const QModelIndex &index) const {
362
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 4187 times.
4187 if (!index.isValid())
363 ✗ return nullptr;
364 4187 return static_cast<ThreadNode *>(index.internalPointer());
365 }
366
367 4145 QModelIndex MailThreadModel::indexFromNode(ThreadNode *node, int column) const {
368
2/4
✓ Branch 2 → 3 taken 4145 times.
✗ Branch 2 → 4 not taken.
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 4145 times.
4145 if (!node || node->rowInParent < 0) return {};
369 4145 return createIndex(node->rowInParent, column, node);
370 }
371
372 3712 MailThreadModel::Prepared MailThreadModel::prepareRoots(std::vector<std::unique_ptr<ThreadNode>> roots) {
373
1/1
✓ Branch 2 → 3 taken 3712 times.
3712 Prepared result(new PreparedThreads, [](PreparedThreads *value) {
374 // Releasing a large folder is also linear work. Empty states are cheap;
375 // retire populated snapshots away from the GUI thread.
376
3/4
✓ Branch 3 → 4 taken 3645 times.
✓ Branch 3 → 7 taken 67 times.
✓ Branch 4 → 5 taken 3645 times.
✗ Branch 4 → 10 not taken.
3712 if (value->roots.empty()) delete value;
377
2/3
✓ Branch 2 → 3 taken 67 times.
✗ Branch 2 → 5 not taken.
✓ Branch 8 → 9 taken 67 times.
134 else QThreadPool::globalInstance()->start([value] { delete value; });
378
2/5
✓ Branch 4 → 5 taken 3712 times.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 8 taken 3712 times.
✗ Branch 24 → 25 not taken.
✗ Branch 24 → 27 not taken.
7424 });
379 3712 result->roots = std::move(roots);
380 38852 const auto chargeString = [](const QString &text) -> qint64 {
381 38852 return 32 + qint64(text.capacity() + 1) * 2;
382 };
383 ✗ std::function<void(ThreadNode *, int)> walk = [&](ThreadNode *node, int row) {
384 6499 node->rowInParent = row;
385 6499 node->cachedDescendants = 0;
386 6499 node->cachedUnreadDescendants = 0;
387 6499 const MailKey key{node->header.folderId, node->header.uid};
388
1/1
✓ Branch 4 → 5 taken 6499 times.
6499 result->uidIndex.insert(key, node);
389
1/1
✓ Branch 8 → 9 taken 6499 times.
6499 result->rowByKey.insert(key, result->rows.size());
390
1/1
✓ Branch 10 → 11 taken 6499 times.
6499 result->rows.append({node, node->depth});
391 // Conservative charge includes both hash tables, vector storage, allocation
392 // overhead and each header string even when Qt shares its backing buffer.
393 6499 result->retainedBytes += sizeof(ThreadNode) + 256 + node->children.capacity() * sizeof(void *);
394 6499 const auto &h = node->header;
395
2/2
✓ Branch 23 → 20 taken 32495 times.
✓ Branch 23 → 24 taken 6499 times.
71489 for (const auto &text : {h.subject, h.from, h.to, h.messageId, h.inReplyTo})
396
2/2
✓ Branch 25 → 26 taken 32495 times.
✓ Branch 25 → 29 taken 6499 times.
71489 result->retainedBytes += chargeString(text);
397
2/2
✓ Branch 46 → 35 taken 19497 times.
✓ Branch 46 → 47 taken 6499 times.
45493 for (const auto &values : {h.labels, h.inReplyToIds, h.references}) {
398 19497 result->retainedBytes += values.capacity() * sizeof(QString);
399
2/2
✓ Branch 44 → 39 taken 6357 times.
✓ Branch 44 → 45 taken 19497 times.
25854 for (const auto &text : values) result->retainedBytes += chargeString(text);
400 25996 }
401
2/2
✓ Branch 60 → 54 taken 6209 times.
✓ Branch 60 → 61 taken 6499 times.
12708 for (size_t i = 0; i < node->children.size(); ++i) {
402 6209 auto *child = node->children[i].get();
403
1/1
✓ Branch 56 → 57 taken 6209 times.
6209 walk(child, int(i));
404 6209 node->cachedDescendants += 1 + child->cachedDescendants;
405 6209 node->cachedUnreadDescendants += int(!child->header.isSeen()) + child->cachedUnreadDescendants;
406 }
407
1/1
✓ Branch 11 → 12 taken 3712 times.
10211 };
408
3/3
✓ Branch 16 → 17 taken 290 times.
✓ Branch 20 → 13 taken 290 times.
✓ Branch 20 → 21 taken 3712 times.
4002 for (size_t i = 0; i < result->roots.size(); ++i) walk(result->roots[i].get(), int(i));
409 3712 return result;
410 3712 }
411
412 // T-232: Set suggestion for a specific UID
413 ✗ void MailThreadModel::setSuggestion(qint64 uid, qint64 folderId,
414 const QString &text,
415 double confidence) {
416 ✗ m_suggestionCache[MailKey{folderId, uid}] = {text, confidence};
417 ✗ auto *node = m_prepared->uidIndex.value(MailKey{folderId, uid}, nullptr);
418 ✗ if (node) {
419 ✗ auto idx = indexFromNode(node, Suggestion);
420 ✗ emit dataChanged(idx, idx, {Qt::DisplayRole, Qt::ForegroundRole});
421 }
422 ✗ }
423
424 // T-232: Clear all suggestion data
425 ✗ void MailThreadModel::clearSuggestions() {
426 ✗ if (m_suggestionCache.isEmpty())
427 ✗ return;
428 ✗ m_suggestionCache.clear();
429 // Use dataChanged for the suggestion column range instead of dangerous
430 // bare layoutChanged() (which causes crash without layoutAboutToBeChanged).
431 ✗ if (!m_prepared->roots.empty()) {
432 ✗ emit dataChanged(index(0, Suggestion),
433 ✗ index(rowCount() - 1, Suggestion),
434 {Qt::DisplayRole, Qt::ForegroundRole});
435 }
436 }
437