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: 86.2% 1792 / 0 / 2080
Functions: 93.4% 170 / 0 / 182
Branches: 67.4% 2046 / 5 / 3041

controller/MailController.cpp
Line Branch Exec Source
1 #include "MailController.h"
2 #include <QDeadlineTimer>
3 #include "service/MailDisplayReadService.h"
4 #include "service/MailBodyWriteService.h"
5 #include "service/MailSelectionMutationService.h"
6
7 #include <QFileInfo>
8 #include <QFutureWatcher>
9 #include <QtConcurrentRun>
10 #include <QLocale>
11 #include <QLoggingCategory>
12 #include <QPointer>
13
14 #include <algorithm>
15 #include <limits>
16 #include <utility>
17
18 #include "data/MailCache.h"
19 #include "performance/BackgroundPerformanceTracker.h"
20 #include "performance/FolderPerformanceTracker.h"
21 #include "performance/ContentPerformanceTracker.h"
22 #include "performance/MailOpenPerformanceTracker.h"
23 #include "performance/MutationPerformanceTracker.h"
24 #include "util/AttachmentFileSecurity.h"
25 #include "data/Models.h"
26 #include "controller/UndoManager.h"
27 #include "service/ImapService.h"
28 #include "service/ConnectionHealthMonitor.h"
29 #include "service/MimeParser.h"
30 #include "service/NetworkLifecycleCoordinator.h"
31 #include "ui/MailListModel.h"
32 #include "ui/MailThreadModel.h"
33 #include "util/MessageId.h"
34 #include "util/Utf8ByteCount.h"
35
36
4/7
✓ Branch 2 → 3 taken 21 times.
✓ Branch 2 → 8 taken 2178 times.
✓ Branch 4 → 5 taken 21 times.
✗ Branch 4 → 8 not taken.
✓ Branch 5 → 6 taken 21 times.
✗ Branch 10 → 11 not taken.
✗ Branch 10 → 12 not taken.
2199 Q_LOGGING_CATEGORY(lcController, "mailjd.controller")
37
38 static constexpr int REVERSE_CHUNK_SIZE = 50;
39 // Chunks after the first: every chunk is one sequential round trip, so a
40 // 20,000-mail backfill in 50-UID chunks paid 400 of them. The first chunk
41 // stays small so the newest mail appears at once; headers are still
42 // streamed and stored in batches of ImapService::HEADER_BATCH_SIZE.
43 static constexpr int BACKFILL_CHUNK_SIZE = 250;
44
45 namespace {
46 // Newest-first UID chunks: REVERSE_CHUNK_SIZE, then BACKFILL_CHUNK_SIZE.
47 7 QList<QList<qint64>> reverseFetchChunks(const QList<qint64> &newestFirst) {
48 7 QList<QList<qint64>> chunks;
49
2/2
✓ Branch 12 → 3 taken 8 times.
✓ Branch 12 → 13 taken 7 times.
15 for (qsizetype at = 0; at < newestFirst.size();) {
50
2/2
✓ Branch 4 → 5 taken 7 times.
✓ Branch 4 → 6 taken 1 time.
8 const int size = chunks.isEmpty() ? REVERSE_CHUNK_SIZE : BACKFILL_CHUNK_SIZE;
51
2/2
✓ Branch 7 → 8 taken 8 times.
✓ Branch 8 → 9 taken 8 times.
8 chunks.append(newestFirst.mid(at, size));
52 8 at += size;
53 }
54 7 return chunks;
55 ✗ }
56 } // namespace
57
58
59 624 void MailController::presentMail(const MailHeader &header, const MailBody &body) {
60 // P90.7 trace: the render tail starts when the projected variant is
61 // handed to the view. The mode decides which tail the probe expects —
62 // full HTML continues with inline/sanitize/webengine marks from the
63 // Quick HTML reader, native text ends at the quick document commit.
64 624 const auto mailId = m_bodies.requests().selectedMailId();
65
1/1
✓ Branch 4 → 5 taken 624 times.
624 auto &tracker = MailJD::Performance::MailOpenPerformanceTracker::instance();
66
6/12
✓ Branch 6 → 7 taken 624 times.
✗ Branch 6 → 11 not taken.
✓ Branch 7 → 8 taken 624 times.
✓ Branch 8 → 9 taken 624 times.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 624 times.
✓ Branch 12 → 13 taken 624 times.
✗ Branch 12 → 14 not taken.
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 35 taken 624 times.
✗ Branch 42 → 43 not taken.
✗ Branch 42 → 44 not taken.
624 if (mailId.isValid() && tracker.operationFor(mailId).isValid()) {
67 ✗ int inlineImages = 0;
68 ✗ for (const auto &attachment : body.attachments)
69 ✗ if (!attachment.contentId.isEmpty()) ++inlineImages;
70 ✗ const bool htmlTail = !body.textHtml.isEmpty() &&
71 ✗ m_preferredDisplayContent == MailDisplaySnapshot::PreferredContent::Html;
72 // Trace accounting must not create two full encoded copies before the
73 // first content frame. The trace is the only consumer of these lengths.
74 ✗ constexpr qsizetype maxTraceTextBytes = std::numeric_limits<qsizetype>::max() / 4;
75 const qint64 textBytes =
76 ✗ MailJD::utf8ByteCountUpTo(body.textPlain, maxTraceTextBytes) +
77 ✗ MailJD::utf8ByteCountUpTo(body.textHtml, maxTraceTextBytes);
78 ✗ tracker.renderStarted(
79 ✗ mailId.folderId, mailId.uid, htmlTail,
80 textBytes,
81 ✗ body.attachments.count(), inlineImages);
82 }
83
1/2
✓ Branch 35 → 36 taken 624 times.
✗ Branch 35 → 39 not taken.
624 if (m_view)
84
1/1
✓ Branch 38 → 39 taken 624 times.
624 m_view->presentMail(mailId, m_bodies.requests().displayGeneration(), header, body);
85
1/1
✓ Branch 39 → 40 taken 624 times.
624 requestSpamFields(header, body);
86 624 }
87
88 624 void MailController::requestSpamFields(const MailHeader &header, const MailBody &body) {
89
7/8
✓ Branch 2 → 3 taken 620 times.
✓ Branch 2 → 7 taken 4 times.
✓ Branch 4 → 5 taken 620 times.
✗ Branch 4 → 7 not taken.
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 8 taken 619 times.
✓ Branch 9 → 10 taken 5 times.
✓ Branch 9 → 11 taken 619 times.
624 if (header.spamKnown || !body.rawSource.isEmpty() || !body.spamHeaders.isEmpty()) return;
90
2/2
✓ Branch 11 → 12 taken 46 times.
✓ Branch 11 → 13 taken 573 times.
619 if (header.folderId != m_folder.currentFolderId) return;
91
2/2
✓ Branch 13 → 14 taken 428 times.
✓ Branch 13 → 15 taken 145 times.
573 if (m_spamFieldFolder != header.folderId) {
92 428 m_spamFieldFolder = header.folderId;
93 428 m_spamFieldRequests.clear();
94 }
95
5/6
✓ Branch 16 → 17 taken 573 times.
✗ Branch 16 → 19 not taken.
✓ Branch 18 → 19 taken 42 times.
✓ Branch 18 → 20 taken 531 times.
✓ Branch 21 → 22 taken 42 times.
✓ Branch 21 → 23 taken 531 times.
573 if (m_spamFieldRequests.size() >= 256 || m_spamFieldRequests.contains(header.uid)) return;
96
1/1
✓ Branch 23 → 24 taken 531 times.
531 m_spamFieldRequests.insert(header.uid);
97 531 const quint64 generation = m_folder.folderGeneration;
98 531 const qint64 uid = header.uid;
99
5/5
✓ Branch 24 → 25 taken 531 times.
✓ Branch 28 → 29 taken 531 times.
✓ Branch 29 → 30 taken 531 times.
✓ Branch 33 → 26 taken 531 times.
✓ Branch 33 → 34 taken 531 times.
1062 qCInfo(lcController) << "Fetching the spam filter fields of one displayed mail";
100
2/2
✓ Branch 34 → 35 taken 531 times.
✓ Branch 35 → 36 taken 531 times.
531 m_imap->executeAfterIdle([this, generation, uid] {
101
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 531 times.
531 if (generation != m_folder.folderGeneration) return;
102
5/6
✓ Branch 4 → 5 taken 531 times.
✓ Branch 5 → 6 taken 531 times.
✓ Branch 7 → 8 taken 531 times.
✗ Branch 7 → 12 not taken.
✓ Branch 8 → 9 taken 531 times.
✓ Branch 9 → 10 taken 531 times.
531 if (beginHeaderStream({uid})) m_imap->fetchHeadersByUids({uid});
103 });
104 }
105
106 110 void MailController::presentError(const MailHeader &header, const QString &reason,
107 bool retryable) {
108
1/2
✓ Branch 2 → 3 taken 110 times.
✗ Branch 2 → 10 not taken.
110 if (m_view)
109
1/1
✓ Branch 7 → 8 taken 110 times.
110 m_view->presentError(m_bodies.requests().selectedMailId(),
110 110 m_bodies.requests().displayGeneration(), header, reason, retryable);
111 110 }
112
113 // The mutation pipeline's callback into the controller (body connection)
114 // and the controller signals it emits (S91.4.2).
115 1050 MailMutationPipeline::Deps MailController::mutationDeps() {
116 11 return {[this] { ensureBodyConnection(); },
117 18 [this](const QString &text) { emit statusMessage(text); },
118 12 [this](const QString &text) { emit actionError(text); },
119 77 [this](qint64 token, const QString &message) { emit pendingActionChanged(token, message); },
120 2104 [this](qint64 token, const QString &folderPath, qint64 uid) {
121 4 emit appendMutationCompleted(token, folderPath, uid);
122 4 },
123 1 [this](qint64 token, const QString &error) { emit appendMutationFailed(token, error); },
124 4 [this](const QString &folder, int count) { emit unreadCountChanged(folder, count); },
125 1054 [this](const QString &folder, int count) { emit cachedUnreadCountChanged(folder, count); }};
126 1050 }
127
128 // The move commands' callbacks into the controller (folder reopen/resync,
129 // folder ids, loaded rows, body connection) and the controller signals they
130 // emit (S91.4.4).
131 1050 MailMoveCommands::Deps MailController::moveDeps() {
132 2 return {[this] { ensureBodyConnection(); },
133 143 [this](qint64 folderId, qint64 uid) { return loadedHeader(folderId, uid); },
134 3 [this](const QString &folderPath) { onFolderSelected(folderPath); },
135 2 [this](const QString &folderPath) { return resolveFolderId(folderPath); },
136 2 [this] { resyncCurrentFolder(); },
137 94 [this](const QString &text) { emit statusMessage(text); },
138 4 [this](const QString &text) { emit actionError(text); },
139 55 [this](qint64 folderId, const QList<qint64> &uids) { emit movesVisuallyPending(folderId, uids); },
140 52 [this](qint64 folderId, const QList<qint64> &uids) { emit movesVisuallySettled(folderId, uids); },
141 1054 [this](const QString &folder, int count) { emit unreadCountChanged(folder, count); }};
142 1050 }
143
144 // The selection state commands' label projection and controller signals.
145 1050 MailSelectionStateCommands::Deps MailController::selectionStateDeps() {
146 2103 return {[this](qint64 uid, qint64 folderId, const QString &label, bool present) {
147 3 applyVisibleLabel(uid, folderId, label, present);
148 3 },
149 18 [this](const QString &text) { emit statusMessage(text); },
150 1 [this](const QString &text) { emit actionError(text); },
151 12 [this](qint64 folderId, qint64 uid, bool seen) { emit seenVisualChanged(folderId, uid, seen); },
152 267 [this](qint64 folderId, qint64 uid, bool starred) { emit starVisualChanged(folderId, uid, starred); },
153 1056 [this](const QString &folder, int count) { emit unreadCountChanged(folder, count); }};
154 1050 }
155
156 // The body transport's callbacks into the controller (display, seen flag,
157 // connection) and the controller signals it emits (S91.4.1).
158 1050 MailBodyTransport::Deps MailController::bodyTransportDeps() {
159 1 return {[this](const MailId &mailId) { displayMailId(mailId); },
160 13 [this] { ensureBodyConnection(); },
161 2107 [this](qint64 uid, qint64 folderId, std::optional<bool> visibleSeen) {
162 7 markMailAsSeenInFolder(uid, folderId, visibleSeen);
163 7 },
164 2100 [this](const MailHeader &header, const QString &reason, bool retryable) {
165 ✗ presentError(header, reason, retryable);
166 ✗ },
167 ✗ [this](const MailHeader &header, const MailBody &body) { presentMail(header, body); },
168 7 [this](const QString &text) { emit statusMessage(text); },
169 10 [this](qint64 uid, qint64 folderId) { emit bodyLoaded(uid, folderId); },
170 1050 [this](const MailId &mailId, BodyOutcome outcome, const QString &error) {
171 25 emit bodyRequestFinished(mailId, outcome, error);
172 2125 }};
173 1050 }
174
175 1050 MailController::MailController(ImapService *imap, MailCache *cache,
176 MailListModel *model, MailDisplayPort *view,
177 1050 QObject *parent)
178 1050 : QObject(parent), m_imap(imap), m_cache(cache), m_model(model),
179 1050 m_serverSearch(m_searchImap, m_subscribedFolders,
180 16 {[this] { ensureSearchConnection(); },
181 12 [this](const QString &path) { return resolveFolderId(path); },
182 4 [this](qint64 folderId, const QList<qint64> &uids) { return uncachedUids(folderId, uids); },
183 ✗ [this](qint64 folderId, quint32 uidValidity, const QList<MailHeader> &headers) {
184 2 m_cache->storeEpochHeaders(folderId, uidValidity, headers);
185 2 }}),
186 2100 m_mutations(imap, m_bodyImap, cache, model, m_threadModel, m_folder, m_imapConfig,
187
1/1
✓ Branch 17 → 18 taken 1050 times.
1050 m_unavailableFolders, mutationDeps()),
188
1/1
✓ Branch 32 → 33 taken 1050 times.
1050 m_flagSync(imap, m_searchImap, cache, model, m_threadModel, m_folder, m_mutations.intents(),
189 1050 m_subscribedFolders, m_folderPresentationEnabled, m_folderSwitchStopwatch,
190 4 {[this] { ensureSearchConnection(); },
191
1/1
✓ Branch 3 → 4 taken 1 time.
1 [this] { return beginHeaderStream(); },
192 16 [this] { return m_pendingHeaderWrites; },
193 ✗ [this](const QString &error) { failHeaderWrite(error); },
194 2 [this](const QList<QPair<qint64, quint32>> &flags) { m_deferredHeaderFlags = flags; },
195 ✗ [this] { resyncCurrentFolder(); },
196
1/1
✓ Branch 4 → 5 taken 16 times.
16 [this](QList<qint64> missing, bool exact) { finishFlagSync(std::move(missing), exact); },
197 2 [this] { requestFolderCountStatus(); },
198 1 [this](const QString &text) { emit statusMessage(text); },
199 15 [this](const QString &folder, int count) { emit unreadCountChanged(folder, count); },
200 11 [this](const QString &folder, int count) { emit cachedUnreadCountChanged(folder, count); },
201 ✗ [this](qint64 folderId, int rows, quint64 generation) {
202 2 emit authoritativeFolderPresented(folderId, rows, generation);
203 2 }}),
204 2100 m_bodies(cache, model, m_view, m_folder, m_bodyImap, m_bodyImapSelectedFolder,
205 1050 m_loadingPlaceholderTimer, m_networkEnabled, m_fetchInProgress,
206
1/1
✓ Branch 37 → 38 taken 1050 times.
1050 m_unavailableFolders, bodyTransportDeps()),
207 2100 m_moves(imap, m_bodyImap, m_bodyImapSelectedFolder, m_bodyFetchSelect, cache, model,
208
1/1
✓ Branch 40 → 41 taken 1050 times.
1050 m_threadModel, m_folder, m_mutations, m_undoManager, m_selectionActionGeneration,
209 2100 moveDeps()),
210
1/1
✓ Branch 43 → 44 taken 1050 times.
1050 m_selectionStates(cache, model, m_threadModel, m_folder, m_mutations, m_undoManager,
211
7/7
✓ Branch 3 → 4 taken 1050 times.
✓ Branch 5 → 6 taken 1050 times.
✓ Branch 6 → 7 taken 1050 times.
✓ Branch 51 → 52 taken 1050 times.
✓ Branch 57 → 58 taken 1050 times.
✓ Branch 58 → 59 taken 1050 times.
✓ Branch 59 → 60 taken 1050 times.
4200 selectionStateDeps()) {
212
1/1
✓ Branch 67 → 68 taken 1050 times.
1050 m_prefetchProbeTimer.setSingleShot(true);
213
1/1
✓ Branch 68 → 69 taken 1050 times.
1050 connect(&m_prefetchProbeTimer, &QTimer::timeout, this, [this] {
214 31 const auto mailId = m_prefetchProbeMail;
215
1/2
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 31 times.
62 if (m_bodies.requests().selectedMailId() != mailId ||
216
1/2
✓ Branch 9 → 10 taken 31 times.
✗ Branch 9 → 13 not taken.
31 m_bodies.requests().displayGeneration() != m_prefetchProbeGeneration ||
217
3/6
✓ Branch 6 → 7 taken 31 times.
✗ Branch 6 → 13 not taken.
✓ Branch 11 → 12 taken 31 times.
✗ Branch 11 → 13 not taken.
✓ Branch 15 → 16 taken 31 times.
✗ Branch 15 → 17 not taken.
124 mailId.accountId != m_folder.accountId ||
218
1/2
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 31 times.
31 mailId.folderId != m_folder.currentFolderId) return;
219 // A stable cached read is the first safe idle point to establish the
220 // dedicated body connection. This sends no body request and leaves the
221 // displayed document alone; a later cache miss can skip Connect/Login.
222 // Account handover disables networking and disconnects old credentials.
223
1/4
✗ Branch 20 → 21 not taken.
✓ Branch 20 → 27 taken 31 times.
✗ Branch 22 → 23 not taken.
✗ Branch 22 → 27 not taken.
31 if (!m_bodyImap && m_networkEnabled && !m_bodies.prefetchSuspended() &&
224
2/8
✓ Branch 19 → 20 taken 31 times.
✗ Branch 19 → 27 not taken.
✗ Branch 23 → 24 not taken.
✗ Branch 23 → 27 not taken.
✗ Branch 25 → 26 not taken.
✗ Branch 25 → 27 not taken.
✗ Branch 28 → 29 not taken.
✓ Branch 28 → 30 taken 31 times.
62 !m_fetchInProgress && !m_imapConfig.host.isEmpty())
225 ✗ ensureBodyConnection();
226
1/1
✓ Branch 30 → 31 taken 31 times.
31 const int currentRow = m_model->rowForUid(mailId.uid, mailId.folderId);
227
2/3
✓ Branch 31 → 32 taken 31 times.
✗ Branch 31 → 33 not taken.
✓ Branch 32 → 34 taken 31 times.
31 if (currentRow >= 0) m_bodies.prefetchAdjacent(currentRow);
228 ✗ else m_bodies.prefetchIndexedAdjacent(mailId);
229
1/2
✓ Branch 36 → 37 taken 31 times.
✗ Branch 36 → 39 not taken.
31 });
230
1/1
✓ Branch 70 → 71 taken 1050 times.
1050 m_streamCountTimer.setSingleShot(true);
231
1/1
✓ Branch 71 → 72 taken 1050 times.
1050 connect(&m_streamCountTimer, &QTimer::timeout, this, &MailController::readStreamCounts);
232
1/1
✓ Branch 73 → 74 taken 1050 times.
1050 connect(this, &MailController::actionError, this, &MailController::statusMessage);
233
1/1
✓ Branch 75 → 76 taken 1050 times.
1050 m_mutations.connectCacheSignals();
234
2/2
✓ Branch 76 → 77 taken 1050 times.
✓ Branch 77 → 78 taken 1050 times.
1050 MailBodyWriteService::shared()->observeCache(m_cache);
235
1/1
✓ Branch 78 → 79 taken 1050 times.
1050 connect(m_model, &QAbstractItemModel::modelAboutToBeReset, this, [this] {
236 1880 MailDisplayReadService::shared()->cancel(m_model);
237 1880 });
238
1/1
✓ Branch 80 → 81 taken 1050 times.
1050 connect(m_model, &MailListModel::pageLoadFailed, this, [this](const QString &error) {
239
3/3
✓ Branch 2 → 3 taken 1 time.
✓ Branch 4 → 5 taken 1 time.
✓ Branch 5 → 6 taken 1 time.
2 emit statusMessage(tr("Cached messages could not be loaded: %1").arg(error));
240 1 });
241
242
1/1
✓ Branch 82 → 83 taken 1050 times.
1050 setDisplayPort(view);
243
244 // T-118: Debounce timer for IMAP SELECT — waits 50ms so rapid
245 // folder switches only trigger one SELECT for the final folder.
246
3/6
✓ Branch 83 → 84 taken 1050 times.
✓ Branch 84 → 85 taken 1050 times.
✗ Branch 85 → 86 not taken.
✓ Branch 85 → 87 taken 1050 times.
✗ Branch 212 → 213 not taken.
✗ Branch 212 → 214 not taken.
1050 m_folderSwitchTimer = new QTimer(this);
247
1/1
✓ Branch 87 → 88 taken 1050 times.
1050 m_folderSwitchTimer->setSingleShot(true);
248
1/1
✓ Branch 88 → 89 taken 1050 times.
1050 m_folderSwitchTimer->setInterval(50);
249 1050 connect(m_folderSwitchTimer, &QTimer::timeout, this,
250
1/1
✓ Branch 89 → 90 taken 1050 times.
1050 &MailController::executeDeferredFolderSwitch);
251 // IMAP → controller connections
252 1050 connect(m_imap, &ImapService::folderSelected, this,
253
1/1
✓ Branch 91 → 92 taken 1050 times.
1050 &MailController::onFolderSelectedFromImap);
254 1050 connect(m_imap, &ImapService::headersReceived, this,
255
1/1
✓ Branch 93 → 94 taken 1050 times.
1050 &MailController::onHeadersReceived);
256 1050 connect(m_imap, &ImapService::headerFetchComplete, this,
257
1/1
✓ Branch 95 → 96 taken 1050 times.
1050 &MailController::onHeaderFetchComplete);
258 1050 connect(m_imap, &ImapService::idleNewMessages, &m_flagSync,
259
1/1
✓ Branch 97 → 98 taken 1050 times.
1050 &FolderFlagSync::onIdleNewMessages);
260 1050 connect(m_imap, &ImapService::idleFlagsChanged, &m_flagSync,
261
1/1
✓ Branch 99 → 100 taken 1050 times.
1050 &FolderFlagSync::onIdleFlagsChanged);
262 1050 connect(m_imap, &ImapService::idleMessageExpunged, &m_flagSync,
263
1/1
✓ Branch 101 → 102 taken 1050 times.
1050 &FolderFlagSync::onIdleMessageExpunged);
264 1050 connect(m_imap, &ImapService::idleFlagsNeedRefetch, &m_flagSync,
265
1/1
✓ Branch 103 → 104 taken 1050 times.
1050 &FolderFlagSync::onIdleFlagsNeedRefetch);
266 1050 connect(m_imap, &ImapService::flagsReceived, &m_flagSync,
267
1/1
✓ Branch 105 → 106 taken 1050 times.
1050 &FolderFlagSync::onFlagsReceived);
268 1050 connect(m_imap, &ImapService::folderStatusReceived, &m_flagSync,
269
1/1
✓ Branch 107 → 108 taken 1050 times.
1050 &FolderFlagSync::onFolderStatusReceived);
270 1050 connect(m_imap, &ImapService::folderStatusFailed, &m_flagSync,
271
1/1
✓ Branch 109 → 110 taken 1050 times.
1050 &FolderFlagSync::onFolderStatusFailed);
272 1050 connect(m_imap, &ImapService::searchResultReceived, this,
273
1/1
✓ Branch 111 → 112 taken 1050 times.
1050 &MailController::onSearchResultReceived);
274 1050 connect(m_imap, &ImapService::changedFlagsReceived, &m_flagSync,
275
1/1
✓ Branch 113 → 114 taken 1050 times.
1050 &FolderFlagSync::onChangedFlagsReceived);
276 1050 connect(m_imap, &ImapService::uidMembershipReceived, &m_flagSync,
277
1/1
✓ Branch 115 → 116 taken 1050 times.
1050 &FolderFlagSync::onUidMembershipReceived);
278 1050 connect(m_imap, &ImapService::messageMoved, &m_moves,
279
1/1
✓ Branch 117 → 118 taken 1050 times.
1050 &MailMoveCommands::onMessageMoved);
280 1050 connect(m_imap, &ImapService::messagesMoved, &m_moves,
281
1/1
✓ Branch 119 → 120 taken 1050 times.
1050 &MailMoveCommands::onMessagesMoved);
282 1050 connect(m_imap, &ImapService::moveError, &m_moves,
283
1/1
✓ Branch 121 → 122 taken 1050 times.
1050 &MailMoveCommands::onMoveError);
284
1/1
✓ Branch 123 → 124 taken 1050 times.
1050 m_mutations.connectMainConnection(m_imap);
285
286 // S91.2.6: polling and expunge-debounce timers of the flag sync.
287
1/1
✓ Branch 124 → 125 taken 1050 times.
1050 m_flagSync.connectTimers();
288 3150 }
289
290 // Bug 38: Clear undo stack to prevent use-after-free — undo lambdas capture `this`
291 // T-720: Also deactivate + detach the body/search health monitors BEFORE
292 // Qt's parent-child cleanup destroys m_bodyImap / m_searchImap. Without
293 // this the monitor slots could react to the dying sockets' stateChanged.
294 2082 MailController::~MailController() {
295 1050 m_mutations.shutdown();
296 1050 resetHeaderWrites();
297 1050 m_flagSync.abortMembershipRead();
298 1050 MailDisplayReadService::shared()->cancel(this);
299 1050 m_bodies.waitForPreparations();
300 1050 m_flagSync.waitForMembershipReads();
301
2/2
✓ Branch 9 → 10 taken 19 times.
✓ Branch 9 → 12 taken 1031 times.
1050 if (m_bodyHealth) {
302 19 m_bodyHealth->setActive(false);
303 19 m_bodyHealth->detach();
304 }
305
2/2
✓ Branch 12 → 13 taken 20 times.
✓ Branch 12 → 15 taken 1030 times.
1050 if (m_searchHealth) {
306 20 m_searchHealth->setActive(false);
307 20 m_searchHealth->detach();
308 }
309
2/2
✓ Branch 16 → 17 taken 1031 times.
✓ Branch 16 → 19 taken 19 times.
1050 if (m_undoManager)
310 1031 m_undoManager->clear();
311 2082 }
312
313 1828 void MailController::setDisplayPort(MailDisplayPort *view) {
314
2/2
✓ Branch 2 → 3 taken 1032 times.
✓ Branch 2 → 4 taken 796 times.
1828 if (m_view == view)
315 1033 return;
316
1/1
✓ Branch 4 → 5 taken 796 times.
796 QObject::disconnect(m_displayDestroyedConnection);
317
1/1
✓ Branch 5 → 6 taken 796 times.
796 QObject::disconnect(m_displayRetryConnection);
318 796 m_view = view;
319
2/2
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 8 taken 795 times.
796 if (!m_view)
320 1 return;
321 m_displayDestroyedConnection =
322
1/1
✓ Branch 9 → 10 taken 795 times.
1590 connect(&m_view->displayObject(), &QObject::destroyed, this,
323
1/1
✓ Branch 8 → 9 taken 795 times.
1590 [this] { m_view = nullptr; });
324
1/1
✓ Branch 13 → 14 taken 795 times.
1590 m_displayRetryConnection = m_view->subscribeRetry(this, [this] {
325 5 const MailId selected = m_bodies.requests().selectedMailId();
326
1/2
✓ Branch 5 → 6 taken 5 times.
✗ Branch 5 → 7 not taken.
5 if (selected.isValid())
327
1/1
✓ Branch 6 → 7 taken 5 times.
5 displayMailId(selected);
328 800 });
329 // Rebinding starts a fresh display generation. The session and independent
330 // body consumers survive, but a queued delivery to the old view cannot win.
331 795 const MailId selected = m_bodies.requests().selectedMailId();
332
2/2
✓ Branch 20 → 21 taken 5 times.
✓ Branch 20 → 22 taken 790 times.
795 if (selected.isValid())
333
1/1
✓ Branch 21 → 22 taken 5 times.
5 displayMailId(selected);
334 795 }
335
336 1075 void MailController::setAccount(const QString &accountId) {
337 1075 ++m_selectionActionGeneration;
338 // Tokens belong to this session's database, not to the visible account.
339 // Keep queued APPEND bytes alive so switching back can still submit them.
340
2/2
✓ Branch 3 → 4 taken 1043 times.
✓ Branch 3 → 28 taken 32 times.
1075 if (m_folder.accountId != accountId) {
341 1043 m_mutations.cancelPendingAppendPayloadReads();
342 1043 m_serverSearch.abandon(); // S106-161: no old scan resumes on the new account's connection
343 // Any queued SELECT, header completion or final count from the previous
344 // account must lose its folder generation before the new account binds.
345 1043 ++m_folder.folderGeneration;
346 1043 m_folderSwitchTimer->stop();
347 1043 m_flagSync.stopPolling();
348 1043 m_folder.currentFolder.clear();
349 1043 m_folder.currentFolderId = -1;
350 1043 m_folder.activeFolderUidValidity = 0;
351 1043 m_pendingHeaderFetch = false;
352 1043 m_fetchInProgress = false;
353 1043 m_reverseChunks.clear();
354 1043 m_subscribedFolders.clear();
355 1043 m_flagSync.resetPollingIndex();
356 1043 m_mutations.intents().clearLastPolledUnread();
357 1043 m_inboxFirstSyncSignaled = false;
358 1043 m_inboxCacheWasEmpty = false;
359 1043 m_flagSync.cancelMembershipRead();
360 1043 m_seenIntentGenerations.clear();
361 1043 m_starIntentGenerations.clear();
362 1043 m_seenVisualDesired.clear();
363 1043 m_starVisualDesired.clear();
364 1043 emit pendingActionsReset();
365 1043 m_mutations.resetForAccountSwitch();
366 1043 MailDisplayReadService::shared()->cancel(m_model);
367 // Invalidate the loading timer as well as asynchronous body callbacks.
368 // Neither an old placeholder nor an already displayed document belongs
369 // to the newly active account. Other body consumers remain registered.
370 1043 clearDisplaySelection();
371 1043 m_unavailableFolders.clear();
372 1043 m_mutations.intents().clearReadbackFences();
373 1043 resetHeaderWrites();
374 }
375 1075 m_folder.accountId = accountId;
376
1/1
✓ Branch 29 → 30 taken 1075 times.
1075 QTimer::singleShot(0, &m_mutations, &MailMutationPipeline::replayMutationJournal);
377 1075 }
378
379 840 void MailController::setSubscribedFolders(const QStringList &folders) {
380 840 m_subscribedFolders = folders;
381 840 }
382
383 // ═══════════════════════════════════════════════════════
384 // Folder Selection Flow
385 // ═══════════════════════════════════════════════════════
386
387 12 void MailController::setRestoreAnchor(const QString &folderPath, qint64 uid) {
388 12 m_restoreAnchorFolder = folderPath;
389 12 m_restoreAnchorUid = uid;
390 12 }
391
392 36 void MailController::reconcileAvailableFolders(const QStringList &folderPaths) {
393
1/1
✓ Branch 4 → 5 taken 36 times.
36 const QSet<QString> available(folderPaths.begin(), folderPaths.end());
394
1/1
✓ Branch 5 → 6 taken 36 times.
36 m_unavailableFolders.subtract(available);
395
5/5
✓ Branch 6 → 7 taken 36 times.
✓ Branch 7 → 8 taken 36 times.
✓ Branch 8 → 9 taken 36 times.
✓ Branch 22 → 10 taken 287 times.
✓ Branch 22 → 23 taken 36 times.
323 for (const auto &path : m_cache->allFolderPaths(m_folder.accountId)) {
396
6/6
✓ Branch 12 → 13 taken 10 times.
✓ Branch 12 → 16 taken 277 times.
✓ Branch 14 → 15 taken 9 times.
✓ Branch 14 → 16 taken 1 time.
✓ Branch 17 → 18 taken 9 times.
✓ Branch 17 → 20 taken 278 times.
287 if (!available.contains(path) && !m_unavailableFolders.contains(path)) {
397
1/1
✓ Branch 18 → 19 taken 9 times.
9 m_unavailableFolders.insert(path);
398
1/1
✓ Branch 19 → 20 taken 9 times.
9 invalidateUnavailableFolder(path);
399 }
400 36 }
401 36 }
402
403 9 void MailController::invalidateUnavailableFolder(const QString &folderPath) {
404 9 const qint64 folderId = m_cache->ensureFolder(m_folder.accountId, folderPath);
405
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 9 times.
9 if (folderId < 0)
406 ✗ return;
407 // Keep durable intent for review, but never replay a UID against a mailbox
408 // which disappeared from the authoritative LIST response.
409
5/5
✓ Branch 6 → 7 taken 9 times.
✓ Branch 7 → 8 taken 9 times.
✓ Branch 8 → 9 taken 9 times.
✓ Branch 29 → 10 taken 1 time.
✓ Branch 29 → 30 taken 9 times.
10 for (const auto &record : m_cache->journal().pendingMutations(m_folder.accountId)) {
410
2/6
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 14 taken 1 time.
✗ Branch 13 → 14 not taken.
✗ Branch 13 → 15 not taken.
✓ Branch 16 → 17 taken 1 time.
✗ Branch 16 → 27 not taken.
1 if (record.mailId.folderId == folderId || record.targetFolder == folderPath)
411
1/1
✓ Branch 22 → 23 taken 1 time.
3 m_cache->journal().updateMutationPhase(record.token, MutationPhase::Quarantined,
412 2 QStringLiteral("Mailbox unavailable after LIST; action requires review"));
413 9 }
414 9 m_bodies.requests().invalidateFolder(m_folder.accountId, folderId);
415 9 m_bodies.dropReconnectRetries(folderId);
416 9 m_subscribedFolders.removeAll(folderPath);
417
2/2
✓ Branch 35 → 36 taken 8 times.
✓ Branch 35 → 41 taken 1 time.
9 if (folderId == m_folder.currentFolderId) {
418 8 ++m_folder.folderGeneration;
419 8 m_folderSwitchTimer->stop();
420 8 m_reverseChunks.clear();
421 8 m_pendingHeaderFetch = false;
422 8 m_fetchInProgress = false; // S106-183: the fenced sync never completes (blocked prefetch)
423 8 clearDisplaySelection();
424 8 m_restoreAnchorFolder.clear();
425 8 m_restoreAnchorUid = 0;
426 }
427 9 emit folderIdentityInvalidated(folderId);
428
2/2
✓ Branch 42 → 43 taken 8 times.
✓ Branch 42 → 46 taken 1 time.
9 if (folderId == m_folder.currentFolderId) {
429 8 m_folder.currentFolder.clear();
430 8 m_folder.currentFolderId = -1;
431
1/2
✓ Branch 44 → 45 taken 8 times.
✗ Branch 44 → 46 not taken.
8 if (m_folderPresentationEnabled)
432 8 m_model->clear();
433 }
434 }
435
436 2 void MailController::resyncCurrentFolder() {
437
1/2
✓ Branch 3 → 4 taken 2 times.
✗ Branch 3 → 5 not taken.
2 if (!m_folder.currentFolder.isEmpty())
438 2 m_folderSwitchTimer->start();
439 2 }
440
441 15 void MailController::onFolderSelected(const QString &folderPath) {
442
1/1
✓ Branch 3 → 4 taken 15 times.
15 onFolderSelected(folderPath, false);
443 15 }
444
445 760 void MailController::onFolderSelected(const QString &folderPath, bool countsOnly,
446 std::optional<PublishedFolderSummary> published,
447 qint64 knownFolderId) {
448
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 759 times.
760 if (m_unavailableFolders.contains(folderPath))
449 4 return;
450 759 m_folderPresentationEnabled = true;
451 759 m_flatPageNeedsReload = false;
452 759 m_flatPageRefreshInFlight = false;
453 759 ++m_flatPageRefreshVersion;
454
1/1
✓ Branch 5 → 6 taken 759 times.
759 resetHeaderWrites();
455
1/1
✓ Branch 6 → 7 taken 759 times.
759 m_flagSync.cancelMembershipRead();
456 // Abort any in-progress reverse-chunk fetch for the previous folder
457
1/1
✓ Branch 7 → 8 taken 759 times.
759 m_reverseChunks.clear();
458 759 m_pendingHeaderFetch = false;
459 759 ++m_folder.folderGeneration; // Invalidate stale async callbacks
460 759 m_folder.activeFolderUidValidity = 0;
461 auto &performanceTracker =
462
1/1
✓ Branch 8 → 9 taken 759 times.
759 MailJD::Performance::FolderPerformanceTracker::instance();
463
1/1
✓ Branch 9 → 10 taken 759 times.
759 performanceTracker.beginSwitch(folderPath, m_folder.folderGeneration);
464 auto &backgroundTracker =
465
1/1
✓ Branch 10 → 11 taken 759 times.
759 MailJD::Performance::BackgroundPerformanceTracker::instance();
466
3/5
✓ Branch 30 → 31 taken 759 times.
✓ Branch 33 → 34 taken 4554 times.
✓ Branch 33 → 35 taken 759 times.
✗ Branch 206 → 207 not taken.
✗ Branch 206 → 208 not taken.
5313 backgroundTracker.beginBackground(
467 759 {QStringLiteral("imap_initial"), QStringLiteral("imap_no_change"),
468 759 QStringLiteral("imap_small_delta"),
469 759 QStringLiteral("imap_large_delta"),
470 759 QStringLiteral("mail_open_during_header_sync"),
471 759 QStringLiteral("folder_during_header_sync")});
472
1/1
✓ Branch 44 → 45 taken 759 times.
759 backgroundTracker.backgroundPhaseStarted(QStringLiteral("cache_model"));
473
474 // T-201: Do NOT call clearDeferredCommands() here.
475 // The generation check in executeDeferredFolderSwitch() already
476 // safely ignores stale SELECTs. Clearing the queue would destroy
477 // pending STORE/MOVE commands (user actions) — causing data loss.
478
479 // Ensure polling restarts even if IDLE doesn't start (e.g. IDLE race)
480
1/1
✓ Branch 47 → 48 taken 759 times.
759 m_flagSync.restartPolling();
481 759 bool folderChanged = (folderPath != m_folder.currentFolder); // T-545b
482 759 m_folder.currentFolder = folderPath;
483
3/4
✓ Branch 50 → 51 taken 693 times.
✓ Branch 50 → 53 taken 66 times.
✓ Branch 51 → 52 taken 693 times.
✗ Branch 51 → 53 not taken.
759 if (folderChanged && m_model) m_model->discardUnfilteredWindow();
484
2/2
✓ Branch 53 → 54 taken 693 times.
✓ Branch 53 → 58 taken 66 times.
759 if (folderChanged) {
485
2/2
✓ Branch 54 → 55 taken 689 times.
✓ Branch 54 → 56 taken 4 times.
693 if (m_view)
486
1/1
✓ Branch 55 → 56 taken 689 times.
689 m_view->clearDisplay();
487
1/1
✓ Branch 57 → 58 taken 693 times.
693 m_bodies.requests().clearDisplaySelection();
488 }
489 759 m_fetchInProgress = true; // T-074: mark fetch in progress
490 759 m_folderSwitchStopwatch.start(); // T-210: start total folder-switch timing
491
492 // Quick already resolved this account-scoped path from its folder identity
493 // map or cold lookup. Repeating ensureFolder here would enter the GUI SQLite
494 // writer connection on every warm switch. New folders still use ensure.
495
2/2
✓ Branch 59 → 60 taken 15 times.
✓ Branch 59 → 62 taken 744 times.
759 m_folder.currentFolderId = knownFolderId > 0 ? knownFolderId
496
1/1
✓ Branch 60 → 61 taken 15 times.
15 : m_cache->ensureFolder(m_folder.accountId, folderPath);
497
1/2
✗ Branch 63 → 64 not taken.
✓ Branch 63 → 78 taken 759 times.
759 if (m_folder.currentFolderId < 0) {
498 ✗ performanceTracker.folderLookupFinished(m_folder.currentFolderId, false);
499 ✗ qCWarning(lcController)
500 ✗ << "Failed to ensure folder in cache:" << folderPath;
501 ✗ m_fetchInProgress = false;
502 ✗ backgroundTracker.backgroundFinished(false);
503 ✗ return;
504 }
505
1/1
✓ Branch 78 → 79 taken 759 times.
759 performanceTracker.folderLookupFinished(m_folder.currentFolderId, true);
506
507 // Step 2 (T-87.D1/P90.2): publish a cached first page, or reuse an exact
508 // indexed page's totals. The fallback reader resolves counts, restore
509 // anchors and up to 200 rows on a read-only worker snapshot. The IMAP
510 // SELECT debounce starts only after cached presentation committed.
511
1/1
✓ Branch 79 → 80 taken 759 times.
759 performanceTracker.headerQueryStarted();
512
3/4
✓ Branch 81 → 82 taken 12 times.
✓ Branch 81 → 84 taken 747 times.
✓ Branch 82 → 83 taken 12 times.
✗ Branch 82 → 84 not taken.
759 const bool anchoredRestore = m_restoreAnchorFolder == folderPath && m_restoreAnchorUid > 0;
513
2/2
✓ Branch 85 → 86 taken 12 times.
✓ Branch 85 → 87 taken 747 times.
759 const qint64 restoreUid = anchoredRestore ? m_restoreAnchorUid : 0;
514
4/6
✓ Branch 88 → 89 taken 3 times.
✓ Branch 88 → 95 taken 756 times.
✓ Branch 89 → 90 taken 3 times.
✗ Branch 89 → 95 not taken.
✓ Branch 91 → 92 taken 3 times.
✗ Branch 91 → 95 not taken.
762 const bool publishedCandidate = countsOnly && !anchoredRestore && published &&
515
1/2
✓ Branch 93 → 94 taken 3 times.
✗ Branch 93 → 95 not taken.
3 published->folderId == m_folder.currentFolderId;
516 const auto publishedState = publishedCandidate
517
2/2
✓ Branch 96 → 97 taken 3 times.
✓ Branch 96 → 99 taken 756 times.
759 ? std::optional<MailCache::FolderPresentationState>{
518
1/1
✓ Branch 97 → 98 taken 3 times.
3 m_cache->folderPresentationState(m_folder.currentFolderId)}
519 759 : std::nullopt;
520 1518 const MailHeaderStreamIdentity identity{m_cache->databasePath(), m_folder.accountId,
521 759 folderPath, m_folder.currentFolderId,
522
3/9
✓ Branch 104 → 105 taken 3 times.
✓ Branch 104 → 107 taken 756 times.
✓ Branch 107 → 108 taken 756 times.
✗ Branch 246 → 247 not taken.
✗ Branch 246 → 248 not taken.
✗ Branch 249 → 250 not taken.
✗ Branch 249 → 251 not taken.
✗ Branch 252 → 253 not taken.
✗ Branch 252 → 254 not taken.
759 publishedState ? publishedState->uidValidity : m_cache->uidValidity(m_folder.currentFolderId)};
523 // A restored selection may lie outside the indexed page; keep its balanced
524 // flat window until a shared indexed anchor/read contract replaces it.
525
3/4
✓ Branch 109 → 110 taken 3 times.
✓ Branch 109 → 112 taken 756 times.
✓ Branch 110 → 111 taken 3 times.
✗ Branch 110 → 112 not taken.
759 countsOnly = countsOnly && !anchoredRestore;
526 759 m_flatPageNeedsReload = countsOnly;
527 759 m_restoreAnchorFolder.clear();
528 759 m_restoreAnchorUid = 0;
529 // A published indexed page carries totals from the same SQLite snapshot
530 // as its revision. If that exact identity is still current, no second
531 // cache read is needed to start the protocol path.
532
2/4
✓ Branch 116 → 117 taken 3 times.
✗ Branch 116 → 137 not taken.
✓ Branch 118 → 119 taken 3 times.
✗ Branch 118 → 137 not taken.
3 if (countsOnly && publishedState && published->folderId == identity.folderId &&
533
1/2
✓ Branch 120 → 121 taken 3 times.
✗ Branch 120 → 137 not taken.
3 published->uidValidity == identity.uidValidity &&
534
2/4
✓ Branch 122 → 123 taken 3 times.
✗ Branch 122 → 137 not taken.
✓ Branch 125 → 126 taken 3 times.
✗ Branch 125 → 137 not taken.
3 published->revision >= 0 && published->revision == publishedState->threadRevision &&
535
1/2
✓ Branch 128 → 129 taken 3 times.
✗ Branch 128 → 137 not taken.
3 published->totalCount == publishedState->headerCount &&
536
6/8
✓ Branch 114 → 115 taken 3 times.
✓ Branch 114 → 137 taken 756 times.
✓ Branch 130 → 131 taken 3 times.
✗ Branch 130 → 137 not taken.
✓ Branch 132 → 133 taken 3 times.
✗ Branch 132 → 137 not taken.
✓ Branch 138 → 139 taken 3 times.
✓ Branch 138 → 195 taken 756 times.
765 published->totalCount > 0 && published->unreadCount >= 0 &&
537
1/2
✓ Branch 135 → 136 taken 3 times.
✗ Branch 135 → 137 not taken.
3 published->unreadCount <= published->totalCount) {
538 3 const int total = published->totalCount;
539 3 const int unread = published->unreadCount;
540
1/1
✓ Branch 141 → 142 taken 3 times.
3 performanceTracker.labelsStarted();
541
1/1
✓ Branch 142 → 143 taken 3 times.
3 performanceTracker.cacheQueriesFinished(total);
542
1/2
✓ Branch 143 → 144 taken 3 times.
✗ Branch 143 → 155 not taken.
3 if (m_folderPresentationEnabled) {
543
1/1
✓ Branch 144 → 145 taken 3 times.
3 performanceTracker.modelUpdateStarted();
544
1/1
✓ Branch 148 → 149 taken 3 times.
3 m_model->setAsyncPagedHeaders({}, total, unread, {}, {});
545
1/1
✓ Branch 152 → 153 taken 3 times.
3 performanceTracker.modelUpdateFinished();
546
1/1
✓ Branch 153 → 154 taken 3 times.
3 performanceTracker.cachedCommitted(identity.folderId, total, m_folder.folderGeneration);
547
1/1
✓ Branch 154 → 155 taken 3 times.
3 emit cachedFolderPresented(identity.folderId, total, m_folder.folderGeneration);
548 }
549
1/1
✓ Branch 158 → 159 taken 3 times.
6 backgroundTracker.backgroundPhaseFinished(QStringLiteral("cache_model"), total);
550
1/1
✓ Branch 164 → 165 taken 3 times.
3 backgroundTracker.backgroundPhaseStarted(QStringLiteral("protocol_wait"));
551
7/10
✓ Branch 167 → 168 taken 3 times.
✗ Branch 167 → 174 not taken.
✓ Branch 172 → 173 taken 1 time.
✓ Branch 172 → 174 taken 2 times.
✓ Branch 175 → 176 taken 3 times.
✗ Branch 175 → 177 not taken.
✓ Branch 177 → 178 taken 3 times.
✗ Branch 177 → 179 not taken.
✓ Branch 179 → 180 taken 1 time.
✓ Branch 179 → 181 taken 2 times.
6 if (!m_inboxFirstSyncSignaled && m_folder.currentFolder == QStringLiteral("INBOX"))
552 1 m_inboxCacheWasEmpty = total == 0;
553
1/2
✓ Branch 181 → 182 taken 3 times.
✗ Branch 181 → 192 not taken.
3 if (total > 0)
554
4/4
✓ Branch 182 → 183 taken 3 times.
✓ Branch 184 → 185 taken 3 times.
✓ Branch 186 → 187 taken 3 times.
✓ Branch 187 → 188 taken 3 times.
9 emit backgroundStatusMessage(QString("%1 \u2013 %2 mails").arg(identity.folder).arg(total));
555
1/1
✓ Branch 192 → 193 taken 3 times.
3 m_folderSwitchTimer->start();
556
1/1
✓ Branch 193 → 194 taken 3 times.
3 performanceTracker.debounceStarted();
557 3 return;
558 }
559
1/1
✓ Branch 195 → 196 taken 756 times.
756 requestCachedFirstPage(identity, m_folder.folderGeneration, anchoredRestore, restoreUid, 0,
560 true, countsOnly);
561 6072 }
562
563 // Bounded retry when the shared reader queue or the header-stream fence is
564 // momentarily busy; gives up after this many attempts and falls back to the
565 // protocol path so a folder switch can never hang on local presentation.
566 static constexpr int kFirstPageBusyRetries = 3;
567
568 // Subsequent cached pages share the same bounded reader, identity fence and
569 // foreground fairness as the first page (T-87.D1 page ownership).
570 1402 MailListModel::AsyncPageLoader MailController::cachedPageLoader(
571 const MailHeaderStreamIdentity &identity, quint64 generation, bool previous,
572 const MailSortOrder &sort) {
573
1/1
✓ Branch 2 → 3 taken 1402 times.
1402 const QPointer<MailController> controller(this);
574
0/4
✗ Branch 13 → 14 not taken.
✗ Branch 13 → 15 not taken.
✗ Branch 16 → 17 not taken.
✗ Branch 16 → 18 not taken.
2804 return [controller, identity, generation, previous, sort](const MailListModel::PageCursor &anchor,
575 int limit, MailListModel::PageCallback done) {
576
5/6
✓ Branch 3 → 4 taken 105 times.
✗ Branch 3 → 9 not taken.
✓ Branch 5 → 6 taken 104 times.
✓ Branch 5 → 9 taken 1 time.
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 13 taken 104 times.
209 if (!controller || generation != controller->m_folder.folderGeneration ||
577
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 104 times.
105 identity.account != controller->m_folder.accountId) return;
578
2/2
✓ Branch 13 → 14 taken 104 times.
✓ Branch 16 → 17 taken 104 times.
104 auto fence = MailBodyWriteService::shared()->beginHeaderStream(controller->m_model, identity);
579
1/2
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 33 taken 104 times.
104 if (!fence) {
580 ✗ MailListModel::PageResult result;
581 ✗ result.error = QStringLiteral("Header page state is busy");
582 ✗ done(std::move(result));
583 ✗ return;
584 ✗ }
585 312 MailHeaderPageRequest request{identity.account, identity.folder, identity.folderId,
586 104 identity.uidValidity, anchor.uid, anchor.date, previous, limit};
587 104 request.includeAnchor = anchor.includeAnchor;
588
1/1
✓ Branch 38 → 39 taken 104 times.
104 request.filter = controller->flatFilterFor(identity.folderId);
589 104 request.sort = sort;
590 104 request.anchorFrom = anchor.from;
591 104 request.anchorTo = anchor.to;
592 104 request.anchorSubject = anchor.subject;
593 104 request.anchorSize = anchor.size;
594 104 request.anchorFlags = anchor.flags;
595
2/2
✓ Branch 44 → 45 taken 104 times.
✓ Branch 54 → 55 taken 104 times.
208 MailDisplayReadService::shared()->readHeaderPage(controller->m_model, identity.databasePath,
596
1/7
✓ Branch 50 → 51 taken 104 times.
✗ Branch 87 → 88 not taken.
✗ Branch 87 → 89 not taken.
✗ Branch 90 → 91 not taken.
✗ Branch 90 → 92 not taken.
✗ Branch 93 → 94 not taken.
✗ Branch 93 → 95 not taken.
208 request, [controller, identity, generation, previous, fence, done = std::move(done)]
597 (MailHeaderPageSnapshot snapshot) mutable {
598
3/6
✓ Branch 3 → 4 taken 96 times.
✗ Branch 3 → 9 not taken.
✓ Branch 5 → 6 taken 96 times.
✗ Branch 5 → 9 not taken.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 96 times.
192 if (!controller || generation != controller->m_folder.folderGeneration ||
599
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 96 times.
96 identity.account != controller->m_folder.accountId) return;
600 96 MailListModel::PageResult result;
601 96 result.error = snapshot.error;
602
2/2
✓ Branch 15 → 16 taken 1 time.
✓ Branch 15 → 25 taken 95 times.
96 if (snapshot.outcome != MailHeaderPageSnapshot::Outcome::Ready) {
603
1/2
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 41 taken 1 time.
1 if (result.error.isEmpty()) result.error = QStringLiteral("Header page read failed");
604 } else {
605 95 result.endOfData = snapshot.headers.isEmpty();
606
2/2
✓ Branch 26 → 27 taken 87 times.
✓ Branch 26 → 34 taken 8 times.
95 if (!result.endOfData)
607
2/2
✓ Branch 27 → 28 taken 5 times.
✓ Branch 27 → 29 taken 82 times.
87 result.cursor = previous ? snapshot.headers.constFirst() : snapshot.headers.constLast();
608 95 result.headers = std::move(snapshot.headers);
609 // Keep the SQL cursor even if a concurrent deletion removes every row.
610
1/1
✓ Branch 37 → 38 taken 95 times.
95 result.error = fence->apply(result.headers);
611 }
612
1/1
✓ Branch 43 → 44 taken 96 times.
96 done(std::move(result));
613 96 });
614
2/3
✓ Branch 5 → 6 taken 1402 times.
✓ Branch 62 → 63 taken 104 times.
✗ Branch 62 → 65 not taken.
2908 };
615 1402 }
616
617 // The flat list's quick filter is evaluated by the page reader too, so a
618 // filtered view pages through the whole folder rather than only the rows
619 // already loaded. A change reloads the first page (applied as a diff).
620 905 MailHeaderFilter MailController::flatFilterFor(qint64 folderId) const {
621 905 MailHeaderFilter filter = m_flatFilter;
622
5/6
✓ Branch 3 → 4 taken 9 times.
✓ Branch 3 → 7 taken 896 times.
✓ Branch 5 → 6 taken 9 times.
✗ Branch 5 → 7 not taken.
✓ Branch 8 → 9 taken 9 times.
✓ Branch 8 → 15 taken 896 times.
905 if (filter.unread && m_retainedUnreadProvider) {
623
1/1
✓ Branch 9 → 10 taken 9 times.
9 filter.retainedUnread = m_retainedUnreadProvider(folderId);
624 // Bounded inline list; beyond it the reader shows unread mails only.
625
1/2
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 9 times.
9 if (filter.retainedUnread.size() > 4096) filter.retainedUnread.clear();
626 }
627 905 return filter;
628 ✗ }
629
630 116 bool MailController::setFlatFilter(const MailHeaderFilter &filter) {
631
2/2
✓ Branch 3 → 4 taken 58 times.
✓ Branch 3 → 5 taken 58 times.
116 if (m_flatFilter == filter) return false;
632 58 const bool wasActive = m_flatFilter.active();
633 58 m_flatFilter = filter;
634 58 bool restoredCurrentWindow = false;
635
3/6
✓ Branch 7 → 8 taken 58 times.
✗ Branch 7 → 37 not taken.
✓ Branch 8 → 9 taken 58 times.
✗ Branch 8 → 37 not taken.
✓ Branch 9 → 10 taken 58 times.
✗ Branch 9 → 37 not taken.
58 if (m_model && m_folderPresentationEnabled && m_streamedHeadersVisible) {
636 58 bool keptWindowChanged = false;
637
7/7
✓ Branch 11 → 12 taken 37 times.
✓ Branch 11 → 14 taken 21 times.
✓ Branch 12 → 13 taken 29 times.
✓ Branch 12 → 14 taken 8 times.
✓ Branch 15 → 16 taken 29 times.
✓ Branch 15 → 17 taken 29 times.
✓ Branch 16 → 36 taken 29 times.
58 if (filter.active() && !wasActive) m_model->keepUnfilteredWindow();
638 // Clearing shows the kept window at once; the reload below catches up
639 // only when mail changed meanwhile (one model commit otherwise).
640
7/9
✓ Branch 18 → 19 taken 21 times.
✓ Branch 18 → 23 taken 8 times.
✓ Branch 19 → 20 taken 21 times.
✗ Branch 19 → 23 not taken.
✓ Branch 20 → 21 taken 21 times.
✓ Branch 21 → 22 taken 21 times.
✗ Branch 21 → 23 not taken.
✓ Branch 24 → 25 taken 21 times.
✓ Branch 24 → 36 taken 8 times.
29 else if (!filter.active() && wasActive && m_model->restoreUnfilteredWindow(&keptWindowChanged)) {
641 21 restoredCurrentWindow = !keptWindowChanged;
642 // Moves queued while filtered must not flash back in.
643
1/2
✗ Branch 34 → 29 not taken.
✓ Branch 34 → 35 taken 21 times.
21 for (const MailId &moving : m_mutations.intents().pendingMoves())
644 ✗ if (moving.folderId == m_folder.currentFolderId) m_model->removeByUid(moving.uid, moving.folderId);
645 }
646 }
647
8/8
✓ Branch 37 → 38 taken 58 times.
✓ Branch 41 → 42 taken 58 times.
✓ Branch 42 → 43 taken 58 times.
✓ Branch 44 → 45 taken 37 times.
✓ Branch 44 → 46 taken 21 times.
✓ Branch 47 → 48 taken 58 times.
✓ Branch 53 → 39 taken 58 times.
✓ Branch 53 → 54 taken 58 times.
116 qCInfo(lcController) << "Flat filter" << (filter.active() ? "active" : "cleared")
648
2/2
✓ Branch 48 → 49 taken 58 times.
✓ Branch 49 → 50 taken 58 times.
58 << "for" << m_folder.currentFolder;
649
1/2
✓ Branch 55 → 56 taken 58 times.
✗ Branch 55 → 59 not taken.
58 if (!m_folderPresentationEnabled || !m_streamedHeadersVisible ||
650
4/8
✓ Branch 54 → 55 taken 58 times.
✗ Branch 54 → 59 not taken.
✓ Branch 56 → 57 taken 58 times.
✗ Branch 56 → 59 not taken.
✗ Branch 58 → 59 not taken.
✓ Branch 58 → 60 taken 58 times.
✗ Branch 61 → 62 not taken.
✓ Branch 61 → 63 taken 58 times.
116 m_folder.currentFolderId <= 0 || m_folder.currentFolder.isEmpty()) return false;
651
2/2
✓ Branch 63 → 64 taken 21 times.
✓ Branch 63 → 65 taken 37 times.
58 if (restoredCurrentWindow) return true; // the restore replaced the rows
652 37 m_flatPageNeedsReload = true;
653
1/2
✗ Branch 65 → 66 not taken.
✓ Branch 65 → 67 taken 37 times.
37 if (m_flatPageRefreshInFlight) ++m_flatPageRefreshVersion;
654 37 refreshFlatPageIfNeeded(true);
655 37 return true;
656 }
657
658 806 bool MailController::setFlatSort(const MailSortOrder &order) {
659
2/2
✓ Branch 3 → 4 taken 795 times.
✓ Branch 3 → 5 taken 11 times.
806 if (m_flatSort == order) return false;
660 11 m_flatSort = order;
661 // A window kept for a filter belongs to the previous order.
662
1/2
✓ Branch 5 → 6 taken 11 times.
✗ Branch 5 → 7 not taken.
11 if (m_model) m_model->discardUnfilteredWindow();
663
9/9
✓ Branch 7 → 8 taken 11 times.
✓ Branch 11 → 12 taken 11 times.
✓ Branch 12 → 13 taken 11 times.
✓ Branch 13 → 14 taken 11 times.
✓ Branch 14 → 15 taken 11 times.
✓ Branch 15 → 16 taken 11 times.
✓ Branch 16 → 17 taken 11 times.
✓ Branch 21 → 9 taken 11 times.
✓ Branch 21 → 22 taken 11 times.
22 qCInfo(lcController) << "Flat sort" << order.toString() << "for" << m_folder.currentFolder;
664
3/6
✓ Branch 22 → 23 taken 11 times.
✗ Branch 22 → 25 not taken.
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 26 taken 11 times.
✗ Branch 27 → 28 not taken.
✓ Branch 27 → 29 taken 11 times.
11 if (m_folder.currentFolderId <= 0 || m_folder.currentFolder.isEmpty()) return false;
665 // A hidden flat list (Quick's indexed thread view) reloads when shown.
666 11 m_flatPageNeedsReload = true;
667
3/4
✓ Branch 29 → 30 taken 11 times.
✗ Branch 29 → 31 not taken.
✓ Branch 30 → 31 taken 4 times.
✓ Branch 30 → 32 taken 7 times.
11 if (!m_folderPresentationEnabled || !m_streamedHeadersVisible) return false;
668
2/2
✓ Branch 32 → 33 taken 1 time.
✓ Branch 32 → 34 taken 6 times.
7 if (m_flatPageRefreshInFlight) ++m_flatPageRefreshVersion;
669 7 refreshFlatPageIfNeeded(true);
670 7 return true;
671 }
672
673 65 void MailController::refreshFlatPageIfNeeded(bool allowActiveStream) {
674
1/2
✓ Branch 3 → 4 taken 46 times.
✗ Branch 3 → 9 not taken.
46 if (!m_flatPageNeedsReload || !m_folderPresentationEnabled ||
675
3/4
✓ Branch 4 → 5 taken 46 times.
✗ Branch 4 → 9 not taken.
✓ Branch 5 → 6 taken 45 times.
✓ Branch 5 → 9 taken 1 time.
46 !m_streamedHeadersVisible || m_flatPageRefreshInFlight ||
676
6/8
✓ Branch 2 → 3 taken 46 times.
✓ Branch 2 → 9 taken 19 times.
✓ Branch 6 → 7 taken 45 times.
✗ Branch 6 → 9 not taken.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 45 times.
✓ Branch 11 → 12 taken 20 times.
✓ Branch 11 → 13 taken 45 times.
111 m_folder.currentFolderId <= 0 || m_folder.currentFolder.isEmpty()) return;
677 // A user entering flat mode gets the current cached page immediately.
678 // Subsequent catch-up waits for the active stream to finish so a long
679 // stream cannot force a model reset on every committed batch.
680
2/8
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 18 taken 45 times.
✗ Branch 15 → 16 not taken.
✗ Branch 15 → 17 not taken.
✗ Branch 16 → 17 not taken.
✗ Branch 16 → 18 not taken.
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 21 taken 45 times.
45 if (!allowActiveStream && (m_headerStream || m_pendingHeaderWrites > 0)) return;
681 45 m_flatPageRefreshInFlight = true;
682 // After SELECT, the active mailbox epoch is already fenced by this folder
683 // generation. The page reader validates it with account/path/ID; a
684 // pre-SELECT cached refresh keeps the cache lookup for its epoch.
685 45 const quint32 epoch = m_folder.activeFolderGen == m_folder.folderGeneration &&
686
1/4
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 24 taken 45 times.
✗ Branch 22 → 23 not taken.
✗ Branch 22 → 24 not taken.
45 m_folder.activeFolderUidValidity > 0 ? m_folder.activeFolderUidValidity
687
1/1
✓ Branch 24 → 25 taken 45 times.
45 : m_cache->uidValidity(m_folder.currentFolderId);
688 90 const MailHeaderStreamIdentity identity{m_cache->databasePath(), m_folder.accountId,
689 45 m_folder.currentFolder, m_folder.currentFolderId, epoch};
690
1/1
✓ Branch 29 → 30 taken 45 times.
45 requestCachedFirstPage(identity, m_folder.folderGeneration, false, 0, 0, false);
691 45 }
692
693 1 void MailController::retryCachedFirstPage(const MailHeaderStreamIdentity &identity,
694 quint64 generation, bool anchoredRestore, qint64 restoreUid, int attempt,
695 bool protocolSync, bool countsOnly) {
696
1/1
✓ Branch 2 → 3 taken 1 time.
1 const QPointer<MailController> controller(this);
697
1/5
✓ Branch 5 → 6 taken 1 time.
✗ Branch 11 → 12 not taken.
✗ Branch 11 → 13 not taken.
✗ Branch 14 → 15 not taken.
✗ Branch 14 → 16 not taken.
1 QTimer::singleShot(50, this, [controller, identity, generation, anchoredRestore,
698 restoreUid, attempt, protocolSync, countsOnly]() {
699
3/6
✓ Branch 3 → 4 taken 1 time.
✗ Branch 3 → 9 not taken.
✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 9 not taken.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 1 time.
2 if (!controller || generation != controller->m_folder.folderGeneration ||
700
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 1 time.
1 identity.account != controller->m_folder.accountId)
701 ✗ return;
702 1 controller->requestCachedFirstPage(identity, generation, anchoredRestore,
703 1 restoreUid, attempt + 1, protocolSync, countsOnly);
704 });
705 1 }
706
707 ✗ void MailController::presentCachedFirstPageFailure(const MailHeaderStreamIdentity &identity,
708 quint64 generation, const QString &error, bool protocolSync) {
709 ✗ if (generation != m_folder.folderGeneration || identity.account != m_folder.accountId) return;
710 ✗ if (!protocolSync) {
711 ✗ m_flatPageRefreshInFlight = false;
712 ✗ qCWarning(lcController) << "Flat page refresh failed for" << identity.folder << ":" << error;
713 ✗ emit statusMessage(tr("Folder page refresh incomplete: %1").arg(error));
714 ✗ return;
715 }
716 ✗ qCWarning(lcController) << "Cached first page read failed for" << identity.folder
717 ✗ << ":" << error
718 ✗ << "- continuing with protocol synchronization";
719 auto &performanceTracker =
720 ✗ MailJD::Performance::FolderPerformanceTracker::instance();
721 ✗ performanceTracker.labelsStarted();
722 ✗ performanceTracker.cacheQueriesFinished(0);
723 ✗ if (m_folderPresentationEnabled) {
724 ✗ performanceTracker.modelUpdateStarted();
725 ✗ m_model->clear();
726 ✗ performanceTracker.modelUpdateFinished();
727 // Resolve dangling cached expectations with the honestly empty
728 // presentation; the authoritative sync owns any later correction.
729 ✗ performanceTracker.cachedCommitted(identity.folderId, 0, m_folder.folderGeneration);
730 ✗ emit cachedFolderPresented(identity.folderId, 0, m_folder.folderGeneration);
731 }
732 auto &backgroundTracker =
733 ✗ MailJD::Performance::BackgroundPerformanceTracker::instance();
734 ✗ backgroundTracker.backgroundPhaseFinished(QStringLiteral("cache_model"), 0);
735 ✗ backgroundTracker.backgroundPhaseStarted(QStringLiteral("protocol_wait"));
736 ✗ if (!m_inboxFirstSyncSignaled && m_folder.currentFolder == QStringLiteral("INBOX"))
737 ✗ m_inboxCacheWasEmpty = true;
738 // Step 4 (T-118) still runs: without a SELECT the folder would never sync.
739 ✗ m_folderSwitchTimer->start();
740 ✗ performanceTracker.debounceStarted();
741 }
742
743 802 void MailController::requestCachedFirstPage(const MailHeaderStreamIdentity &identity,
744 quint64 generation, bool anchoredRestore, qint64 restoreUid, int attempt,
745 bool protocolSync, bool countsOnly) {
746
1/1
✓ Branch 2 → 3 taken 802 times.
802 const QPointer<MailController> controller(this);
747
4/8
✓ Branch 3 → 4 taken 802 times.
✗ Branch 3 → 9 not taken.
✓ Branch 5 → 6 taken 802 times.
✗ Branch 5 → 9 not taken.
✓ Branch 7 → 8 taken 802 times.
✗ Branch 7 → 9 not taken.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 19 taken 802 times.
1604 if (!m_model || identity.folder.isEmpty() || identity.account.isEmpty() ||
748
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 802 times.
802 identity.folderId <= 0) {
749 ✗ presentCachedFirstPageFailure(identity, generation,
750 ✗ QStringLiteral("Invalid cached folder identity"), protocolSync);
751 ✗ return;
752 }
753 802 auto fence = countsOnly ? std::shared_ptr<MailHeaderStateFence>{}
754
4/8
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 21 taken 802 times.
✓ Branch 21 → 22 taken 802 times.
✓ Branch 23 → 24 taken 802 times.
✓ Branch 24 → 25 taken 802 times.
✗ Branch 24 → 26 not taken.
✗ Branch 92 → 93 not taken.
✗ Branch 92 → 94 not taken.
802 : MailBodyWriteService::shared()->beginHeaderStream(m_model, identity);
755
5/6
✓ Branch 26 → 27 taken 802 times.
✗ Branch 26 → 30 not taken.
✓ Branch 28 → 29 taken 1 time.
✓ Branch 28 → 30 taken 801 times.
✓ Branch 31 → 32 taken 1 time.
✓ Branch 31 → 42 taken 801 times.
802 if (!countsOnly && !fence) {
756
1/2
✓ Branch 32 → 33 taken 1 time.
✗ Branch 32 → 34 not taken.
1 if (attempt < kFirstPageBusyRetries)
757
1/1
✓ Branch 33 → 41 taken 1 time.
1 retryCachedFirstPage(identity, generation, anchoredRestore, restoreUid, attempt,
758 protocolSync, countsOnly);
759 else
760 ✗ presentCachedFirstPageFailure(identity, generation,
761 ✗ QStringLiteral("Header page state is busy"), protocolSync);
762 1 return;
763 }
764 2403 MailHeaderPageRequest page{identity.account, identity.folder, identity.folderId,
765 801 identity.uidValidity, countsOnly ? 0 : restoreUid, {}, false,
766
2/4
✗ Branch 44 → 45 not taken.
✓ Branch 44 → 46 taken 801 times.
✗ Branch 48 → 49 not taken.
✓ Branch 48 → 50 taken 801 times.
1602 countsOnly ? 0 : MailHeaderPageSnapshot::MaximumRows};
767 801 page.includeCounts = true;
768 801 page.countsOnly = countsOnly;
769
3/4
✓ Branch 52 → 53 taken 801 times.
✗ Branch 52 → 55 not taken.
✓ Branch 53 → 54 taken 12 times.
✓ Branch 53 → 55 taken 789 times.
801 page.anchoredRestore = !countsOnly && anchoredRestore;
770
1/1
✓ Branch 56 → 57 taken 801 times.
801 page.filter = flatFilterFor(identity.folderId);
771 801 page.sort = m_flatSort;
772 801 const quint64 flatRefreshVersion = m_flatPageRefreshVersion;
773 801 const MailHeaderFilter requestedFilter = m_flatFilter;
774 801 const MailSortOrder requestedSort = m_flatSort;
775
2/2
✓ Branch 60 → 61 taken 801 times.
✓ Branch 68 → 69 taken 801 times.
1602 MailDisplayReadService::shared()->readHeaderPage(m_model, identity.databasePath, page,
776
1/9
✓ Branch 65 → 66 taken 801 times.
✗ Branch 113 → 114 not taken.
✗ Branch 113 → 115 not taken.
✗ Branch 116 → 117 not taken.
✗ Branch 116 → 118 not taken.
✗ Branch 119 → 120 not taken.
✗ Branch 119 → 121 not taken.
✗ Branch 122 → 123 not taken.
✗ Branch 122 → 124 not taken.
1602 [controller, identity, generation, anchoredRestore, restoreUid, attempt,
777 protocolSync, countsOnly, flatRefreshVersion, fence, requestedFilter, requestedSort]
778 (MailHeaderPageSnapshot snapshot) mutable {
779
5/6
✓ Branch 3 → 4 taken 703 times.
✗ Branch 3 → 9 not taken.
✓ Branch 5 → 6 taken 702 times.
✓ Branch 5 → 9 taken 1 time.
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 13 taken 702 times.
1405 if (!controller || generation != controller->m_folder.folderGeneration ||
780
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 702 times.
702 identity.account != controller->m_folder.accountId)
781 2 return;
782 // A refresh read for a filter the user already changed must not replace
783 // the current (e.g. restored unfiltered) window; read again instead.
784
8/9
✓ Branch 13 → 14 taken 42 times.
✓ Branch 13 → 21 taken 660 times.
✓ Branch 15 → 16 taken 42 times.
✓ Branch 16 → 17 taken 42 times.
✗ Branch 16 → 20 not taken.
✓ Branch 19 → 20 taken 1 time.
✓ Branch 19 → 21 taken 41 times.
✓ Branch 22 → 23 taken 1 time.
✓ Branch 22 → 30 taken 701 times.
744 if (!protocolSync && (!(requestedFilter == controller->m_flatFilter) ||
785 42 requestedSort != controller->m_flatSort)) {
786 1 controller->m_flatPageRefreshInFlight = false;
787 1 controller->m_flatPageNeedsReload = true;
788
1/1
✓ Branch 27 → 28 taken 1 time.
1 QTimer::singleShot(0, controller, [controller] {
789
1/2
✓ Branch 3 → 4 taken 1 time.
✗ Branch 3 → 6 not taken.
1 if (controller) controller->refreshFlatPageIfNeeded(true);
790 1 });
791 1 return;
792 }
793
1/2
✗ Branch 30 → 31 not taken.
✓ Branch 30 → 35 taken 701 times.
701 if (snapshot.outcome == MailHeaderPageSnapshot::Outcome::Busy &&
794 ✗ attempt < kFirstPageBusyRetries) {
795 ✗ controller->retryCachedFirstPage(identity, generation, anchoredRestore,
796 restoreUid, attempt, protocolSync, countsOnly);
797 ✗ return;
798 }
799 701 QString error = snapshot.error;
800
1/2
✗ Branch 36 → 37 not taken.
✓ Branch 36 → 46 taken 701 times.
701 if (snapshot.outcome != MailHeaderPageSnapshot::Outcome::Ready) {
801 ✗ if (error.isEmpty()) error = QStringLiteral("Cached first page read failed");
802
1/2
✓ Branch 46 → 47 taken 701 times.
✗ Branch 46 → 52 not taken.
701 } else if (!countsOnly) {
803
1/1
✓ Branch 48 → 49 taken 701 times.
701 error = fence->apply(snapshot.headers);
804 }
805
1/2
✗ Branch 53 → 54 not taken.
✓ Branch 53 → 57 taken 701 times.
701 if (!error.isEmpty()) {
806 ✗ controller->presentCachedFirstPageFailure(identity, generation, error,
807 protocolSync);
808 ✗ return;
809 }
810
2/2
✓ Branch 57 → 58 taken 41 times.
✓ Branch 57 → 61 taken 660 times.
742 const bool refreshStale = !protocolSync &&
811
1/2
✗ Branch 59 → 60 not taken.
✓ Branch 59 → 61 taken 41 times.
41 flatRefreshVersion != controller->m_flatPageRefreshVersion;
812 const int cachedTotal =
813
1/2
✓ Branch 62 → 63 taken 701 times.
✗ Branch 62 → 64 not taken.
701 snapshot.totalCount >= 0 ? snapshot.totalCount : snapshot.headers.size();
814 701 const int cachedUnread = snapshot.unreadCount >= 0 ? snapshot.unreadCount : 0;
815 auto &performanceTracker =
816
1/1
✓ Branch 66 → 67 taken 701 times.
701 MailJD::Performance::FolderPerformanceTracker::instance();
817
2/2
✓ Branch 67 → 68 taken 660 times.
✓ Branch 67 → 70 taken 41 times.
701 if (protocolSync) {
818
1/1
✓ Branch 68 → 69 taken 660 times.
660 performanceTracker.labelsStarted();
819
1/1
✓ Branch 69 → 70 taken 660 times.
660 performanceTracker.cacheQueriesFinished(cachedTotal);
820 }
821 // The page was requested before a search projection may have taken the
822 // model; a late delivery must never replace that projection (T-87.D1).
823
1/2
✓ Branch 71 → 72 taken 701 times.
✗ Branch 71 → 108 not taken.
701 if (controller->m_folderPresentationEnabled) {
824
3/3
✓ Branch 72 → 73 taken 660 times.
✓ Branch 72 → 74 taken 41 times.
✓ Branch 73 → 74 taken 660 times.
701 if (protocolSync) performanceTracker.modelUpdateStarted();
825 // The retained window slides while paging (T-87.D1 eviction): always
826 // attach the previous loader so evicted newest rows can be re-fetched.
827 // At the folder's top it harmlessly returns an empty end-of-data page
828 // once and then retires itself.
829 701 controller->m_model->setSortOrder(requestedSort);
830 701 controller->m_model->setHidingFilteredRows(requestedFilter.active());
831
1/1
✓ Branch 88 → 89 taken 701 times.
1402 controller->m_model->setAsyncPagedHeaders(snapshot.headers, cachedTotal, cachedUnread,
832
1/2
✗ Branch 84 → 85 not taken.
✓ Branch 84 → 86 taken 701 times.
1402 countsOnly ? MailListModel::AsyncPageLoader{}
833
1/1
✓ Branch 87 → 88 taken 701 times.
701 : controller->cachedPageLoader(identity, generation, false, requestedSort),
834
1/2
✗ Branch 80 → 81 not taken.
✓ Branch 80 → 82 taken 701 times.
1402 countsOnly ? MailListModel::AsyncPageLoader{}
835
1/1
✓ Branch 83 → 84 taken 701 times.
701 : controller->cachedPageLoader(identity, generation, true, requestedSort));
836 701 controller->m_model->setHidingFilteredRows(false);
837
2/4
✓ Branch 93 → 94 taken 701 times.
✗ Branch 93 → 95 not taken.
✗ Branch 94 → 95 not taken.
✓ Branch 94 → 96 taken 701 times.
701 controller->m_flatPageNeedsReload = countsOnly || refreshStale;
838
2/2
✓ Branch 98 → 99 taken 41 times.
✓ Branch 98 → 101 taken 660 times.
701 if (!protocolSync) controller->m_flatPageRefreshInFlight = false;
839
2/2
✓ Branch 101 → 102 taken 660 times.
✓ Branch 101 → 105 taken 41 times.
701 if (protocolSync) {
840
1/1
✓ Branch 102 → 103 taken 660 times.
660 performanceTracker.modelUpdateFinished();
841
1/1
✓ Branch 104 → 105 taken 660 times.
660 performanceTracker.cachedCommitted(identity.folderId, cachedTotal,
842 660 controller->m_folder.folderGeneration);
843 }
844
1/1
✓ Branch 107 → 111 taken 701 times.
1402 emit controller->cachedFolderPresented(identity.folderId, cachedTotal,
845 701 controller->m_folder.folderGeneration);
846 ✗ } else if (!protocolSync) {
847 ✗ controller->m_flatPageRefreshInFlight = false;
848 }
849
1/2
✗ Branch 111 → 112 not taken.
✓ Branch 111 → 117 taken 701 times.
701 if (refreshStale)
850 ✗ QTimer::singleShot(0, controller, [controller] {
851 ✗ if (controller) controller->refreshFlatPageIfNeeded();
852 ✗ });
853
2/2
✓ Branch 117 → 118 taken 660 times.
✓ Branch 117 → 132 taken 41 times.
701 if (protocolSync) {
854 auto &backgroundTracker =
855
1/1
✓ Branch 118 → 119 taken 660 times.
660 MailJD::Performance::BackgroundPerformanceTracker::instance();
856
1/1
✓ Branch 122 → 123 taken 660 times.
1320 backgroundTracker.backgroundPhaseFinished(QStringLiteral("cache_model"),
857 cachedTotal);
858
1/1
✓ Branch 128 → 129 taken 660 times.
660 backgroundTracker.backgroundPhaseStarted(QStringLiteral("protocol_wait"));
859 }
860
861 // 67.A2: Remember whether the first INBOX sync starts from an empty
862 // cache (first-ever mailbox load → suppressed notifications are
863 // dropped, not flushed). The worker counts settle this before the
864 // debounced SELECT can deliver any headers.
865
5/6
✓ Branch 132 → 133 taken 660 times.
✓ Branch 132 → 142 taken 41 times.
✓ Branch 134 → 135 taken 660 times.
✗ Branch 134 → 142 not taken.
✓ Branch 140 → 141 taken 605 times.
✓ Branch 140 → 142 taken 55 times.
1361 if (protocolSync && !controller->m_inboxFirstSyncSignaled &&
866
6/6
✓ Branch 143 → 144 taken 660 times.
✓ Branch 143 → 145 taken 41 times.
✓ Branch 145 → 146 taken 660 times.
✓ Branch 145 → 147 taken 41 times.
✓ Branch 147 → 148 taken 605 times.
✓ Branch 147 → 150 taken 96 times.
1361 controller->m_folder.currentFolder == QStringLiteral("INBOX"))
867 605 controller->m_inboxCacheWasEmpty = cachedTotal == 0;
868
869
4/4
✓ Branch 150 → 151 taken 660 times.
✓ Branch 150 → 180 taken 41 times.
✓ Branch 151 → 152 taken 596 times.
✓ Branch 151 → 180 taken 64 times.
701 if (protocolSync && cachedTotal > 0) {
870
2/2
✓ Branch 153 → 154 taken 596 times.
✓ Branch 158 → 159 taken 596 times.
1192 emit controller->backgroundStatusMessage(QString("%1 \u2013 %2 mails")
871
1/1
✓ Branch 155 → 156 taken 596 times.
1192 .arg(identity.folder)
872
1/1
✓ Branch 157 → 158 taken 596 times.
1192 .arg(cachedTotal));
873
7/7
✓ Branch 162 → 163 taken 596 times.
✓ Branch 166 → 167 taken 596 times.
✓ Branch 167 → 168 taken 596 times.
✓ Branch 169 → 170 taken 596 times.
✓ Branch 170 → 171 taken 596 times.
✓ Branch 178 → 164 taken 596 times.
✓ Branch 178 → 179 taken 596 times.
1192 qCInfo(lcController) << "Loaded first" << snapshot.headers.size() << "of"
874
3/3
✓ Branch 171 → 172 taken 596 times.
✓ Branch 172 → 173 taken 596 times.
✓ Branch 173 → 174 taken 596 times.
596 << cachedTotal << "cached headers for" << identity.folder
875
1/1
✓ Branch 174 → 175 taken 596 times.
596 << "through the foreground reader";
876 }
877
878 // Step 4: T-118 — Debounce the IMAP SELECT.
879 // The expensive part (stopping IDLE, SELECT round-trip, flag sync) is
880 // delayed by 50ms. If the user switches again within 50ms, the timer
881 // restarts and only the final folder gets SELECTed.
882
2/2
✓ Branch 180 → 181 taken 660 times.
✓ Branch 180 → 184 taken 41 times.
701 if (protocolSync) {
883
1/1
✓ Branch 182 → 183 taken 660 times.
660 controller->m_folderSwitchTimer->start();
884
1/1
✓ Branch 183 → 184 taken 660 times.
660 performanceTracker.debounceStarted();
885 }
886
1/2
✓ Branch 186 → 187 taken 701 times.
✗ Branch 186 → 189 not taken.
701 });
887 803 }
888
889 2994 void MailController::clearDisplaySelection() {
890 2994 MailDisplayReadService::shared()->cancel(this);
891 2994 m_prefetchProbeTimer.stop();
892
2/2
✓ Branch 5 → 6 taken 228 times.
✓ Branch 5 → 7 taken 2766 times.
2994 if (m_loadingPlaceholderTimer) m_loadingPlaceholderTimer->stop();
893 2994 m_bodies.requests().clearDisplaySelection();
894
2/2
✓ Branch 9 → 10 taken 2666 times.
✓ Branch 9 → 11 taken 328 times.
2994 if (m_view)
895 2666 m_view->clearDisplay();
896 2994 }
897
898 588 void MailController::executeDeferredFolderSwitch() {
899 // T-118: Timer fired — issue the IMAP SELECT for the current folder.
900 588 auto gen = m_folder.folderGeneration;
901 588 auto folderPath = m_folder.currentFolder;
902 auto &performanceTracker =
903
1/1
✓ Branch 3 → 4 taken 588 times.
588 MailJD::Performance::FolderPerformanceTracker::instance();
904
1/1
✓ Branch 4 → 5 taken 588 times.
588 performanceTracker.debounceFinished();
905
1/1
✓ Branch 5 → 6 taken 588 times.
588 performanceTracker.idleExitStarted();
906
2/4
✓ Branch 7 → 8 taken 588 times.
✓ Branch 8 → 9 taken 588 times.
✗ Branch 17 → 18 not taken.
✗ Branch 17 → 19 not taken.
588 m_imap->executeAfterIdle([this, folderPath, gen]() {
907
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 6 taken 588 times.
588 if (gen != m_folder.folderGeneration) {
908 ✗ MailJD::Performance::FolderPerformanceTracker::instance()
909 ✗ .staleGenerationDiscarded(gen);
910 ✗ return; // folder changed before IDLE finished — discard
911 }
912 588 MailJD::Performance::FolderPerformanceTracker::instance()
913 588 .selectDispatched();
914 // A folder without cached headers needs every header anyway: its UID
915 // list starts the newest-first fetch without a full flag snapshot
916 // first. Otherwise pipeline SELECT + FETCH FLAGS (T-207).
917 588 m_openedWithUidSearch =
918
1/1
✓ Branch 8 → 9 taken 588 times.
588 m_cache->folderPresentationState(m_folder.currentFolderId).headerCount == 0;
919 // A cached folder on a CONDSTORE server asks only for mail changed
920 // since the last completed sync plus the UID list (RFC 7162).
921
1/2
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 14 taken 531 times.
531 const quint64 modseq = m_openedWithUidSearch || !m_imap->hasCondstoreCapability()
922
2/2
✓ Branch 9 → 10 taken 531 times.
✓ Branch 9 → 14 taken 57 times.
1119 ? 0 : m_cache->highestModseq(m_folder.currentFolderId);
923 588 m_openedWithChangedSince = modseq > 0;
924 588 m_flagSync.clearChangedSinceFlags();
925
2/2
✓ Branch 16 → 17 taken 57 times.
✓ Branch 16 → 18 taken 531 times.
588 if (m_openedWithUidSearch)
926 57 m_imap->selectAndSearchAll(folderPath);
927
1/2
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 531 times.
531 else if (m_openedWithChangedSince)
928 ✗ m_imap->selectAndFetchChangedFlags(folderPath, modseq);
929 else
930 531 m_imap->selectAndFetchFlags(folderPath);
931 });
932 588 }
933
934 26 bool MailController::reconcileFolderIdentity(const QString &path, quint32 uidValidity) {
935
1/1
✓ Branch 2 → 3 taken 26 times.
26 const qint64 folderId = m_cache->ensureFolder(m_folder.accountId, path);
936
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 26 times.
26 if (folderId < 0)
937 ✗ return false;
938 // One presentation snapshot supplies both the pre-SELECT epoch and the
939 // cache-empty decision. The aggregate is maintained with header changes.
940
1/1
✓ Branch 5 → 6 taken 26 times.
26 const auto state = m_cache->folderPresentationState(folderId);
941 26 const quint32 previousValidity = state.uidValidity;
942
4/4
✓ Branch 6 → 7 taken 21 times.
✓ Branch 6 → 9 taken 5 times.
✓ Branch 7 → 8 taken 8 times.
✓ Branch 7 → 9 taken 13 times.
26 const bool invalidated = previousValidity != 0 && previousValidity != uidValidity;
943
2/2
✓ Branch 10 → 11 taken 8 times.
✓ Branch 10 → 22 taken 18 times.
26 if (invalidated) {
944
1/1
✓ Branch 12 → 13 taken 8 times.
8 m_bodies.requests().invalidateFolder(m_folder.accountId, folderId);
945
1/1
✓ Branch 13 → 14 taken 8 times.
8 m_bodies.dropReconnectRetries(folderId);
946 // F-67: a new mailbox epoch invalidates the Undo stack with the
947 // selection it was built from — rebased entries would change
948 // whatever message now owns the reused UIDs.
949
2/3
✓ Branch 15 → 16 taken 8 times.
✗ Branch 15 → 18 not taken.
✓ Branch 17 → 18 taken 8 times.
8 if (m_undoManager) m_undoManager->clear();
950
1/2
✓ Branch 18 → 19 taken 8 times.
✗ Branch 18 → 22 not taken.
8 if (folderId == m_folder.currentFolderId) {
951
1/1
✓ Branch 19 → 20 taken 8 times.
8 clearDisplaySelection();
952 8 m_restoreAnchorFolder.clear();
953 8 m_restoreAnchorUid = 0;
954 }
955 }
956 // SELECT usually confirms the existing mailbox epoch. Avoid a second
957 // uidValidity read and an unconditional UPDATE on the GUI writer connection
958 // when nothing changed; the transition path still purges stale UIDs.
959
2/2
✓ Branch 22 → 23 taken 12 times.
✓ Branch 22 → 39 taken 14 times.
26 if (previousValidity != uidValidity) {
960 // F-63: a failed transition keeps the old epoch; header admission
961 // rejects the new epoch's streams, so identity stays intact.
962
2/3
✓ Branch 23 → 24 taken 12 times.
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 39 taken 12 times.
12 if (!m_cache->setUidValidity(folderId, uidValidity)) {
963 ✗ qCWarning(lcController) << "Epoch transition failed for folder" << path
964 ✗ << "; keeping epoch" << previousValidity;
965 ✗ return false;
966 }
967 }
968
2/2
✓ Branch 39 → 40 taken 8 times.
✓ Branch 39 → 44 taken 18 times.
26 if (invalidated) {
969
1/1
✓ Branch 40 → 41 taken 8 times.
8 emit folderIdentityInvalidated(folderId);
970
2/4
✓ Branch 41 → 42 taken 8 times.
✗ Branch 41 → 44 not taken.
✓ Branch 42 → 43 taken 8 times.
✗ Branch 42 → 44 not taken.
8 if (folderId == m_folder.currentFolderId && m_folderPresentationEnabled)
971
1/1
✓ Branch 43 → 44 taken 8 times.
8 m_model->clear();
972 }
973 26 return state.headerCount == 0;
974 }
975
976 18 void MailController::onFolderSelectedFromImap(const QString &path,
977 int messageCount,
978 quint32 uidValidity,
979 quint64 highestModseq) {
980 // Body fetch SELECT: don't trigger sync pipeline
981
2/2
✓ Branch 2 → 3 taken 6 times.
✓ Branch 2 → 4 taken 12 times.
18 if (m_bodyFetchSelect) {
982 6 m_bodyFetchSelect = false;
983 6 return;
984 }
985
986
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 9 taken 11 times.
12 if (path != m_folder.currentFolder) {
987 1 MailJD::Performance::FolderPerformanceTracker::instance()
988 1 .staleGenerationDiscarded(m_folder.activeFolderGen);
989 1 return;
990 }
991
992 // T-113: Mark this generation as the active pipeline.
993 // All downstream callbacks (onFlagsReceived, onHeadersReceived, etc.)
994 // check m_folder.activeFolderGen == m_folder.folderGeneration to detect stale data.
995 11 m_folder.activeFolderGen = m_folder.folderGeneration;
996 11 resetHeaderWrites();
997 11 MailJD::Performance::FolderPerformanceTracker::instance().selected(
998 m_folder.currentFolderId, m_folder.folderGeneration);
999
1000 11 const auto selectedGeneration = m_folder.folderGeneration;
1001 11 const bool cacheWasEmpty = reconcileFolderIdentity(path, uidValidity);
1002
3/6
✓ Branch 13 → 14 taken 11 times.
✗ Branch 13 → 16 not taken.
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 11 times.
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 11 times.
11 if (selectedGeneration != m_folder.folderGeneration || path != m_folder.currentFolder) return;
1003 11 m_folder.activeFolderUidValidity = uidValidity;
1004
1005 // T-208: HIGHESTMODSEQ is persisted when this open's sync completes
1006 // (finishFlagSync), never before.
1007
2/2
✓ Branch 20 → 21 taken 2 times.
✓ Branch 20 → 22 taken 9 times.
11 m_pendingModseqFolderId = highestModseq > 0 ? m_folder.currentFolderId : -1;
1008 11 m_pendingModseq = highestModseq;
1009
1010
8/8
✓ Branch 23 → 24 taken 11 times.
✓ Branch 27 → 28 taken 11 times.
✓ Branch 28 → 29 taken 11 times.
✓ Branch 29 → 30 taken 11 times.
✓ Branch 30 → 31 taken 11 times.
✓ Branch 31 → 32 taken 11 times.
✓ Branch 37 → 25 taken 11 times.
✓ Branch 37 → 38 taken 11 times.
22 qCInfo(lcController) << "IMAP selected" << path << "with" << messageCount
1011
2/2
✓ Branch 32 → 33 taken 11 times.
✓ Branch 33 → 34 taken 11 times.
11 << "messages. Cached folder empty:" << cacheWasEmpty;
1012
1013 // The identity path already cleared a changed epoch. A concurrently
1014 // emptied cache also drops any stale pre-SELECT model rows.
1015
7/9
✓ Branch 38 → 39 taken 10 times.
✓ Branch 38 → 44 taken 1 time.
✓ Branch 39 → 40 taken 6 times.
✓ Branch 39 → 44 taken 4 times.
✓ Branch 41 → 42 taken 6 times.
✗ Branch 42 → 43 not taken.
✓ Branch 42 → 44 taken 6 times.
✗ Branch 45 → 46 not taken.
✓ Branch 45 → 47 taken 11 times.
11 if (m_folderPresentationEnabled && cacheWasEmpty && m_model->rowCount() > 0) {
1016 ✗ m_model->clear();
1017 }
1018
1019
2/2
✓ Branch 48 → 49 taken 1 time.
✓ Branch 48 → 56 taken 10 times.
11 if (std::exchange(m_openedWithChangedSince, false)) {
1020 // CHANGEDSINCE and the UID membership answer next; the membership
1021 // handler reconciles both and backfills missing UIDs.
1022 1 m_pendingHeaderFetch = true;
1023
3/3
✓ Branch 49 → 50 taken 1 time.
✓ Branch 51 → 52 taken 1 time.
✓ Branch 52 → 53 taken 1 time.
2 emit backgroundStatusMessage(QString("%1 – syncing changes...").arg(path));
1024 1 return;
1025 }
1026 // Consumed once: any later SELECT of this folder (reconnect) pipelines
1027 // FETCH FLAGS again.
1028
2/2
✓ Branch 57 → 58 taken 2 times.
✓ Branch 57 → 65 taken 8 times.
10 if (std::exchange(m_openedWithUidSearch, false)) {
1029 // The pipelined UID SEARCH ALL answers next: onSearchResultReceived
1030 // fetches the headers newest first and ends with the final flag pass
1031 // (the T-061 initial-sync path).
1032 2 m_pendingHeaderFetch = false;
1033
3/3
✓ Branch 58 → 59 taken 2 times.
✓ Branch 60 → 61 taken 2 times.
✓ Branch 61 → 62 taken 2 times.
4 emit backgroundStatusMessage(QString("%1 – loading messages...").arg(path));
1034 2 return;
1035 }
1036 // T-058: Flag sync FIRST to update stale cached flags immediately.
1037 // After flag sync completes, onFlagsReceived will trigger header delta fetch.
1038 // T-207: fetchFlags() is already pipelined via selectAndFetchFlags(),
1039 // so we don't need to send it separately here.
1040 8 m_pendingHeaderFetch = true;
1041
3/3
✓ Branch 65 → 66 taken 8 times.
✓ Branch 67 → 68 taken 8 times.
✓ Branch 68 → 69 taken 8 times.
16 emit backgroundStatusMessage(QString("%1 – syncing flags...").arg(path));
1042 }
1043
1044 // ═══════════════════════════════════════════════════════
1045 // Header Streaming (Bug 3 fix)
1046 // ═══════════════════════════════════════════════════════
1047
1048 18 void MailController::onHeadersReceived(const QList<MailHeader> &headers) {
1049 // T-113: Discard headers from a stale folder pipeline
1050
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 23 taken 17 times.
18 if (m_folder.activeFolderGen != m_folder.folderGeneration) {
1051
1/1
✓ Branch 3 → 4 taken 1 time.
1 MailJD::Performance::FolderPerformanceTracker::instance()
1052
1/1
✓ Branch 4 → 5 taken 1 time.
1 .staleGenerationDiscarded(m_folder.activeFolderGen);
1053
6/6
✓ Branch 5 → 6 taken 1 time.
✓ Branch 9 → 10 taken 1 time.
✓ Branch 10 → 11 taken 1 time.
✓ Branch 12 → 13 taken 1 time.
✓ Branch 21 → 7 taken 1 time.
✓ Branch 21 → 22 taken 1 time.
2 qCInfo(lcController) << "Discarding" << headers.size()
1054
2/2
✓ Branch 13 → 14 taken 1 time.
✓ Branch 14 → 15 taken 1 time.
1 << "stale headers (gen" << m_folder.activeFolderGen
1055
3/3
✓ Branch 15 → 16 taken 1 time.
✓ Branch 16 → 17 taken 1 time.
✓ Branch 17 → 18 taken 1 time.
1 << "vs" << m_folder.folderGeneration << ")";
1056 2 return;
1057 }
1058
1059
5/6
✓ Branch 24 → 25 taken 17 times.
✗ Branch 24 → 27 not taken.
✓ Branch 26 → 27 taken 1 time.
✓ Branch 26 → 28 taken 16 times.
✓ Branch 29 → 30 taken 1 time.
✓ Branch 29 → 31 taken 16 times.
17 if (headers.isEmpty() || !m_headerCacheError.isEmpty())
1060 1 return;
1061
1062
7/11
✓ Branch 32 → 33 taken 12 times.
✓ Branch 32 → 37 taken 4 times.
✓ Branch 34 → 35 taken 12 times.
✗ Branch 35 → 36 not taken.
✓ Branch 35 → 37 taken 12 times.
✓ Branch 38 → 39 taken 12 times.
✓ Branch 38 → 40 taken 4 times.
✗ Branch 40 → 41 not taken.
✓ Branch 40 → 42 taken 16 times.
✗ Branch 102 → 103 not taken.
✗ Branch 102 → 104 not taken.
16 if (!m_headerStream && !beginHeaderStream()) return;
1063
1/1
✓ Branch 42 → 43 taken 16 times.
16 auto *writer = MailBodyWriteService::shared();
1064 // A FETCH queued behind other commands can deliver into a stream another
1065 // FETCH opened after this one was requested (its own stream was completed
1066 // meanwhile). The batch belongs to the selected folder of this generation:
1067 // widen the open stream by its UIDs instead of failing the folder sync.
1068 16 QList<qint64> batchUids;
1069
1/1
✓ Branch 44 → 45 taken 16 times.
16 batchUids.reserve(headers.size());
1070
3/3
✓ Branch 48 → 49 taken 26 times.
✓ Branch 51 → 47 taken 26 times.
✓ Branch 51 → 52 taken 16 times.
42 for (const auto &header : headers) batchUids.append(header.uid);
1071
1/1
✓ Branch 53 → 54 taken 16 times.
16 writer->widenHeaderStream(m_headerStream, headerStreamIdentity(), batchUids);
1072 // A mutation already pending when this FETCH began still owns its flags.
1073
2/2
✓ Branch 72 → 57 taken 26 times.
✓ Branch 72 → 73 taken 16 times.
42 for (const auto &header : headers) {
1074
3/5
✓ Branch 60 → 61 taken 26 times.
✓ Branch 62 → 63 taken 1 time.
✓ Branch 62 → 70 taken 25 times.
✗ Branch 112 → 113 not taken.
✗ Branch 112 → 114 not taken.
26 if (m_mutations.intents().hasPendingFlag(MailId{m_folder.accountId, m_folder.currentFolderId, header.uid})) {
1075
2/3
✓ Branch 63 → 64 taken 1 time.
✓ Branch 65 → 66 taken 1 time.
✗ Branch 65 → 68 not taken.
1 if (const auto cached = m_cache->header(m_folder.currentFolderId, header.uid))
1076
1/1
✓ Branch 67 → 68 taken 1 time.
1 writer->preserveHeaderFlags(m_headerStream, header.uid, cached->flags);
1077 }
1078 }
1079
1/1
✓ Branch 73 → 74 taken 16 times.
16 auto &backgroundTracker = MailJD::Performance::BackgroundPerformanceTracker::instance();
1080
1/1
✓ Branch 77 → 78 taken 16 times.
16 backgroundTracker.backgroundPhaseStarted(QStringLiteral("header_cache_model"));
1081
2/2
✓ Branch 80 → 81 taken 16 times.
✓ Branch 81 → 82 taken 16 times.
16 MailJD::Performance::FolderPerformanceTracker::instance().cacheIndexStarted(m_folder.folderGeneration);
1082 16 const auto generation = m_headerWriteGeneration;
1083 16 const auto folderGeneration = m_folder.folderGeneration;
1084 16 const auto stream = m_headerStream;
1085 16 ++m_pendingHeaderWrites;
1086
2/2
✓ Branch 84 → 85 taken 16 times.
✓ Branch 87 → 88 taken 16 times.
16 writer->writeHeaders(this, headers, stream,
1087 32 [this, generation, folderGeneration, stream](MailHeaderWriteResult result) {
1088
3/4
✓ Branch 2 → 3 taken 15 times.
✓ Branch 2 → 4 taken 1 time.
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 15 times.
16 if (generation != m_headerWriteGeneration || folderGeneration != m_folder.folderGeneration) return;
1089 15 --m_pendingHeaderWrites;
1090
1/1
✓ Branch 5 → 6 taken 15 times.
15 QPointer<MailController> self(this);
1091
1/2
✓ Branch 7 → 8 taken 15 times.
✗ Branch 7 → 18 not taken.
15 if (m_headerCacheError.isEmpty()) {
1092
3/3
✓ Branch 8 → 9 taken 3 times.
✓ Branch 8 → 10 taken 12 times.
✓ Branch 9 → 18 taken 3 times.
15 if (!result.stored) failHeaderWrite(result.error);
1093
2/4
✓ Branch 11 → 12 taken 12 times.
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 12 times.
✗ Branch 14 → 16 not taken.
12 else if (const auto error = stream->apply(result.headers); !error.isEmpty()) failHeaderWrite(error);
1094 12 else publishStoredHeaders(result.headers, result.projectionChanged,
1095
1/1
✓ Branch 15 → 16 taken 12 times.
12 result.totalCount, result.pendingMoveUids,
1096 12 result.uidValidity);
1097 }
1098
6/8
✓ Branch 19 → 20 taken 15 times.
✗ Branch 19 → 23 not taken.
✓ Branch 20 → 21 taken 15 times.
✗ Branch 20 → 23 not taken.
✓ Branch 21 → 22 taken 3 times.
✓ Branch 21 → 23 taken 12 times.
✓ Branch 24 → 25 taken 3 times.
✓ Branch 24 → 26 taken 12 times.
15 if (self && m_pendingHeaderWrites == 0 && m_headerFetchCompletedPending) {
1099 3 m_headerFetchCompletedPending = false;
1100
1/1
✓ Branch 25 → 26 taken 3 times.
3 onHeaderFetchComplete();
1101 }
1102
6/8
✓ Branch 27 → 28 taken 15 times.
✗ Branch 27 → 32 not taken.
✓ Branch 28 → 29 taken 15 times.
✗ Branch 28 → 32 not taken.
✓ Branch 30 → 31 taken 2 times.
✓ Branch 30 → 32 taken 13 times.
✓ Branch 33 → 34 taken 2 times.
✓ Branch 33 → 41 taken 13 times.
15 if (self && m_pendingHeaderWrites == 0 && m_deferredHeaderFlags) {
1103 2 const auto flags = std::move(*m_deferredHeaderFlags);
1104 2 m_deferredHeaderFlags.reset();
1105
1/1
✓ Branch 38 → 39 taken 2 times.
2 m_flagSync.onFlagsReceived(flags);
1106 2 }
1107 15 });
1108 16 }
1109
1110 2863 void MailController::resetHeaderWrites() {
1111 2863 cancelStreamCounts();
1112 2863 ++m_headerWriteGeneration;
1113
2/2
✓ Branch 4 → 5 taken 446 times.
✓ Branch 4 → 7 taken 2417 times.
2863 if (m_headerStream) m_headerStream->cancel();
1114 2863 m_headerStream.reset();
1115 2863 m_pendingHeaderWrites = 0;
1116 2863 m_headerFetchCompletedPending = false;
1117 2863 m_deferredHeaderFlags.reset();
1118 2863 m_headerCacheError.clear();
1119 2863 }
1120
1121 2871 void MailController::cancelStreamCounts() {
1122 2871 ++m_streamCountGeneration;
1123 2871 m_streamCountTimer.stop();
1124 2871 m_streamCountsDirty = false;
1125 2871 m_streamCountsPending = false;
1126 2871 m_finalHeaderCountPending = false;
1127 2871 MailDisplayReadService::shared()->cancel(&m_streamCountReadOwner);
1128 2871 MailDisplayReadService::shared()->cancel(&m_folderStatusReadOwner);
1129 2871 }
1130
1131 12 void MailController::scheduleStreamCounts() {
1132 12 m_streamCountsDirty = true;
1133
3/6
✓ Branch 2 → 3 taken 12 times.
✗ Branch 2 → 5 not taken.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 12 times.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 12 times.
12 if (m_streamCountsPending || m_streamCountTimer.isActive()) return;
1134
1/2
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 12 times.
12 const qint64 elapsed = m_headerStatusTimer.isValid() ? m_headerStatusTimer.elapsed() : 100;
1135
1/2
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 12 times.
12 const int delay = elapsed >= 100 ? 0 : int(100 - elapsed);
1136 12 m_streamCountTimer.start(delay);
1137 }
1138
1139 10 void MailController::readStreamCounts() {
1140
1/2
✓ Branch 3 → 4 taken 10 times.
✗ Branch 3 → 7 not taken.
10 if (!m_streamCountsDirty || m_streamCountsPending ||
1141
4/8
✓ Branch 2 → 3 taken 10 times.
✗ Branch 2 → 7 not taken.
✓ Branch 5 → 6 taken 10 times.
✗ Branch 5 → 7 not taken.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 10 times.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 10 times.
20 !m_headerStream || m_folder.currentFolderId <= 0) return;
1142 10 m_streamCountsDirty = false;
1143 10 m_streamCountsPending = true;
1144 10 const auto generation = m_streamCountGeneration;
1145 10 const auto folderGeneration = m_folder.folderGeneration;
1146 10 const qint64 folderId = m_folder.currentFolderId;
1147 10 const QString folder = m_folder.currentFolder;
1148 10 MailHeaderPageRequest request{m_folder.accountId, folder, folderId, m_headerStreamUidValidity};
1149 10 request.limit = 0;
1150 10 request.includeCounts = true;
1151 10 request.countsOnly = true;
1152
1/1
✓ Branch 16 → 17 taken 10 times.
10 const QPointer<MailController> self(this);
1153
2/2
✓ Branch 17 → 18 taken 10 times.
✓ Branch 23 → 24 taken 10 times.
40 MailDisplayReadService::shared()->readHeaderPage(&m_streamCountReadOwner,
1154 20 m_cache->databasePath(), request,
1155
1/3
✓ Branch 20 → 21 taken 10 times.
✗ Branch 43 → 44 not taken.
✗ Branch 43 → 45 not taken.
20 [self, generation, folderGeneration, folderId, folder](MailHeaderPageSnapshot snapshot) {
1156
1/2
✓ Branch 5 → 6 taken 10 times.
✗ Branch 5 → 10 not taken.
20 if (!self || generation != self->m_streamCountGeneration ||
1157
3/6
✓ Branch 3 → 4 taken 10 times.
✗ Branch 3 → 10 not taken.
✓ Branch 7 → 8 taken 10 times.
✗ Branch 7 → 10 not taken.
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 10 times.
30 folderGeneration != self->m_folder.folderGeneration ||
1158
1/2
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 10 times.
10 folderId != self->m_folder.currentFolderId) return;
1159 10 self->m_streamCountsPending = false;
1160
1/2
✓ Branch 15 → 16 taken 10 times.
✗ Branch 15 → 51 not taken.
10 if (snapshot.outcome == MailHeaderPageSnapshot::Outcome::Ready &&
1161
2/4
✓ Branch 16 → 17 taken 10 times.
✗ Branch 16 → 51 not taken.
✓ Branch 17 → 18 taken 10 times.
✗ Branch 17 → 51 not taken.
10 snapshot.totalCount >= 0 && snapshot.unreadCount >= 0) {
1162
2/2
✓ Branch 19 → 20 taken 7 times.
✓ Branch 19 → 22 taken 3 times.
10 if (self->m_folderPresentationEnabled)
1163
1/1
✓ Branch 21 → 22 taken 7 times.
7 self->m_model->setAuthoritativeCounts(snapshot.totalCount, snapshot.unreadCount);
1164
1/1
✓ Branch 24 → 25 taken 10 times.
10 const int polledCount = self->m_mutations.intents().lastPolledUnread(folder, 0);
1165
1/1
✓ Branch 27 → 28 taken 10 times.
10 emit self->unreadCountChanged(folder, qMax(snapshot.unreadCount, polledCount));
1166
3/6
✓ Branch 29 → 30 taken 10 times.
✗ Branch 29 → 34 not taken.
✓ Branch 31 → 32 taken 10 times.
✗ Branch 31 → 34 not taken.
✗ Branch 36 → 37 not taken.
✓ Branch 36 → 38 taken 10 times.
20 if (!self || generation != self->m_streamCountGeneration ||
1167
1/2
✗ Branch 33 → 34 not taken.
✓ Branch 33 → 35 taken 10 times.
10 folderGeneration != self->m_folder.folderGeneration) return;
1168 10 self->m_headerStatusTimer.restart();
1169
1/1
✓ Branch 46 → 47 taken 10 times.
20 emit self->backgroundStatusMessage(
1170
3/3
✓ Branch 41 → 42 taken 10 times.
✓ Branch 43 → 44 taken 10 times.
✓ Branch 45 → 46 taken 10 times.
40 QString("%1 – %2 mails").arg(folder).arg(snapshot.totalCount));
1171 10 } else {
1172 // A busy page lane or transient read failure retries the latest count
1173 // without blocking the GUI or discarding a later committed batch.
1174 ✗ self->m_streamCountsDirty = true;
1175 ✗ self->m_headerStatusTimer.restart(); // bounded retry, even before the first success
1176 }
1177
3/6
✓ Branch 55 → 56 taken 10 times.
✗ Branch 55 → 60 not taken.
✓ Branch 57 → 58 taken 10 times.
✗ Branch 57 → 60 not taken.
✗ Branch 62 → 63 not taken.
✓ Branch 62 → 64 taken 10 times.
20 if (!self || generation != self->m_streamCountGeneration ||
1178
1/2
✗ Branch 59 → 60 not taken.
✓ Branch 59 → 61 taken 10 times.
10 folderGeneration != self->m_folder.folderGeneration) return;
1179
1/2
✗ Branch 65 → 66 not taken.
✓ Branch 65 → 68 taken 10 times.
10 if (self->m_streamCountsDirty) self->scheduleStreamCounts();
1180 }, MailDisplayReadService::PagePriority::Background);
1181 10 }
1182
1183 18 void MailController::requestFolderCountStatus() {
1184
3/4
✓ Branch 4 → 5 taken 18 times.
✗ Branch 4 → 8 not taken.
✓ Branch 6 → 7 taken 16 times.
✓ Branch 6 → 8 taken 2 times.
36 if (m_folder.currentFolderId <= 0 || m_folder.currentFolder.isEmpty() ||
1185
5/6
✓ Branch 2 → 3 taken 18 times.
✗ Branch 2 → 8 not taken.
✓ Branch 7 → 8 taken 1 time.
✓ Branch 7 → 9 taken 15 times.
✓ Branch 10 → 11 taken 3 times.
✓ Branch 10 → 12 taken 15 times.
54 m_headerStream || m_finalHeaderCountPending) return;
1186 15 const auto countGeneration = m_streamCountGeneration;
1187 15 const auto folderGeneration = m_folder.folderGeneration;
1188 15 const qint64 folderId = m_folder.currentFolderId;
1189 15 const QString folder = m_folder.currentFolder;
1190 15 MailHeaderPageRequest request{m_folder.accountId, folder, folderId, m_folder.activeFolderUidValidity};
1191 15 request.limit = 0;
1192 15 request.includeCounts = true;
1193 15 request.countsOnly = true;
1194
1/1
✓ Branch 17 → 18 taken 15 times.
15 const QPointer<MailController> self(this);
1195 // A newer status replaces an older one without disturbing the exact final
1196 // count request or the stream count lane's in-flight bookkeeping.
1197
2/2
✓ Branch 18 → 19 taken 15 times.
✓ Branch 24 → 25 taken 15 times.
60 MailDisplayReadService::shared()->readHeaderPage(&m_folderStatusReadOwner,
1198 30 m_cache->databasePath(), request,
1199
1/3
✓ Branch 21 → 22 taken 15 times.
✗ Branch 44 → 45 not taken.
✗ Branch 44 → 46 not taken.
30 [self, countGeneration, folderGeneration, folderId, folder]
1200 (MailHeaderPageSnapshot snapshot) {
1201
1/2
✓ Branch 5 → 6 taken 4 times.
✗ Branch 5 → 15 not taken.
8 if (!self || countGeneration != self->m_streamCountGeneration ||
1202
1/2
✓ Branch 7 → 8 taken 4 times.
✗ Branch 7 → 15 not taken.
4 folderGeneration != self->m_folder.folderGeneration ||
1203
2/4
✓ Branch 9 → 10 taken 4 times.
✗ Branch 9 → 15 not taken.
✓ Branch 12 → 13 taken 4 times.
✗ Branch 12 → 15 not taken.
4 folderId != self->m_folder.currentFolderId || folder != self->m_folder.currentFolder ||
1204
3/6
✓ Branch 3 → 4 taken 4 times.
✗ Branch 3 → 15 not taken.
✓ Branch 13 → 14 taken 4 times.
✗ Branch 13 → 15 not taken.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 4 times.
12 snapshot.outcome != MailHeaderPageSnapshot::Outcome::Ready ||
1205
1/2
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 4 times.
4 snapshot.totalCount < 0) return;
1206
1/1
✓ Branch 25 → 26 taken 4 times.
8 emit self->backgroundStatusMessage(
1207
3/3
✓ Branch 20 → 21 taken 4 times.
✓ Branch 22 → 23 taken 4 times.
✓ Branch 24 → 25 taken 4 times.
16 QString("%1 – %2 mails").arg(folder).arg(snapshot.totalCount));
1208 // The settled cache count covers mail outside the loaded rows (e.g. read
1209 // on another client and applied by the flag sync). Visual Seen intents
1210 // that are not durable yet keep the model's count until they settle.
1211 4 bool seenPending = false;
1212
1/2
✗ Branch 40 → 31 not taken.
✓ Branch 40 → 41 taken 4 times.
4 for (auto it = self->m_seenIntentGenerations.cbegin(); it != self->m_seenIntentGenerations.cend(); ++it)
1213 ✗ seenPending = seenPending || it.key().folderId == folderId;
1214
4/8
✓ Branch 41 → 42 taken 4 times.
✗ Branch 41 → 47 not taken.
✓ Branch 42 → 43 taken 4 times.
✗ Branch 42 → 47 not taken.
✓ Branch 45 → 46 taken 4 times.
✗ Branch 45 → 47 not taken.
✓ Branch 48 → 49 taken 4 times.
✗ Branch 48 → 53 not taken.
4 if (snapshot.unreadCount >= 0 && !seenPending && self->m_model->unreadCount() != snapshot.unreadCount) {
1215 4 self->m_model->setAuthoritativeUnread(snapshot.unreadCount);
1216 4 emit self->unreadCountChanged(folder, snapshot.unreadCount);
1217 }
1218 }, MailDisplayReadService::PagePriority::Background);
1219 15 }
1220
1221 569 MailHeaderStreamIdentity MailController::headerStreamIdentity() const {
1222 1707 return {m_cache->databasePath(), m_folder.accountId, m_folder.currentFolder,
1223 1138 m_folder.currentFolderId, m_folder.activeFolderUidValidity};
1224 569 }
1225
1226 553 bool MailController::beginHeaderStream(const QList<qint64> &uids) {
1227
2/2
✓ Branch 2 → 3 taken 553 times.
✓ Branch 3 → 4 taken 553 times.
553 MailDisplayReadService::shared()->cancel(&m_folderStatusReadOwner);
1228 // The selected IMAP generation already supplied this epoch. The worker's
1229 // storeStreamingHeaders transaction verifies it against the cache before
1230 // accepting a batch, so the GUI need not query it for every reverse chunk.
1231 553 const quint32 uidValidity = m_folder.activeFolderUidValidity;
1232 // An IDLE delta FETCH and a backfill chunk can overlap (the second is
1233 // queued behind the first): keep the open stream of the same folder and
1234 // epoch and widen it, so the first FETCH's headers are still accepted.
1235 553 const MailHeaderStreamIdentity identity = headerStreamIdentity();
1236
4/4
✓ Branch 6 → 7 taken 103 times.
✓ Branch 6 → 11 taken 450 times.
✓ Branch 12 → 13 taken 103 times.
✓ Branch 12 → 28 taken 450 times.
656 if (m_headerStream &&
1237
3/4
✓ Branch 7 → 8 taken 103 times.
✓ Branch 8 → 9 taken 103 times.
✓ Branch 9 → 10 taken 103 times.
✗ Branch 9 → 11 not taken.
656 MailBodyWriteService::shared()->widenHeaderStream(m_headerStream, identity, uids)) {
1238
6/6
✓ Branch 13 → 14 taken 103 times.
✓ Branch 17 → 18 taken 103 times.
✓ Branch 18 → 19 taken 103 times.
✓ Branch 20 → 21 taken 103 times.
✓ Branch 26 → 15 taken 103 times.
✓ Branch 26 → 27 taken 103 times.
206 qCInfo(lcController) << "Header stream widened by" << uids.size()
1239
2/2
✓ Branch 21 → 22 taken 103 times.
✓ Branch 22 → 23 taken 103 times.
103 << "UIDs (0 = range) for" << m_folder.currentFolder;
1240 103 return true;
1241 }
1242
6/6
✓ Branch 28 → 29 taken 450 times.
✓ Branch 32 → 33 taken 450 times.
✓ Branch 33 → 34 taken 450 times.
✓ Branch 35 → 36 taken 450 times.
✓ Branch 46 → 30 taken 450 times.
✓ Branch 46 → 47 taken 450 times.
900 qCInfo(lcController) << "Header stream begun with" << uids.size()
1243
2/2
✓ Branch 36 → 37 taken 450 times.
✓ Branch 37 → 38 taken 450 times.
450 << "UIDs (0 = range) for" << m_folder.currentFolder
1244
2/3
✗ Branch 39 → 40 not taken.
✓ Branch 39 → 41 taken 450 times.
✓ Branch 42 → 43 taken 450 times.
450 << (m_headerStream ? "replacing an inactive stream" : "");
1245
2/2
✓ Branch 47 → 48 taken 450 times.
✓ Branch 48 → 49 taken 450 times.
450 m_headerStream = MailBodyWriteService::shared()->beginHeaderStream(this, identity, uids);
1246
1/2
✓ Branch 52 → 53 taken 450 times.
✗ Branch 52 → 55 not taken.
450 if (m_headerStream) {
1247 450 m_headerStreamUidValidity = uidValidity;
1248 450 m_headerStatusTimer.invalidate();
1249 450 return true;
1250 }
1251 ✗ failHeaderWrite(QStringLiteral("Header stream state is busy or exceeds its budget"));
1252 ✗ return false;
1253 553 }
1254
1255 3 void MailController::failHeaderWrite(const QString &error) {
1256 3 cancelStreamCounts();
1257
1/3
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 3 times.
✗ Branch 5 → 7 not taken.
3 m_headerCacheError = error.isEmpty() ? tr("Header cache write failed") : error;
1258 3 m_reverseChunks.clear();
1259 3 m_pendingHeaderFetch = false;
1260 3 auto &backgroundTracker = MailJD::Performance::BackgroundPerformanceTracker::instance();
1261
1/1
✓ Branch 14 → 15 taken 3 times.
3 backgroundTracker.backgroundPhaseFinished(QStringLiteral("header_cache_model"), 0, 0);
1262 3 backgroundTracker.backgroundFinished(false);
1263 3 MailJD::Performance::FolderPerformanceTracker::instance().cacheIndexFinished(m_folder.folderGeneration, false);
1264
6/6
✓ Branch 20 → 21 taken 3 times.
✓ Branch 24 → 25 taken 3 times.
✓ Branch 25 → 26 taken 3 times.
✓ Branch 26 → 27 taken 3 times.
✓ Branch 30 → 22 taken 3 times.
✓ Branch 30 → 31 taken 3 times.
6 qCWarning(lcController) << "Header persistence or publication failed:" << m_headerCacheError;
1265
3/3
✓ Branch 31 → 32 taken 3 times.
✓ Branch 33 → 34 taken 3 times.
✓ Branch 34 → 35 taken 3 times.
6 emit statusMessage(tr("Folder sync incomplete: %1").arg(m_headerCacheError));
1266 3 }
1267
1268 12 void MailController::publishStoredHeaders(const QList<MailHeader> &headers,
1269 bool projectionChanged, int totalCount,
1270 const QSet<qint64> &pendingMoveUids,
1271 quint32 uidValidity) {
1272
1/1
✓ Branch 2 → 3 taken 12 times.
12 auto &backgroundTracker = MailJD::Performance::BackgroundPerformanceTracker::instance();
1273
1/1
✓ Branch 3 → 4 taken 12 times.
12 QPointer<MailController> self(this);
1274 12 const quint64 folderGeneration = m_folder.folderGeneration;
1275 12 const qint64 folderId = m_folder.currentFolderId;
1276 // T-176: Notify listeners (e.g. FolderPredictor) about stored headers
1277
1/1
✓ Branch 4 → 5 taken 12 times.
12 emit headersStored(m_folder.currentFolder, folderId, uidValidity, headers);
1278
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 12 times.
12 if (!self) return;
1279 // S105.2.5: the displayed mail's spam fields arrived after it was shown.
1280 12 const MailId shown = m_bodies.requests().selectedMailId();
1281
2/8
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 15 taken 12 times.
✗ Branch 12 → 13 not taken.
✗ Branch 12 → 15 not taken.
✗ Branch 13 → 14 not taken.
✗ Branch 13 → 15 not taken.
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 27 taken 12 times.
12 if (m_view && shown.isValid() && shown.folderId == folderId)
1282 ✗ for (const auto &header : headers)
1283 ✗ if (header.uid == shown.uid && header.spamKnown) m_view->presentSpamVerdict(shown, header);
1284
4/8
✓ Branch 28 → 29 taken 12 times.
✗ Branch 28 → 31 not taken.
✓ Branch 29 → 30 taken 12 times.
✗ Branch 29 → 31 not taken.
✗ Branch 30 → 31 not taken.
✓ Branch 30 → 32 taken 12 times.
✗ Branch 33 → 34 not taken.
✓ Branch 33 → 35 taken 12 times.
12 if (!self || folderGeneration != m_folder.folderGeneration || folderId != m_folder.currentFolderId) return;
1285
2/2
✓ Branch 35 → 36 taken 10 times.
✓ Branch 35 → 37 taken 2 times.
12 if (projectionChanged)
1286
1/1
✓ Branch 36 → 37 taken 10 times.
10 emit headerProjectionAdvanced(m_folder.currentFolder, m_folder.currentFolderId, totalCount);
1287
4/8
✓ Branch 38 → 39 taken 12 times.
✗ Branch 38 → 41 not taken.
✓ Branch 39 → 40 taken 12 times.
✗ Branch 39 → 41 not taken.
✗ Branch 40 → 41 not taken.
✓ Branch 40 → 42 taken 12 times.
✗ Branch 43 → 44 not taken.
✓ Branch 43 → 45 taken 12 times.
12 if (!self || folderGeneration != m_folder.folderGeneration || folderId != m_folder.currentFolderId) return;
1288
1289 // The indexed Quick page reads the canonical cache directly. In that mode
1290 // the flat page is hidden, so streaming a million headers into it would
1291 // spend GUI time and memory without presenting a single additional row.
1292 12 QList<MailHeader> newOnly;
1293
3/4
✓ Branch 45 → 46 taken 9 times.
✓ Branch 45 → 68 taken 3 times.
✓ Branch 46 → 47 taken 9 times.
✗ Branch 46 → 68 not taken.
12 if (m_folderPresentationEnabled && m_streamedHeadersVisible) {
1294 // P90.16: journaled-away mails remain canonical until remote confirmation.
1295 // The writer read the durable journal after its header commit. Also check
1296 // local intents started after that read and before this GUI callback.
1297
2/2
✓ Branch 66 → 49 taken 14 times.
✓ Branch 66 → 67 taken 9 times.
23 for (const auto &h : headers) {
1298 52 if (m_model->rowForUid(h.uid, m_folder.currentFolderId) < 0 &&
1299
4/5
✓ Branch 50 → 51 taken 14 times.
✓ Branch 51 → 52 taken 10 times.
✓ Branch 51 → 59 taken 4 times.
✓ Branch 53 → 54 taken 10 times.
✗ Branch 53 → 59 not taken.
24 !pendingMoveUids.contains(h.uid) &&
1300
6/13
✓ Branch 56 → 57 taken 10 times.
✓ Branch 57 → 58 taken 10 times.
✗ Branch 57 → 59 not taken.
✓ Branch 60 → 61 taken 10 times.
✓ Branch 60 → 62 taken 4 times.
✓ Branch 62 → 63 taken 10 times.
✓ Branch 62 → 64 taken 4 times.
✗ Branch 136 → 137 not taken.
✗ Branch 136 → 138 not taken.
✗ Branch 140 → 141 not taken.
✗ Branch 140 → 143 not taken.
✗ Branch 141 → 142 not taken.
✗ Branch 141 → 143 not taken.
24 !m_mutations.intents().hasPendingMove(MailId{m_folder.accountId, m_folder.currentFolderId, h.uid}))
1301
1/1
✓ Branch 63 → 64 taken 10 times.
10 newOnly.append(h);
1302 }
1303
1/4
✗ Branch 68 → 69 not taken.
✓ Branch 68 → 71 taken 3 times.
✗ Branch 69 → 70 not taken.
✗ Branch 69 → 71 not taken.
12 } else if (m_folderPresentationEnabled && projectionChanged) {
1304 ✗ m_flatPageNeedsReload = true;
1305 }
1306
3/4
✓ Branch 71 → 72 taken 10 times.
✓ Branch 71 → 74 taken 2 times.
✗ Branch 72 → 73 not taken.
✓ Branch 72 → 74 taken 10 times.
12 if (projectionChanged && m_flatPageRefreshInFlight) ++m_flatPageRefreshVersion;
1307 12 int visibleHeaders = 0;
1308
2/2
✓ Branch 75 → 76 taken 7 times.
✓ Branch 75 → 88 taken 5 times.
12 if (!newOnly.isEmpty()) {
1309 // T-548: IMAP-parsed headers have folderId=0 (parser doesn't know the folder).
1310 // Set the correct folderId so appendHeaders indexes them under the right
1311 // composite key MailKey{folderId, uid}. Without this, rowForUid() always
1312 // returns -1 for streaming headers, breaking flag updates and mark-as-seen.
1313
4/4
✓ Branch 76 → 77 taken 7 times.
✓ Branch 77 → 78 taken 7 times.
✓ Branch 82 → 79 taken 10 times.
✓ Branch 82 → 83 taken 7 times.
17 for (auto &h : newOnly) {
1314 10 h.folderId = m_folder.currentFolderId;
1315 }
1316
1/1
✓ Branch 83 → 84 taken 7 times.
7 visibleHeaders = m_model->appendStreamedHeaders(newOnly);
1317
1/2
✗ Branch 85 → 86 not taken.
✓ Branch 85 → 88 taken 7 times.
7 if (visibleHeaders < newOnly.size()) {
1318 ✗ m_flatPageNeedsReload = true;
1319 ✗ if (m_flatPageRefreshInFlight) ++m_flatPageRefreshVersion;
1320 }
1321 }
1322 // Coalesce worker count snapshots. The next batch can commit while this
1323 // callback runs; no SQLite statement is needed on the GUI hot path.
1324
1/1
✓ Branch 88 → 89 taken 12 times.
12 scheduleStreamCounts();
1325
1/1
✓ Branch 89 → 90 taken 12 times.
12 MailJD::Performance::BackgroundPerformanceTracker::instance()
1326
1/1
✓ Branch 91 → 92 taken 12 times.
12 .headersCommitted(headers.size(), visibleHeaders);
1327 12 qint64 headerBytes = 0;
1328
2/2
✓ Branch 98 → 94 taken 20 times.
✓ Branch 98 → 99 taken 12 times.
32 for (const auto &header : headers)
1329 20 headerBytes += std::max<qint64>(0, header.size);
1330
1/1
✓ Branch 100 → 101 taken 12 times.
12 backgroundTracker.addBackgroundWork(headers.size(), headerBytes);
1331
7/7
✓ Branch 101 → 102 taken 12 times.
✓ Branch 105 → 106 taken 12 times.
✓ Branch 106 → 107 taken 12 times.
✓ Branch 108 → 109 taken 12 times.
✓ Branch 109 → 110 taken 12 times.
✓ Branch 113 → 103 taken 12 times.
✓ Branch 113 → 114 taken 12 times.
24 qCDebug(lcController) << "Streaming:" << headers.size() << "headers received";
1332
1/1
✓ Branch 118 → 119 taken 12 times.
24 backgroundTracker.backgroundPhaseFinished(
1333 24 QStringLiteral("header_cache_model"), headers.size(),
1334 headerBytes);
1335
2/2
✓ Branch 121 → 122 taken 12 times.
✓ Branch 122 → 123 taken 12 times.
12 MailJD::Performance::FolderPerformanceTracker::instance().cacheIndexFinished(
1336 m_folder.folderGeneration);
1337 12 }
1338
1339 9 void MailController::onHeaderFetchComplete() {
1340 // T-113: Discard if folder changed
1341
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 16 taken 8 times.
9 if (m_folder.activeFolderGen != m_folder.folderGeneration) {
1342 1 MailJD::Performance::FolderPerformanceTracker::instance()
1343 1 .staleGenerationDiscarded(m_folder.activeFolderGen);
1344
5/5
✓ Branch 5 → 6 taken 1 time.
✓ Branch 9 → 10 taken 1 time.
✓ Branch 10 → 11 taken 1 time.
✓ Branch 14 → 7 taken 1 time.
✓ Branch 14 → 15 taken 1 time.
2 qCInfo(lcController) << "Discarding stale headerFetchComplete";
1345 // The current generation may already own a new reverse-chunk queue.
1346 1 return;
1347 }
1348
1349
2/2
✓ Branch 16 → 17 taken 3 times.
✓ Branch 16 → 18 taken 5 times.
8 if (m_pendingHeaderWrites > 0) {
1350 3 m_headerFetchCompletedPending = true;
1351 3 return;
1352 }
1353
1354
1/2
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 5 times.
5 if (m_finalHeaderCountPending) return;
1355
1356 5 m_headerStream.reset();
1357
1358
2/2
✓ Branch 22 → 23 taken 1 time.
✓ Branch 22 → 34 taken 4 times.
5 if (!m_headerCacheError.isEmpty()) {
1359 1 cancelStreamCounts();
1360 1 m_reverseChunks.clear();
1361 1 m_fetchInProgress = false;
1362 1 m_pendingHeaderFetch = false;
1363 // Keep the connection usable, but do not publish a successful header
1364 // boundary or replace the server badge with incomplete cached counts.
1365 1 m_imap->fetchFlags();
1366
3/3
✓ Branch 26 → 27 taken 1 time.
✓ Branch 28 → 29 taken 1 time.
✓ Branch 29 → 30 taken 1 time.
2 emit statusMessage(tr("Folder sync incomplete: %1").arg(m_headerCacheError));
1367 1 refreshFlatPageIfNeeded();
1368 1 return;
1369 }
1370
1371 // All header batches delivered
1372
6/6
✓ Branch 34 → 35 taken 4 times.
✓ Branch 38 → 39 taken 4 times.
✓ Branch 39 → 40 taken 4 times.
✓ Branch 40 → 41 taken 4 times.
✓ Branch 44 → 36 taken 4 times.
✓ Branch 44 → 45 taken 4 times.
8 qCInfo(lcController) << "Header fetch complete for" << m_folder.currentFolder;
1373
1374 // T-061: If more reverse chunks remain, fetch next chunk directly
1375
1/2
✗ Branch 46 → 47 not taken.
✓ Branch 46 → 49 taken 4 times.
4 if (!m_reverseChunks.isEmpty()) {
1376 // Yield to event loop so UI can repaint between chunks
1377 ✗ QTimer::singleShot(0, this, &MailController::fetchNextChunk);
1378 ✗ return;
1379 }
1380
1381 4 cancelStreamCounts();
1382
1383 // Read the exact final count on the read-only lane. The status/badge and
1384 // final FETCH FLAGS boundary wait for that snapshot, not for GUI SQLite.
1385 4 m_fetchInProgress = false;
1386 4 m_finalHeaderCountPending = true;
1387 4 requestFinalHeaderCounts(m_folder.folderGeneration, m_folder.currentFolderId, m_folder.currentFolder);
1388 4 refreshFlatPageIfNeeded();
1389 }
1390
1391 4 void MailController::requestFinalHeaderCounts(quint64 folderGeneration,
1392 qint64 folderId,
1393 const QString &folder,
1394 int attempt) {
1395
1/2
✓ Branch 3 → 4 taken 4 times.
✗ Branch 3 → 7 not taken.
4 if (!m_finalHeaderCountPending || folderGeneration != m_folder.folderGeneration ||
1396
4/8
✓ Branch 2 → 3 taken 4 times.
✗ Branch 2 → 7 not taken.
✓ Branch 4 → 5 taken 4 times.
✗ Branch 4 → 7 not taken.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 4 times.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 4 times.
8 folderId != m_folder.currentFolderId || folder != m_folder.currentFolder) return;
1397 4 const auto countGeneration = m_streamCountGeneration;
1398 4 MailHeaderPageRequest request{m_folder.accountId, folder, folderId, m_folder.activeFolderUidValidity};
1399 4 request.limit = 0;
1400 4 request.includeCounts = true;
1401 4 request.countsOnly = true;
1402
1/1
✓ Branch 15 → 16 taken 4 times.
4 const QPointer<MailController> self(this);
1403
2/2
✓ Branch 16 → 17 taken 4 times.
✓ Branch 22 → 23 taken 4 times.
16 MailDisplayReadService::shared()->readHeaderPage(&m_streamCountReadOwner,
1404 8 m_cache->databasePath(), request,
1405
1/5
✓ Branch 19 → 20 taken 4 times.
✗ Branch 41 → 42 not taken.
✗ Branch 41 → 43 not taken.
✗ Branch 44 → 45 not taken.
✗ Branch 44 → 46 not taken.
8 [self, countGeneration, folderGeneration, folderId, folder, attempt]
1406 (MailHeaderPageSnapshot snapshot) {
1407
1/2
✓ Branch 5 → 6 taken 4 times.
✗ Branch 5 → 15 not taken.
8 if (!self || countGeneration != self->m_streamCountGeneration ||
1408
1/2
✓ Branch 7 → 8 taken 4 times.
✗ Branch 7 → 15 not taken.
4 folderGeneration != self->m_folder.folderGeneration ||
1409
4/8
✓ Branch 3 → 4 taken 4 times.
✗ Branch 3 → 15 not taken.
✓ Branch 9 → 10 taken 4 times.
✗ Branch 9 → 15 not taken.
✓ Branch 12 → 13 taken 4 times.
✗ Branch 12 → 15 not taken.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 4 times.
12 folderId != self->m_folder.currentFolderId || folder != self->m_folder.currentFolder ||
1410
1/2
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 4 times.
4 !self->m_finalHeaderCountPending) return;
1411
1/2
✗ Branch 20 → 21 not taken.
✓ Branch 20 → 24 taken 4 times.
4 if (self->m_pendingHeaderWrites > 0) {
1412 ✗ self->m_finalHeaderCountPending = false;
1413 ✗ self->m_headerFetchCompletedPending = true;
1414 ✗ return;
1415 }
1416
1/4
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 32 taken 4 times.
✗ Branch 25 → 26 not taken.
✗ Branch 25 → 32 not taken.
4 if (snapshot.outcome == MailHeaderPageSnapshot::Outcome::Busy && attempt < 3) {
1417 ✗ QTimer::singleShot(50, self, [self, folderGeneration, folderId, folder, attempt] {
1418 ✗ if (self) self->requestFinalHeaderCounts(folderGeneration, folderId,
1419 ✗ folder, attempt + 1);
1420 ✗ });
1421 ✗ return;
1422 }
1423 4 int total = snapshot.totalCount;
1424 4 int unread = snapshot.unreadCount;
1425
2/4
✓ Branch 32 → 33 taken 4 times.
✗ Branch 32 → 35 not taken.
✓ Branch 33 → 34 taken 4 times.
✗ Branch 33 → 35 not taken.
4 if (snapshot.outcome != MailHeaderPageSnapshot::Outcome::Ready ||
1426
1/2
✗ Branch 34 → 35 not taken.
✓ Branch 34 → 51 taken 4 times.
4 total < 0 || unread < 0) {
1427 ✗ qCWarning(lcController) << "Final header count read failed for" << folder
1428 ✗ << ":" << snapshot.error;
1429 // Preserve the old completion contract if the read-only lane cannot
1430 // supply a result after bounded retries.
1431 ✗ const auto counts = self->m_cache->folderCounts(folderId);
1432 ✗ total = counts.first;
1433 ✗ unread = counts.second;
1434 }
1435 4 self->completeHeaderFetchCounts(folderGeneration, folderId, folder, total, unread);
1436 });
1437 4 }
1438
1439 4 void MailController::completeHeaderFetchCounts(quint64 folderGeneration,
1440 qint64 folderId,
1441 const QString &folder,
1442 int total, int unread) {
1443
3/6
✓ Branch 2 → 3 taken 4 times.
✗ Branch 2 → 6 not taken.
✓ Branch 3 → 4 taken 4 times.
✗ Branch 3 → 6 not taken.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 4 times.
8 if (folderGeneration != m_folder.folderGeneration || folderId != m_folder.currentFolderId ||
1444
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 4 times.
4 folder != m_folder.currentFolder) return;
1445
1/1
✓ Branch 10 → 11 taken 4 times.
4 const QPointer<MailController> self(this);
1446
1/2
✓ Branch 11 → 12 taken 4 times.
✗ Branch 11 → 13 not taken.
4 if (m_folderPresentationEnabled)
1447
1/1
✓ Branch 12 → 13 taken 4 times.
4 m_model->setAuthoritativeCounts(total, unread);
1448
4/8
✓ Branch 14 → 15 taken 4 times.
✗ Branch 14 → 19 not taken.
✓ Branch 15 → 16 taken 4 times.
✗ Branch 15 → 19 not taken.
✓ Branch 16 → 17 taken 4 times.
✗ Branch 16 → 19 not taken.
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 23 taken 4 times.
8 if (!self || folderGeneration != m_folder.folderGeneration || folderId != m_folder.currentFolderId ||
1449
1/2
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 4 times.
4 folder != m_folder.currentFolder) return;
1450
1/1
✓ Branch 24 → 25 taken 4 times.
4 m_mutations.intents().setLastPolledUnread(folder, unread);
1451
1/1
✓ Branch 25 → 26 taken 4 times.
4 emit unreadCountChanged(folder, unread);
1452
4/8
✓ Branch 27 → 28 taken 4 times.
✗ Branch 27 → 32 not taken.
✓ Branch 28 → 29 taken 4 times.
✗ Branch 28 → 32 not taken.
✓ Branch 29 → 30 taken 4 times.
✗ Branch 29 → 32 not taken.
✗ Branch 34 → 35 not taken.
✓ Branch 34 → 36 taken 4 times.
8 if (!self || folderGeneration != m_folder.folderGeneration || folderId != m_folder.currentFolderId ||
1453
1/2
✗ Branch 31 → 32 not taken.
✓ Branch 31 → 33 taken 4 times.
4 folder != m_folder.currentFolder) return;
1454
1455 // 67.A2: Signal the end of the session's first INBOX sync exactly once
1456 // (notification suppression window ends here).
1457
3/4
✓ Branch 36 → 37 taken 4 times.
✗ Branch 36 → 43 not taken.
✓ Branch 41 → 42 taken 3 times.
✓ Branch 41 → 43 taken 1 time.
8 if (!m_inboxFirstSyncSignaled &&
1458
4/6
✓ Branch 44 → 45 taken 4 times.
✗ Branch 44 → 46 not taken.
✓ Branch 46 → 47 taken 4 times.
✗ Branch 46 → 48 not taken.
✓ Branch 48 → 49 taken 3 times.
✓ Branch 48 → 50 taken 1 time.
8 folder == QStringLiteral("INBOX")) {
1459 3 m_inboxFirstSyncSignaled = true;
1460
1/1
✓ Branch 49 → 50 taken 3 times.
3 emit inboxFirstSyncCompleted(m_inboxCacheWasEmpty);
1461 }
1462
4/8
✓ Branch 51 → 52 taken 4 times.
✗ Branch 51 → 56 not taken.
✓ Branch 52 → 53 taken 4 times.
✗ Branch 52 → 56 not taken.
✓ Branch 53 → 54 taken 4 times.
✗ Branch 53 → 56 not taken.
✗ Branch 58 → 59 not taken.
✓ Branch 58 → 60 taken 4 times.
8 if (!self || folderGeneration != m_folder.folderGeneration || folderId != m_folder.currentFolderId ||
1463
1/2
✗ Branch 55 → 56 not taken.
✓ Branch 55 → 57 taken 4 times.
4 folder != m_folder.currentFolder) return;
1464
1465 // All chunks done -> sync flags (post-header)
1466
1/1
✓ Branch 60 → 61 taken 4 times.
4 MailJD::Performance::FolderPerformanceTracker::instance()
1467
1/1
✓ Branch 61 → 62 taken 4 times.
4 .headerFetchFinished(m_folder.folderGeneration);
1468 4 m_finalHeaderCountPending = false;
1469 4 m_pendingHeaderFetch = false; // T-058: next flag sync = final, then IDLE
1470
2/2
✓ Branch 62 → 63 taken 4 times.
✓ Branch 63 → 64 taken 4 times.
4 MailJD::Performance::FolderPerformanceTracker::instance().flagsStarted(
1471 m_folder.folderGeneration);
1472
1/1
✓ Branch 64 → 65 taken 4 times.
4 m_imap->fetchFlags();
1473 4 }
1474
1475 // ═══════════════════════════════════════════════════════
1476 // Mail Body
1477 // ═══════════════════════════════════════════════════════
1478
1479 1742 void MailController::onMailSelected(const MailId &mailId) {
1480 1742 selectMailId(mailId, nullptr);
1481 1742 }
1482
1483 50 void MailController::onMailSelected(const MailId &mailId, const MailHeader &previewHint) {
1484 50 selectMailId(mailId, &previewHint);
1485 50 }
1486
1487 1792 void MailController::selectMailId(const MailId &mailId, const MailHeader *previewHint) {
1488 // Header batches and proxy re-sorts may re-emit the current row while a
1489 // folder is still streaming. The logical selection has not changed, so
1490 // re-running displayMailId() would bump the render generation and call
1491 // QWebEngineView::setHtml() for the same cached message on every batch.
1492
2/2
✓ Branch 6 → 7 taken 591 times.
✓ Branch 6 → 8 taken 1201 times.
1792 if (m_bodies.requests().selectedMailId() == mailId)
1493 591 return;
1494
1495 1201 MailJD::Performance::MailOpenPerformanceTracker::instance().beginSelection(
1496 mailId);
1497 1201 displayMailId(mailId, previewHint);
1498 }
1499
1500 824 void MailController::setPreferredDisplayContent(const QString &mode) {
1501 824 m_preferredDisplayContent =
1502
2/2
✓ Branch 4 → 5 taken 789 times.
✓ Branch 4 → 6 taken 35 times.
859 mode == QLatin1String("text") ? MailDisplaySnapshot::PreferredContent::Text
1503
1/2
✓ Branch 11 → 12 taken 3 times.
✗ Branch 11 → 13 not taken.
38 : (mode == QLatin1String("light") || mode == QLatin1String("html"))
1504
2/2
✓ Branch 8 → 9 taken 3 times.
✓ Branch 8 → 12 taken 32 times.
38 ? MailDisplaySnapshot::PreferredContent::Html
1505 : MailDisplaySnapshot::PreferredContent::Auto;
1506 824 }
1507
1508 29 void MailController::reprojectBodyVariant(const MailId &mailId, bool needHtml) {
1509
3/6
✓ Branch 3 → 4 taken 29 times.
✗ Branch 3 → 6 not taken.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 29 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 29 times.
29 if (!mailId.isValid() || mailId.accountId != m_folder.accountId) return;
1510 29 const QString databasePath = m_cache->databasePath();
1511 // No selectDisplay: a mode switch must not bump the selection generation
1512 // or restart the loading pipeline; the delivery merges into the body.
1513
1/1
✓ Branch 11 → 12 taken 29 times.
29 const quint32 epoch = displayEpoch(mailId);
1514
3/4
✓ Branch 12 → 13 taken 29 times.
✓ Branch 13 → 14 taken 29 times.
✗ Branch 13 → 15 not taken.
✓ Branch 19 → 20 taken 29 times.
58 MailDisplayReadService::shared()->read(this, databasePath, mailId,
1515
1/5
✓ Branch 18 → 19 taken 29 times.
✗ Branch 30 → 31 not taken.
✗ Branch 30 → 32 not taken.
✗ Branch 33 → 34 not taken.
✗ Branch 33 → 35 not taken.
58 [this, mailId, databasePath, epoch](MailDisplaySnapshot snapshot) {
1516
4/10
✓ Branch 3 → 4 taken 17 times.
✗ Branch 3 → 9 not taken.
✓ Branch 6 → 7 taken 17 times.
✗ Branch 6 → 9 not taken.
✓ Branch 11 → 12 taken 17 times.
✗ Branch 11 → 13 not taken.
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 17 times.
✗ Branch 22 → 23 not taken.
✗ Branch 22 → 24 not taken.
34 if (m_folder.accountId != mailId.accountId || m_cache->databasePath() != databasePath ||
1517
2/3
✓ Branch 7 → 8 taken 17 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 17 times.
17 !sameDisplayEpoch(epoch, snapshot)) return;
1518
1/2
✓ Branch 15 → 16 taken 17 times.
✗ Branch 15 → 18 not taken.
17 if (snapshot.outcome != MailDisplaySnapshot::Outcome::Ready ||
1519
2/4
✓ Branch 16 → 17 taken 17 times.
✗ Branch 16 → 18 not taken.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 17 times.
17 snapshot.body.uid != mailId.uid || snapshot.body.cacheUidValidity == 0) return;
1520
1/2
✓ Branch 19 → 20 taken 17 times.
✗ Branch 19 → 21 not taken.
17 if (m_view) m_view->presentBodyVariant(mailId, snapshot.body);
1521 }, needHtml ? MailDisplaySnapshot::PreferredContent::Html
1522 : MailDisplaySnapshot::PreferredContent::Text);
1523 29 }
1524
1525 // S106-114 (INV-13): a display read answers only for the mailbox epoch it
1526 // was requested under. A UIDVALIDITY reset with UID reuse between request
1527 // and read would otherwise present the new epoch's mail under the old
1528 // request. The epoch is the selected folder's, else the cached one.
1529 1245 quint32 MailController::displayEpoch(const MailId &mailId) const {
1530
4/4
✓ Branch 2 → 3 taken 1166 times.
✓ Branch 2 → 5 taken 79 times.
✓ Branch 3 → 4 taken 15 times.
✓ Branch 3 → 5 taken 1151 times.
1245 if (mailId.folderId == m_folder.currentFolderId && m_folder.activeFolderUidValidity != 0)
1531 15 return m_folder.activeFolderUidValidity;
1532 1230 return m_cache->uidValidity(mailId.folderId);
1533 }
1534
1535 763 bool MailController::sameDisplayEpoch(quint32 epoch, const MailDisplaySnapshot &snapshot) const {
1536
5/6
✓ Branch 2 → 3 taken 757 times.
✓ Branch 2 → 5 taken 6 times.
✓ Branch 3 → 4 taken 757 times.
✗ Branch 3 → 5 not taken.
✓ Branch 4 → 5 taken 756 times.
✓ Branch 4 → 6 taken 1 time.
763 if (epoch == 0 || snapshot.uidValidity == 0 || snapshot.uidValidity == epoch) return true;
1537
6/6
✓ Branch 6 → 7 taken 1 time.
✓ Branch 10 → 11 taken 1 time.
✓ Branch 11 → 12 taken 1 time.
✓ Branch 12 → 13 taken 1 time.
✓ Branch 18 → 8 taken 1 time.
✓ Branch 18 → 19 taken 1 time.
2 qCInfo(lcController) << "Display read dropped: epoch" << snapshot.uidValidity
1538
2/2
✓ Branch 13 → 14 taken 1 time.
✓ Branch 14 → 15 taken 1 time.
1 << "answered a request made under" << epoch;
1539 1 return false;
1540 }
1541
1542 1216 void MailController::displayMailId(const MailId &mailId, const MailHeader *previewHint) {
1543
3/6
✓ Branch 3 → 4 taken 1216 times.
✗ Branch 3 → 6 not taken.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 1216 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 1216 times.
1216 if (!mailId.isValid() || mailId.accountId != m_folder.accountId)
1544 ✗ return;
1545 // P90.9: navigation cadence anchors the prefetch idle window (cache hits
1546 // create no body request, so the display entry itself is the signal).
1547
1/1
✓ Branch 10 → 11 taken 1216 times.
1216 m_bodies.noteDemandActivity();
1548
1549 // A direct selection or Retry click starts a fresh user-visible attempt.
1550
1/1
✓ Branch 11 → 12 taken 1216 times.
1216 m_bodies.clearAutoRetry(mailId);
1551
1552
1/1
✓ Branch 13 → 14 taken 1216 times.
1216 const quint64 generation = m_bodies.requests().selectDisplay(mailId);
1553
1/1
✓ Branch 14 → 15 taken 1216 times.
1216 m_prefetchProbeTimer.stop();
1554 // The previous adjacency window is obsolete as soon as the selection
1555 // changes. A dispatched speculative request can finish, but queued work
1556 // must not occupy the next demand window.
1557
1/1
✓ Branch 16 → 17 taken 1216 times.
1216 m_bodies.requests().removeConsumer(BodyPurpose::Prefetch, 0);
1558 1216 const QString databasePath = m_cache->databasePath();
1559
1/1
✓ Branch 18 → 19 taken 1216 times.
1216 const int row = m_model->rowForUid(mailId.uid, mailId.folderId);
1560 1216 MailHeader headerHint;
1561 1216 headerHint.uid = mailId.uid;
1562 1216 headerHint.folderId = mailId.folderId;
1563 // P90.11/P90.17: the indexed Quick page may already have a bounded
1564 // preview for a row beyond the flat model's first 200 headers. Publish its
1565 // envelope immediately without a GUI-thread SQL lookup; the authoritative
1566 // display snapshot will replace it when the body read completes.
1567
3/4
✓ Branch 20 → 21 taken 24 times.
✓ Branch 20 → 24 taken 1192 times.
✓ Branch 21 → 22 taken 24 times.
✗ Branch 21 → 24 not taken.
1216 if (previewHint && previewHint->uid == mailId.uid &&
1568
1/2
✓ Branch 22 → 23 taken 24 times.
✗ Branch 22 → 24 not taken.
24 previewHint->folderId == mailId.folderId) headerHint = *previewHint;
1569
5/5
✓ Branch 24 → 25 taken 1015 times.
✓ Branch 24 → 27 taken 201 times.
✓ Branch 25 → 26 taken 1015 times.
✓ Branch 28 → 29 taken 1015 times.
✓ Branch 28 → 30 taken 201 times.
1216 if (const auto *header = row >= 0 ? m_model->headerAt(row) : nullptr)
1570 1015 headerHint = *header;
1571
4/4
✓ Branch 30 → 31 taken 1201 times.
✓ Branch 30 → 35 taken 15 times.
✓ Branch 31 → 32 taken 1201 times.
✓ Branch 32 → 33 taken 1201 times.
1216 if (m_view) m_view->presentLoading(mailId, generation, headerHint, tr("Loading body…"), false);
1572
2/2
✓ Branch 35 → 36 taken 547 times.
✓ Branch 35 → 41 taken 669 times.
1216 if (!m_loadingPlaceholderTimer) {
1573
3/6
✓ Branch 36 → 37 taken 547 times.
✓ Branch 37 → 38 taken 547 times.
✗ Branch 38 → 39 not taken.
✓ Branch 38 → 40 taken 547 times.
✗ Branch 69 → 70 not taken.
✗ Branch 69 → 71 not taken.
547 m_loadingPlaceholderTimer = new QTimer(this);
1574
1/1
✓ Branch 40 → 41 taken 547 times.
547 m_loadingPlaceholderTimer->setSingleShot(true);
1575 }
1576
1/1
✓ Branch 41 → 42 taken 1216 times.
1216 m_loadingPlaceholderTimer->stop();
1577
1/1
✓ Branch 42 → 43 taken 1216 times.
1216 m_loadingPlaceholderTimer->disconnect();
1578 1216 connect(m_loadingPlaceholderTimer, &QTimer::timeout, this,
1579
1/3
✓ Branch 45 → 46 taken 1216 times.
✗ Branch 74 → 75 not taken.
✗ Branch 74 → 76 not taken.
2432 [this, mailId, generation, headerHint]() {
1580
3/6
✓ Branch 5 → 6 taken 47 times.
✗ Branch 5 → 9 not taken.
✓ Branch 11 → 12 taken 47 times.
✗ Branch 11 → 13 not taken.
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 47 times.
94 if (m_bodies.requests().selectedMailId() != mailId ||
1581
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 47 times.
47 m_bodies.requests().displayGeneration() != generation)
1582 ✗ return;
1583
3/4
✓ Branch 15 → 16 taken 47 times.
✗ Branch 15 → 20 not taken.
✓ Branch 16 → 17 taken 47 times.
✓ Branch 17 → 18 taken 47 times.
47 if (m_view) m_view->presentLoading(mailId, generation, headerHint, tr("Loading body…"), true);
1584 });
1585
1/1
✓ Branch 48 → 49 taken 1216 times.
1216 m_loadingPlaceholderTimer->start(200);
1586
2/2
✓ Branch 49 → 50 taken 1216 times.
✓ Branch 50 → 51 taken 1216 times.
1216 MailJD::Performance::MailOpenPerformanceTracker::instance().cacheReadStarted(mailId);
1587 const quint64 bodyStoreRevision =
1588
1/1
✓ Branch 51 → 52 taken 1216 times.
1216 MailBodyWriteService::shared()->bodyStoreRevision();
1589
1/1
✓ Branch 53 → 54 taken 1216 times.
1216 const quint32 epoch = displayEpoch(mailId);
1590 // P90.8: project exactly the variant the active reading mode can show.
1591
2/2
✓ Branch 54 → 55 taken 1216 times.
✓ Branch 59 → 60 taken 1216 times.
2432 MailDisplayReadService::shared()->read(this, databasePath, mailId,
1592
1/7
✓ Branch 58 → 59 taken 1216 times.
✗ Branch 83 → 84 not taken.
✗ Branch 83 → 85 not taken.
✗ Branch 86 → 87 not taken.
✗ Branch 86 → 88 not taken.
✗ Branch 89 → 90 not taken.
✗ Branch 89 → 91 not taken.
2432 [this, mailId, generation, databasePath, headerHint,
1593 bodyStoreRevision, epoch](MailDisplaySnapshot snapshot) {
1594
2/4
✓ Branch 22 → 23 taken 9 times.
✓ Branch 22 → 24 taken 745 times.
✗ Branch 102 → 103 not taken.
✗ Branch 102 → 104 not taken.
1508 if (m_bodies.requests().selectedMailId() != mailId ||
1595
1/2
✓ Branch 8 → 9 taken 746 times.
✗ Branch 8 → 16 not taken.
746 m_bodies.requests().displayGeneration() != generation ||
1596
7/12
✓ Branch 5 → 6 taken 746 times.
✓ Branch 5 → 16 taken 8 times.
✓ Branch 10 → 11 taken 746 times.
✗ Branch 10 → 16 not taken.
✓ Branch 13 → 14 taken 746 times.
✗ Branch 13 → 16 not taken.
✓ Branch 18 → 19 taken 746 times.
✓ Branch 18 → 20 taken 8 times.
✓ Branch 20 → 21 taken 754 times.
✗ Branch 20 → 22 not taken.
✗ Branch 98 → 99 not taken.
✗ Branch 98 → 100 not taken.
3000 m_folder.accountId != mailId.accountId || m_cache->databasePath() != databasePath ||
1597
3/3
✓ Branch 14 → 15 taken 746 times.
✓ Branch 15 → 16 taken 1 time.
✓ Branch 15 → 17 taken 745 times.
746 !sameDisplayEpoch(epoch, snapshot))
1598 9 return;
1599 745 auto &tracker = MailJD::Performance::MailOpenPerformanceTracker::instance();
1600 745 const bool ready = snapshot.outcome == MailDisplaySnapshot::Outcome::Ready;
1601
3/4
✓ Branch 25 → 26 taken 114 times.
✓ Branch 25 → 27 taken 631 times.
✓ Branch 26 → 27 taken 114 times.
✗ Branch 26 → 28 not taken.
745 const bool found = ready || snapshot.outcome == MailDisplaySnapshot::Outcome::BodyMissing;
1602 745 tracker.selectionLookupFinished(mailId, found);
1603
3/4
✓ Branch 30 → 31 taken 631 times.
✓ Branch 30 → 34 taken 114 times.
✗ Branch 32 → 33 not taken.
✓ Branch 32 → 34 taken 631 times.
745 tracker.cacheReadFinished(mailId, ready, ready && tracker.consumePrefetch(mailId));
1604
2/2
✓ Branch 36 → 37 taken 631 times.
✓ Branch 36 → 67 taken 114 times.
745 if (ready) {
1605
1/1
✓ Branch 37 → 38 taken 631 times.
631 m_loadingPlaceholderTimer->stop();
1606
3/3
✓ Branch 38 → 39 taken 624 times.
✓ Branch 38 → 40 taken 7 times.
✓ Branch 39 → 40 taken 624 times.
631 if (m_view) presentMail(snapshot.header, snapshot.body);
1607 // The identity-checked display snapshot already supplies a Seen state.
1608 // Indexed rows beyond the flat page have no loaded flat flags; pass
1609 // this hint to the visual-first background writer instead of reading
1610 // the canonical header again on the GUI thread after presentation.
1611
1/1
✓ Branch 40 → 41 taken 631 times.
631 const auto loadedFlags = m_model->flagsFor(mailId.uid, mailId.folderId);
1612
3/3
✓ Branch 42 → 43 taken 579 times.
✓ Branch 42 → 44 taken 52 times.
✓ Branch 46 → 47 taken 631 times.
683 markMailAsSeenInFolder(mailId.uid, mailId.folderId,
1613 683 loadedFlags ? std::nullopt : std::optional<bool>(snapshot.header.isSeen()));
1614
3/6
✓ Branch 50 → 51 taken 631 times.
✗ Branch 50 → 54 not taken.
✓ Branch 56 → 57 taken 631 times.
✗ Branch 56 → 58 not taken.
✗ Branch 58 → 59 not taken.
✓ Branch 58 → 60 taken 631 times.
1262 if (m_bodies.requests().selectedMailId() != mailId ||
1615
1/2
✗ Branch 53 → 54 not taken.
✓ Branch 53 → 55 taken 631 times.
631 m_bodies.requests().displayGeneration() != generation) return;
1616 // The body transport already waits for the demand-idle window. Do the
1617 // speculative hasBody probes after that window too, so they cannot
1618 // block the first Quick frame that presents this cached body.
1619 631 const qint64 elapsed = QDeadlineTimer::current().deadline() -
1620 631 m_bodies.lastDemandBodyActivityMs();
1621 631 const int prefetchDelay = int(qMax<qint64>(50, MailBodyTransport::PrefetchIdleWindowMs - elapsed));
1622 631 m_prefetchProbeMail = mailId;
1623 631 m_prefetchProbeGeneration = generation;
1624
1/1
✓ Branch 64 → 65 taken 631 times.
631 m_prefetchProbeTimer.start(prefetchDelay);
1625 631 return;
1626 }
1627
1/2
✗ Branch 67 → 68 not taken.
✓ Branch 67 → 78 taken 114 times.
114 if (snapshot.outcome != MailDisplaySnapshot::Outcome::BodyMissing) {
1628 ✗ m_loadingPlaceholderTimer->stop();
1629 ✗ tracker.fail(mailId);
1630 ✗ if (m_view) {
1631 ✗ const bool limited = snapshot.outcome == MailDisplaySnapshot::Outcome::Limited;
1632 ✗ presentError(headerHint,
1633 ✗ limited ? tr("Message content exceeds the supported processing limits.")
1634 ✗ : tr("Message content could not be read locally. Retry."), !limited);
1635 }
1636 ✗ return;
1637 }
1638
2/2
✓ Branch 79 → 80 taken 1 time.
✓ Branch 79 → 83 taken 113 times.
114 if (m_bodies.rejectMessageAboveDownloadLimit(snapshot.header, true)) {
1639 1 m_loadingPlaceholderTimer->stop();
1640 1 tracker.fail(mailId);
1641 1 return;
1642 }
1643
2/2
✓ Branch 83 → 84 taken 111 times.
✓ Branch 83 → 93 taken 2 times.
113 if (!m_networkEnabled) {
1644 111 m_loadingPlaceholderTimer->stop();
1645 111 tracker.fail(mailId);
1646 // At startup the restored selection is displayed before the account
1647 // has finished connecting; setNetworkEnabled(true) retries it.
1648 111 m_displayAwaitingNetwork = mailId;
1649
2/2
✓ Branch 87 → 88 taken 110 times.
✓ Branch 87 → 92 taken 1 time.
111 if (m_view)
1650
1/1
✓ Branch 89 → 90 taken 110 times.
110 presentError(snapshot.header,
1651
1/1
✓ Branch 88 → 89 taken 110 times.
220 tr("Message body is not cached. Connect to download it."), true);
1652 111 return;
1653 }
1654 2 tracker.dispatchQueued(mailId);
1655 // The read worker already proved this body missing. Recheck SQLite on the
1656 // GUI thread only if an app-owned body writer committed in the meantime;
1657 // its atomic revision advances before the writer's GUI callback arrives.
1658 const bool noInterveningBodyStore =
1659 2 MailBodyWriteService::shared()->bodyStoreRevision() == bodyStoreRevision;
1660 2 m_bodies.requestBodyForConsumer(mailId, BodyPurpose::Display, 0,
1661 noInterveningBodyStore);
1662 }, m_preferredDisplayContent);
1663 1216 }
1664
1665 // ═══════════════════════════════════════════════════════
1666 // IDLE Event Handlers (all use executeAfterIdle for Bug 5)
1667 // ═══════════════════════════════════════════════════════
1668
1669 // ═══════════════════════════════════════════════════════
1670 // Flag Sync
1671 // ═══════════════════════════════════════════════════════
1672
1673 16 void MailController::finishFlagSync(QList<qint64> missingUids,
1674 bool exactMembership) {
1675 16 missingUids.removeIf([this](qint64 uid) {
1676
1/3
✓ Branch 4 → 5 taken 7 times.
✗ Branch 11 → 12 not taken.
✗ Branch 11 → 13 not taken.
7 return m_mutations.intents().hasPendingMove(MailId{m_folder.accountId, m_folder.currentFolderId, uid});
1677 });
1678 // The folder-open FETCH FLAGS covers every server UID (1:*). If none is
1679 // missing locally, the cache is complete: the delta UID SEARCH and the
1680 // second full FETCH FLAGS it led to only repeated this snapshot. Mail that
1681 // arrives afterwards is reported by IDLE.
1682
8/10
✓ Branch 3 → 4 taken 6 times.
✓ Branch 3 → 11 taken 10 times.
✓ Branch 5 → 6 taken 3 times.
✓ Branch 5 → 11 taken 3 times.
✓ Branch 7 → 8 taken 3 times.
✗ Branch 7 → 11 not taken.
✓ Branch 9 → 10 taken 3 times.
✗ Branch 9 → 11 not taken.
✓ Branch 12 → 13 taken 3 times.
✓ Branch 12 → 25 taken 13 times.
19 if (m_pendingHeaderFetch && missingUids.isEmpty() && m_reverseChunks.isEmpty() &&
1683 3 m_headerCacheError.isEmpty()) {
1684
5/5
✓ Branch 13 → 14 taken 3 times.
✓ Branch 17 → 18 taken 3 times.
✓ Branch 18 → 19 taken 3 times.
✓ Branch 23 → 15 taken 3 times.
✓ Branch 23 → 24 taken 3 times.
6 qCInfo(lcController) << "Flag sync: cache complete, no delta search needed for"
1685
1/1
✓ Branch 19 → 20 taken 3 times.
3 << m_folder.currentFolder;
1686 3 m_pendingHeaderFetch = false;
1687 }
1688
3/4
✓ Branch 27 → 28 taken 13 times.
✗ Branch 27 → 33 not taken.
✓ Branch 29 → 30 taken 12 times.
✓ Branch 29 → 33 taken 1 time.
26 if (!m_pendingHeaderFetch && m_reverseChunks.isEmpty() &&
1689
6/6
✓ Branch 25 → 26 taken 13 times.
✓ Branch 25 → 33 taken 3 times.
✓ Branch 31 → 32 taken 1 time.
✓ Branch 31 → 33 taken 11 times.
✓ Branch 34 → 35 taken 1 time.
✓ Branch 34 → 41 taken 15 times.
42 m_headerCacheError.isEmpty() && !missingUids.isEmpty()) {
1690 // A final FETCH FLAGS can include an arrival after SEARCH. With exact
1691 // cache membership, holes below maxUid are real too; the older small or
1692 // failure path conservatively keeps its high-water-mark rule.
1693
1/2
✓ Branch 35 → 36 taken 1 time.
✗ Branch 35 → 38 not taken.
1 if (!exactMembership) {
1694 1 const qint64 maxCachedUid = m_cache->maxUid(m_folder.currentFolderId);
1695 2 missingUids.removeIf([maxCachedUid](qint64 uid) { return uid <= maxCachedUid; });
1696 }
1697
1/2
✓ Branch 39 → 40 taken 1 time.
✗ Branch 39 → 41 not taken.
1 if (!missingUids.isEmpty()) {
1698 1 m_pendingHeaderFetch = true;
1699 1 m_fetchInProgress = true;
1700 }
1701 }
1702 16 MailJD::Performance::FolderPerformanceTracker::instance().flagsFinished(
1703 16 m_folder.folderGeneration, m_pendingHeaderFetch);
1704
1705 // Only emit badge when model represents the full folder (not during
1706 // initial header fetch where the model is empty/partial).
1707
2/2
✓ Branch 44 → 45 taken 15 times.
✓ Branch 44 → 74 taken 1 time.
16 if (m_headerCacheError.isEmpty()) {
1708
2/2
✓ Branch 45 → 46 taken 11 times.
✓ Branch 45 → 48 taken 4 times.
15 if (!m_pendingHeaderFetch)
1709 11 emit unreadCountChanged(m_folder.currentFolder, m_model->unreadCount());
1710 15 requestFolderCountStatus();
1711 // The initial flag pass may still require missing-header backfill.
1712
5/6
✓ Branch 49 → 50 taken 11 times.
✓ Branch 49 → 53 taken 4 times.
✓ Branch 51 → 52 taken 11 times.
✗ Branch 51 → 53 not taken.
✓ Branch 54 → 55 taken 11 times.
✓ Branch 54 → 81 taken 4 times.
15 if (!m_pendingHeaderFetch && m_reverseChunks.isEmpty()) {
1713 11 m_cache->setLastSync(m_folder.currentFolderId);
1714
3/4
✓ Branch 56 → 57 taken 2 times.
✓ Branch 56 → 73 taken 9 times.
✓ Branch 57 → 58 taken 2 times.
✗ Branch 57 → 73 not taken.
11 if (m_pendingModseqFolderId == m_folder.currentFolderId && m_pendingModseq > 0) {
1715 2 m_cache->setHighestModseq(m_folder.currentFolderId, m_pendingModseq);
1716
6/6
✓ Branch 59 → 60 taken 2 times.
✓ Branch 63 → 64 taken 2 times.
✓ Branch 64 → 65 taken 2 times.
✓ Branch 65 → 66 taken 2 times.
✓ Branch 71 → 61 taken 2 times.
✓ Branch 71 → 72 taken 2 times.
4 qCInfo(lcController) << "T-208: Stored HIGHESTMODSEQ" << m_pendingModseq
1717
2/2
✓ Branch 66 → 67 taken 2 times.
✓ Branch 67 → 68 taken 2 times.
2 << "for" << m_folder.currentFolder;
1718 }
1719 11 m_pendingModseqFolderId = -1;
1720 11 m_pendingModseq = 0;
1721 }
1722 } else {
1723
3/3
✓ Branch 74 → 75 taken 1 time.
✓ Branch 76 → 77 taken 1 time.
✓ Branch 77 → 78 taken 1 time.
2 emit statusMessage(tr("Folder sync incomplete: %1").arg(m_headerCacheError));
1724 }
1725
1726 // T-058: If pending header fetch, do delta fetch now; otherwise start IDLE
1727
2/2
✓ Branch 81 → 82 taken 4 times.
✓ Branch 81 → 142 taken 12 times.
16 if (m_pendingHeaderFetch) {
1728 4 m_pendingHeaderFetch = false;
1729
1730 // Backfill: if server has UIDs we don't have in cache, fetch them.
1731 // Also handles first-visit folders where ALL UIDs are "missing".
1732 // Use reverse-chunk pipeline so newest arrive first.
1733
1/2
✓ Branch 83 → 84 taken 4 times.
✗ Branch 83 → 117 not taken.
4 if (!missingUids.isEmpty()) {
1734
6/6
✓ Branch 84 → 85 taken 4 times.
✓ Branch 88 → 89 taken 4 times.
✓ Branch 89 → 90 taken 4 times.
✓ Branch 91 → 92 taken 4 times.
✓ Branch 96 → 86 taken 4 times.
✓ Branch 96 → 97 taken 4 times.
8 qCInfo(lcController) << "Flag sync: backfilling" << missingUids.size()
1735
1/1
✓ Branch 92 → 93 taken 4 times.
4 << "missing UIDs from server";
1736 // Feed into the reverse-chunk pipeline (same as onSearchResultReceived)
1737 4 std::sort(missingUids.begin(), missingUids.end(),
1738 std::greater<qint64>());
1739
1/1
✓ Branch 100 → 101 taken 4 times.
4 m_reverseChunks = reverseFetchChunks(missingUids);
1740
7/7
✓ Branch 103 → 104 taken 4 times.
✓ Branch 107 → 108 taken 4 times.
✓ Branch 108 → 109 taken 4 times.
✓ Branch 110 → 111 taken 4 times.
✓ Branch 111 → 112 taken 4 times.
✓ Branch 115 → 105 taken 4 times.
✓ Branch 115 → 116 taken 4 times.
8 qCInfo(lcController) << "Backfill:" << m_reverseChunks.size() << "chunks";
1741 4 fetchNextChunk();
1742 } else {
1743 ✗ qint64 maxUid = m_cache->maxUid(m_folder.currentFolderId);
1744
1745 ✗ if (maxUid == 0) {
1746 // T-061: Initial sync – use SEARCH to get UIDs, then fetch newest first
1747 ✗ qCInfo(lcController) << "Initial sync: searching all UIDs for reverse fetch";
1748 ✗ m_imap->searchAllUids();
1749 } else {
1750 // Delta sync – search new UIDs, then fetch newest first
1751 ✗ qCInfo(lcController) << "Delta sync: searching UIDs from"
1752 ✗ << (maxUid + 1);
1753 ✗ m_imap->searchAllUids(maxUid + 1);
1754 }
1755 }
1756 } else {
1757 // T-061: If reverse chunks remain, fetch next chunk
1758
1/2
✗ Branch 143 → 144 not taken.
✓ Branch 143 → 145 taken 12 times.
12 if (!m_reverseChunks.isEmpty()) {
1759 ✗ fetchNextChunk();
1760 } else {
1761 // Flag sync done → start IDLE
1762 12 m_flagSync.startIdleIfPossible();
1763 }
1764 }
1765 16 }
1766
1767 // ═══════════════════════════════════════════════════════
1768 // Folder Polling (Bug 4 fix: pauses IDLE cleanly)
1769 // ═══════════════════════════════════════════════════════
1770
1771 // ═══════════════════════════════════════════════════════
1772 // Helpers
1773 // ═══════════════════════════════════════════════════════
1774
1775 20 void MailController::setImapConfig(const ImapConfig &config) {
1776 20 m_imapConfig = config;
1777
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 20 times.
20 if (m_bodies.requests().pendingCount() > 0)
1778 ✗ ensureBodyConnection();
1779 20 }
1780
1781 881 void MailController::setNetworkEnabled(bool enabled) {
1782
2/2
✓ Branch 2 → 3 taken 57 times.
✓ Branch 2 → 4 taken 824 times.
881 if (m_networkEnabled == enabled)
1783 866 return;
1784 824 m_networkEnabled = enabled;
1785
2/2
✓ Branch 4 → 5 taken 809 times.
✓ Branch 4 → 14 taken 15 times.
824 if (!enabled) {
1786 // Disable recovery before disconnect signals can schedule another attempt.
1787 // Durable intents remain in the existing journal, not in frontend state.
1788
3/3
✓ Branch 5 → 6 taken 2 times.
✓ Branch 5 → 7 taken 807 times.
✓ Branch 6 → 7 taken 2 times.
809 if (m_bodyHealth) m_bodyHealth->setActive(false);
1789
3/3
✓ Branch 7 → 8 taken 1 time.
✓ Branch 7 → 9 taken 808 times.
✓ Branch 8 → 9 taken 1 time.
809 if (m_searchHealth) m_searchHealth->setActive(false);
1790
3/3
✓ Branch 9 → 10 taken 2 times.
✓ Branch 9 → 11 taken 807 times.
✓ Branch 10 → 11 taken 2 times.
809 if (m_bodyImap) m_bodyImap->disconnect(ImapService::DisconnectReason::AccountReload);
1791
3/3
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 13 taken 808 times.
✓ Branch 12 → 13 taken 1 time.
809 if (m_searchImap) m_searchImap->disconnect(ImapService::DisconnectReason::AccountReload);
1792 809 return;
1793 }
1794
5/6
✓ Branch 14 → 15 taken 13 times.
✓ Branch 14 → 18 taken 2 times.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 13 times.
✓ Branch 20 → 21 taken 2 times.
✓ Branch 20 → 22 taken 13 times.
15 if (m_bodyImap || m_bodies.requests().pendingCount() > 0)
1795
1/1
✓ Branch 21 → 22 taken 2 times.
2 ensureBodyConnection();
1796
6/7
✓ Branch 22 → 23 taken 1 time.
✓ Branch 22 → 26 taken 14 times.
✓ Branch 23 → 24 taken 1 time.
✓ Branch 24 → 25 taken 1 time.
✗ Branch 24 → 26 not taken.
✓ Branch 27 → 28 taken 1 time.
✓ Branch 27 → 29 taken 14 times.
15 if (m_searchImap && m_serverSearch.awaitingConnection())
1797
1/1
✓ Branch 28 → 29 taken 1 time.
1 ensureSearchConnection();
1798 // A mail shown as "not cached, connect to download" while the account was
1799 // still offline is fetched now, if the user has not selected another one.
1800 15 const MailId waiting = std::exchange(m_displayAwaitingNetwork, MailId{});
1801
5/6
✓ Branch 32 → 33 taken 3 times.
✓ Branch 32 → 40 taken 12 times.
✓ Branch 34 → 35 taken 3 times.
✗ Branch 34 → 40 not taken.
✓ Branch 38 → 39 taken 1 time.
✓ Branch 38 → 40 taken 2 times.
18 if (waiting.isValid() && waiting.accountId == m_folder.accountId &&
1802
4/4
✓ Branch 41 → 42 taken 3 times.
✓ Branch 41 → 43 taken 12 times.
✓ Branch 43 → 44 taken 1 time.
✓ Branch 43 → 45 taken 14 times.
18 m_bodies.requests().selectedMailId() == waiting)
1803
1/1
✓ Branch 44 → 45 taken 1 time.
1 displayMailId(waiting);
1804 15 }
1805
1806 32 void MailController::ensureSearchConnection() {
1807
2/2
✓ Branch 2 → 3 taken 20 times.
✓ Branch 2 → 18 taken 12 times.
32 if (!m_searchImap) {
1808
2/5
✓ Branch 4 → 5 taken 20 times.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 20 times.
✗ Branch 50 → 51 not taken.
✗ Branch 50 → 52 not taken.
20 m_searchImap = new ImapService(this);
1809 // Note: searchResultReceived is connected dynamically in searchNextFolder()
1810 // T-206: Also handle STATUS responses from search connection (polling)
1811 20 connect(m_searchImap, &ImapService::folderStatusReceived, &m_flagSync,
1812
1/1
✓ Branch 7 → 8 taken 20 times.
20 &FolderFlagSync::onFolderStatusReceived);
1813 20 connect(m_searchImap, &ImapService::folderStatusFailed, &m_flagSync,
1814
1/1
✓ Branch 9 → 10 taken 20 times.
20 &FolderFlagSync::onFolderStatusFailed);
1815 20 connect(m_searchImap, &ImapService::stateChanged, this,
1816
1/1
✓ Branch 11 → 12 taken 20 times.
20 [this](ImapService::State s) {
1817
2/2
✓ Branch 2 → 3 taken 5 times.
✓ Branch 2 → 14 taken 10 times.
15 if (s == ImapService::State::Authenticated) {
1818
5/5
✓ Branch 3 → 4 taken 5 times.
✓ Branch 7 → 8 taken 5 times.
✓ Branch 8 → 9 taken 5 times.
✓ Branch 12 → 5 taken 5 times.
✓ Branch 12 → 13 taken 5 times.
10 qCInfo(lcController) << "Search IMAP connection ready";
1819 // T-720: resume an interrupted server search.
1820 5 m_serverSearch.resumeAfterReconnect();
1821
4/4
✓ Branch 14 → 15 taken 9 times.
✓ Branch 14 → 16 taken 1 time.
✓ Branch 15 → 16 taken 1 time.
✓ Branch 15 → 31 taken 8 times.
10 } else if (s == ImapService::State::Error ||
1822 s == ImapService::State::Disconnected) {
1823
5/5
✓ Branch 16 → 17 taken 2 times.
✓ Branch 20 → 21 taken 2 times.
✓ Branch 21 → 22 taken 2 times.
✓ Branch 29 → 18 taken 2 times.
✓ Branch 29 → 30 taken 2 times.
4 qCWarning(lcController) << "Search IMAP"
1824
3/3
✓ Branch 22 → 23 taken 1 time.
✓ Branch 22 → 24 taken 1 time.
✓ Branch 25 → 26 taken 2 times.
2 << (s == ImapService::State::Error ? "error" : "disconnected");
1825 // T-720: drop the one-shot SELECT/SEARCH waits of the search.
1826 2 m_serverSearch.dropPendingHandlers();
1827 }
1828 15 });
1829
1830 // T-720: Wrap the search connection in a monitor so it self-heals
1831 // instead of waiting for the next ensureSearchConnection() call.
1832 // systemWatchEnabled=false: the main connection (MainWindow) owns the
1833 // single set of system-wide hooks; secondaries react via probes/state.
1834
2/5
✓ Branch 14 → 15 taken 20 times.
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 20 times.
✗ Branch 59 → 60 not taken.
✗ Branch 59 → 61 not taken.
20 m_searchHealth = new ConnectionHealthMonitor(false, this);
1835 20 m_searchHealth->attach(m_searchImap);
1836 }
1837
1838
6/6
✓ Branch 18 → 19 taken 24 times.
✓ Branch 18 → 21 taken 8 times.
✓ Branch 20 → 21 taken 11 times.
✓ Branch 20 → 22 taken 13 times.
✓ Branch 23 → 24 taken 19 times.
✓ Branch 23 → 25 taken 13 times.
32 if (!m_networkEnabled || m_imapConfig.host.isEmpty())
1839 19 return;
1840
1841 // (Re)install the reconnect config + activate the monitor every call.
1842
1/2
✓ Branch 25 → 26 taken 13 times.
✗ Branch 25 → 28 not taken.
13 if (m_searchHealth) {
1843 13 m_searchHealth->setReconnectConfig(m_imapConfig);
1844 13 m_searchHealth->setActive(true);
1845 }
1846
1847 // Reconnect not only from a clean Disconnected state but also from Error:
1848 // servers routinely drop idle secondary connections (TLS close), which lands
1849 // the search connection in Error. The monitor now handles this for us, but
1850 // we keep the explicit call for the initial connect path.
1851
5/6
✓ Branch 29 → 30 taken 5 times.
✓ Branch 29 → 32 taken 8 times.
✗ Branch 31 → 32 not taken.
✓ Branch 31 → 33 taken 5 times.
✓ Branch 34 → 35 taken 8 times.
✓ Branch 34 → 49 taken 5 times.
18 if (m_searchImap->state() == ImapService::State::Disconnected ||
1852 5 m_searchImap->state() == ImapService::State::Error) {
1853
5/5
✓ Branch 35 → 36 taken 8 times.
✓ Branch 39 → 40 taken 8 times.
✓ Branch 40 → 41 taken 8 times.
✓ Branch 47 → 37 taken 8 times.
✓ Branch 47 → 48 taken 8 times.
16 qCInfo(lcController) << "Connecting search IMAP (state:"
1854
2/2
✓ Branch 42 → 43 taken 8 times.
✓ Branch 43 → 44 taken 8 times.
8 << static_cast<int>(m_searchImap->state()) << ")...";
1855 8 m_searchImap->connectToServer(m_imapConfig);
1856 }
1857 }
1858
1859 // T-205: Lazy-init dedicated IMAP connection for body fetch
1860 45 void MailController::ensureBodyConnection() {
1861
2/2
✓ Branch 2 → 3 taken 19 times.
✓ Branch 2 → 23 taken 26 times.
45 if (!m_bodyImap) {
1862
2/5
✓ Branch 4 → 5 taken 19 times.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 19 times.
✗ Branch 68 → 69 not taken.
✗ Branch 68 → 70 not taken.
19 m_bodyImap = new ImapService(this);
1863 19 m_bodyImap->setAutoIdle(false); // T-205: Body connection must NOT idle
1864 // S91.0.6: consumers wire their own signals. Only stateChanged has
1865 // several receivers here; they run in this order: lifecycle log, body
1866 // transport (reconnect retry), mutation pipeline (reset/replay), health.
1867
1/1
✓ Branch 8 → 9 taken 19 times.
19 connect(m_bodyImap, &ImapService::stateChanged, this,
1868 ✗ [](ImapService::State s) {
1869
5/5
✓ Branch 2 → 3 taken 4 times.
✓ Branch 6 → 7 taken 4 times.
✓ Branch 7 → 8 taken 4 times.
✓ Branch 12 → 4 taken 4 times.
✓ Branch 12 → 13 taken 4 times.
8 qCInfo(lcController) << "Body IMAP state changed to"
1870
1/1
✓ Branch 8 → 9 taken 4 times.
4 << static_cast<int>(s);
1871
2/2
✓ Branch 13 → 14 taken 2 times.
✓ Branch 13 → 25 taken 2 times.
4 if (s == ImapService::State::Authenticated)
1872
5/5
✓ Branch 14 → 15 taken 2 times.
✓ Branch 18 → 19 taken 2 times.
✓ Branch 19 → 20 taken 2 times.
✓ Branch 23 → 16 taken 2 times.
✓ Branch 23 → 24 taken 2 times.
4 qCInfo(lcController) << "Body IMAP connection ready";
1873 4 });
1874 19 m_bodies.connectBodyTransport(m_bodyImap);
1875 // T-79.E2/M6: cross-folder moves run on this connection — without
1876 // these handlers a successful move never cleaned the source-folder
1877 // cache and a failed move had neither rollback nor user feedback.
1878 19 connect(m_bodyImap, &ImapService::messagesMoved, &m_moves,
1879
1/1
✓ Branch 11 → 12 taken 19 times.
19 &MailMoveCommands::onBodyImapMessagesMoved);
1880 19 connect(m_bodyImap, &ImapService::moveError, &m_moves,
1881
1/1
✓ Branch 13 → 14 taken 19 times.
19 &MailMoveCommands::onBodyImapMoveError);
1882 19 m_mutations.connectMutationVerification(m_bodyImap);
1883 19 connect(m_bodyImap, &ImapService::folderSelected, this,
1884
1/1
✓ Branch 16 → 17 taken 19 times.
19 [this](const QString &path, int, quint32 validity, quint64) {
1885 14 m_bodyImapSelectedFolder = path;
1886 14 reconcileFolderIdentity(path, validity);
1887 14 });
1888
1889 // T-720: Health monitor replaces T-540 keepalive timer + T-544 manual
1890 // retry arms. Periodic liveness probes keep the body connection alive
1891 // (NOOP while Authenticated/Selected, IDLE DONE/OK while Idling) and
1892 // exponential-backoff reconnect covers silent socket death. The
1893 // Authenticated branch above re-fetches the pending body on reconnect.
1894 // systemWatchEnabled=false: secondary monitor — see m_searchHealth.
1895
2/5
✓ Branch 19 → 20 taken 19 times.
✗ Branch 20 → 21 not taken.
✓ Branch 20 → 22 taken 19 times.
✗ Branch 79 → 80 not taken.
✗ Branch 79 → 81 not taken.
19 m_bodyHealth = new ConnectionHealthMonitor(false, this);
1896 19 m_bodyHealth->attach(m_bodyImap);
1897 }
1898
1899
6/6
✓ Branch 23 → 24 taken 43 times.
✓ Branch 23 → 26 taken 2 times.
✓ Branch 25 → 26 taken 38 times.
✓ Branch 25 → 27 taken 5 times.
✓ Branch 28 → 29 taken 40 times.
✓ Branch 28 → 30 taken 5 times.
45 if (!m_networkEnabled || m_imapConfig.host.isEmpty())
1900 40 return;
1901
1902 // (Re)install the reconnect config + activate the monitor every call —
1903 // setImapConfig() can be invoked later than the first ensureBodyConnection().
1904
1/2
✓ Branch 30 → 31 taken 5 times.
✗ Branch 30 → 33 not taken.
5 if (m_bodyHealth) {
1905 5 m_bodyHealth->setReconnectConfig(m_imapConfig);
1906 5 m_bodyHealth->setActive(true);
1907 }
1908
1909
5/5
✓ Branch 33 → 34 taken 5 times.
✓ Branch 37 → 38 taken 5 times.
✓ Branch 38 → 39 taken 5 times.
✓ Branch 47 → 35 taken 5 times.
✓ Branch 47 → 48 taken 5 times.
10 qCInfo(lcController) << "ensureBodyConnection: state"
1910
1/1
✓ Branch 40 → 41 taken 5 times.
5 << static_cast<int>(m_bodyImap->state())
1911
2/2
✓ Branch 41 → 42 taken 5 times.
✓ Branch 43 → 44 taken 5 times.
5 << "configValid" << !m_imapConfig.host.isEmpty();
1912
1913 // Reconnect if disconnected or in error state. The monitor will pick up
1914 // the next silent death on its own; this call covers the initial connect
1915 // and the case where the user opens an uncached body after a clean
1916 // server-side disconnect (e.g. server idle timeout).
1917
5/6
✓ Branch 49 → 50 taken 4 times.
✓ Branch 49 → 52 taken 1 time.
✗ Branch 51 → 52 not taken.
✓ Branch 51 → 53 taken 4 times.
✓ Branch 54 → 55 taken 1 time.
✓ Branch 54 → 67 taken 4 times.
9 if (m_bodyImap->state() == ImapService::State::Disconnected ||
1918 4 m_bodyImap->state() == ImapService::State::Error) {
1919
5/5
✓ Branch 55 → 56 taken 1 time.
✓ Branch 59 → 60 taken 1 time.
✓ Branch 60 → 61 taken 1 time.
✓ Branch 64 → 57 taken 1 time.
✓ Branch 64 → 65 taken 1 time.
2 qCInfo(lcController) << "Connecting body IMAP...";
1920 1 m_bodyImapSelectedFolder.clear(); // Reset stale folder selection
1921 1 m_bodyImap->connectToServer(m_imapConfig);
1922 }
1923 }
1924
1925 #ifdef MAILJD_E2E_TESTING
1926 1 void MailController::setNextBodyCompletionForE2e(int delayMs, bool fail, const MailId &mailId) {
1927
3/6
✓ Branch 3 → 4 taken 1 time.
✗ Branch 3 → 7 not taken.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 1 time.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 1 time.
1 if (mailId.isValid() && mailId.accountId != m_folder.accountId)
1928 ✗ return;
1929 1 ensureBodyConnection();
1930
2/3
✓ Branch 12 → 13 taken 1 time.
✗ Branch 12 → 14 not taken.
✓ Branch 19 → 20 taken 1 time.
2 m_bodyImap->setNextBodyCompletionForE2e(
1931
2/3
✓ Branch 16 → 17 taken 1 time.
✗ Branch 16 → 18 not taken.
✓ Branch 17 → 19 taken 1 time.
2 delayMs, fail, mailId.isValid() ? m_cache->folderPath(mailId.folderId) : QString(),
1932 1 mailId.isValid() ? mailId.uid : 0);
1933 }
1934 #endif
1935
1936 // ═══════════════════════════════════════════════════════
1937 // Reverse Header Fetch (T-061)
1938 // ═══════════════════════════════════════════════════════
1939
1940
1941 10 void MailController::onSearchResultReceived(const QList<qint64> &uids) {
1942 // T-113: Discard if folder changed
1943
2/2
✓ Branch 2 → 3 taken 6 times.
✓ Branch 2 → 16 taken 4 times.
10 if (m_folder.activeFolderGen != m_folder.folderGeneration) {
1944
1/1
✓ Branch 3 → 4 taken 6 times.
6 MailJD::Performance::FolderPerformanceTracker::instance()
1945
1/1
✓ Branch 4 → 5 taken 6 times.
6 .staleGenerationDiscarded(m_folder.activeFolderGen);
1946
5/5
✓ Branch 5 → 6 taken 6 times.
✓ Branch 9 → 10 taken 6 times.
✓ Branch 10 → 11 taken 6 times.
✓ Branch 14 → 7 taken 6 times.
✓ Branch 14 → 15 taken 6 times.
12 qCInfo(lcController) << "Discarding stale SEARCH result";
1947 7 return;
1948 }
1949
1950
6/6
✓ Branch 16 → 17 taken 4 times.
✓ Branch 20 → 21 taken 4 times.
✓ Branch 21 → 22 taken 4 times.
✓ Branch 23 → 24 taken 4 times.
✓ Branch 29 → 18 taken 4 times.
✓ Branch 29 → 30 taken 4 times.
8 qCInfo(lcController) << "SEARCH returned" << uids.size()
1951
2/2
✓ Branch 24 → 25 taken 4 times.
✓ Branch 25 → 26 taken 4 times.
4 << "UIDs for" << m_folder.currentFolder;
1952
1953 // Filter out UIDs we already have in cache (handles IMAP edge case where
1954 // UID SEARCH N:* returns the highest existing UID even when no new mail).
1955
1/1
✓ Branch 30 → 31 taken 4 times.
4 qint64 maxCachedUid = m_cache->maxUid(m_folder.currentFolderId);
1956 4 QList<qint64> newUids;
1957
2/2
✓ Branch 38 → 33 taken 131 times.
✓ Branch 38 → 39 taken 4 times.
135 for (qint64 uid : uids) {
1958
2/2
✓ Branch 34 → 35 taken 129 times.
✓ Branch 34 → 36 taken 2 times.
131 if (uid > maxCachedUid) {
1959
1/1
✓ Branch 35 → 36 taken 129 times.
129 newUids.append(uid);
1960 }
1961 }
1962
1963
2/2
✓ Branch 40 → 41 taken 1 time.
✓ Branch 40 → 59 taken 3 times.
4 if (newUids.isEmpty()) {
1964
5/5
✓ Branch 41 → 42 taken 1 time.
✓ Branch 45 → 46 taken 1 time.
✓ Branch 46 → 47 taken 1 time.
✓ Branch 52 → 43 taken 1 time.
✓ Branch 52 → 53 taken 1 time.
2 qCInfo(lcController) << "No new UIDs after filtering (maxCached ="
1965
2/2
✓ Branch 47 → 48 taken 1 time.
✓ Branch 48 → 49 taken 1 time.
1 << maxCachedUid << ")";
1966 // No new messages → go straight to flag sync / IDLE
1967
1/1
✓ Branch 53 → 54 taken 1 time.
1 MailJD::Performance::FolderPerformanceTracker::instance()
1968
1/1
✓ Branch 54 → 55 taken 1 time.
1 .headerFetchFinished(m_folder.folderGeneration);
1969
2/2
✓ Branch 55 → 56 taken 1 time.
✓ Branch 56 → 57 taken 1 time.
1 MailJD::Performance::FolderPerformanceTracker::instance().flagsStarted(
1970 m_folder.folderGeneration);
1971
1/1
✓ Branch 57 → 58 taken 1 time.
1 m_imap->fetchFlags();
1972 1 return;
1973 }
1974
1975 // Sort descending (newest UID first)
1976 3 QList<qint64> sorted = newUids;
1977
3/3
✓ Branch 60 → 61 taken 3 times.
✓ Branch 61 → 62 taken 3 times.
✓ Branch 62 → 63 taken 3 times.
3 std::sort(sorted.begin(), sorted.end(), std::greater<qint64>());
1978
1979
1/1
✓ Branch 63 → 64 taken 3 times.
3 m_reverseChunks = reverseFetchChunks(sorted);
1980
7/7
✓ Branch 66 → 67 taken 3 times.
✓ Branch 70 → 71 taken 3 times.
✓ Branch 71 → 72 taken 3 times.
✓ Branch 73 → 74 taken 3 times.
✓ Branch 74 → 75 taken 3 times.
✓ Branch 78 → 68 taken 3 times.
✓ Branch 78 → 79 taken 3 times.
6 qCInfo(lcController) << "Reverse fetch:" << m_reverseChunks.size() << "chunks";
1981
1982 // Start fetching first chunk (newest UIDs)
1983
1/1
✓ Branch 79 → 80 taken 3 times.
3 fetchNextChunk();
1984 4 }
1985
1986 10 void MailController::fetchNextChunk() {
1987 // T-113: Discard if folder changed since chunks were queued
1988
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 17 taken 9 times.
10 if (m_folder.activeFolderGen != m_folder.folderGeneration) {
1989
1/1
✓ Branch 3 → 4 taken 1 time.
1 MailJD::Performance::FolderPerformanceTracker::instance()
1990
1/1
✓ Branch 4 → 5 taken 1 time.
1 .staleGenerationDiscarded(m_folder.activeFolderGen);
1991 2 qCInfo(lcController) << "Discarding stale chunks (folder changed)"; // GCOVR_EXCL_BR_LINE — Qt logging-macro cleanup only; generation check remains measured.
1992
1/1
✓ Branch 15 → 16 taken 1 time.
1 m_reverseChunks.clear();
1993 2 return;
1994 }
1995
1996 // The connection re-enters IDLE after every completed command, and this
1997 // step runs on a later event-loop turn. Commands issued directly while
1998 // idling are rejected ("no folder selected"), which silently ended the
1999 // backfill after its first 50-UID chunk. executeAfterIdle leaves IDLE
2000 // first; a folder switch drops the deferred command.
2001 9 const quint64 generation = m_folder.folderGeneration;
2002
2/2
✓ Branch 18 → 19 taken 1 time.
✓ Branch 18 → 23 taken 8 times.
9 if (m_reverseChunks.isEmpty()) {
2003 // All chunks fetched → final flag sync
2004
1/1
✓ Branch 20 → 21 taken 1 time.
1 m_imap->executeAfterIdle([this, generation] {
2005
1/2
✓ Branch 2 → 3 taken 1 time.
✗ Branch 2 → 4 not taken.
1 if (generation == m_folder.folderGeneration) m_imap->fetchFlags();
2006 1 });
2007 1 return;
2008 }
2009
2010
1/1
✓ Branch 23 → 24 taken 8 times.
8 auto chunk = m_reverseChunks.takeFirst();
2011
6/6
✓ Branch 24 → 25 taken 8 times.
✓ Branch 28 → 29 taken 8 times.
✓ Branch 29 → 30 taken 8 times.
✓ Branch 31 → 32 taken 8 times.
✓ Branch 38 → 26 taken 8 times.
✓ Branch 38 → 39 taken 8 times.
16 qCInfo(lcController) << "Fetching chunk:" << chunk.size()
2012
2/2
✓ Branch 32 → 33 taken 8 times.
✓ Branch 34 → 35 taken 8 times.
8 << "UIDs, remaining chunks:" << m_reverseChunks.size();
2013
2/4
✓ Branch 40 → 41 taken 8 times.
✓ Branch 41 → 42 taken 8 times.
✗ Branch 66 → 67 not taken.
✗ Branch 66 → 68 not taken.
8 m_imap->executeAfterIdle([this, chunk, generation] {
2014
2/2
✓ Branch 2 → 3 taken 1 time.
✓ Branch 2 → 4 taken 7 times.
8 if (generation != m_folder.folderGeneration) return;
2015
1/2
✓ Branch 5 → 6 taken 7 times.
✗ Branch 5 → 7 not taken.
7 if (beginHeaderStream(chunk)) m_imap->fetchHeadersByUids(chunk);
2016 });
2017 8 }
2018
2019 // ═══════════════════════════════════════════════════════
2020 // Flag Management (T-059)
2021 // ═══════════════════════════════════════════════════════
2022
2023 143 std::optional<MailHeader> MailController::loadedHeader(qint64 folderId, qint64 uid) const {
2024 143 const int row = m_model->rowForUid(uid, folderId);
2025
2/2
✓ Branch 3 → 4 taken 139 times.
✓ Branch 3 → 7 taken 4 times.
143 if (row >= 0)
2026
1/2
✓ Branch 5 → 6 taken 139 times.
✗ Branch 5 → 7 not taken.
139 if (const auto *header = m_model->headerAt(row)) return *header;
2027
2/2
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 22 taken 3 times.
4 if (m_indexedHeader)
2028
3/7
✓ Branch 10 → 11 taken 1 time.
✓ Branch 13 → 14 taken 1 time.
✗ Branch 13 → 16 not taken.
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 21 taken 1 time.
✗ Branch 26 → 27 not taken.
✗ Branch 26 → 28 not taken.
1 if (auto header = m_indexedHeader(MailId{m_folder.accountId, folderId, uid})) return header;
2029 3 return m_cache->header(folderId, uid);
2030 }
2031
2032 801 bool MailController::startSeenVisualMutation(qint64 uid, qint64 folderId,
2033 bool desiredSeen, std::optional<bool> visibleSeen) {
2034 801 return startFlagVisualMutation(uid, folderId, MutationKind::Seen,
2035 801 desiredSeen, visibleSeen);
2036 }
2037
2038 844 bool MailController::startFlagVisualMutation(qint64 uid, qint64 folderId,
2039 MutationKind kind, bool desiredState, std::optional<bool> visibleState) {
2040
3/4
✓ Branch 2 → 3 taken 43 times.
✓ Branch 2 → 5 taken 801 times.
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 43 times.
844 if (kind != MutationKind::Seen && kind != MutationKind::Flagged) return false;
2041 844 const bool isSeen = kind == MutationKind::Seen;
2042
2/2
✓ Branch 5 → 6 taken 801 times.
✓ Branch 5 → 7 taken 43 times.
844 const quint32 bit = isSeen ? MailFlag::Seen : MailFlag::Flagged;
2043
1/1
✓ Branch 8 → 9 taken 844 times.
844 const auto loaded = m_model->flagsFor(uid, folderId);
2044
6/6
✓ Branch 10 → 11 taken 162 times.
✓ Branch 10 → 14 taken 682 times.
✓ Branch 12 → 13 taken 102 times.
✓ Branch 12 → 14 taken 60 times.
✓ Branch 15 → 16 taken 102 times.
✓ Branch 15 → 17 taken 742 times.
844 if (!loaded && !visibleState) return false;
2045
2/2
✓ Branch 18 → 19 taken 60 times.
✓ Branch 18 → 21 taken 682 times.
742 const bool previous = visibleState ? *visibleState : bool(*loaded & bit);
2046
2/2
✓ Branch 23 → 24 taken 581 times.
✓ Branch 23 → 25 taken 161 times.
742 if (previous == desiredState) return true;
2047 161 const MailId id{m_folder.accountId, folderId, uid};
2048
1/2
✗ Branch 27 → 28 not taken.
✓ Branch 27 → 29 taken 161 times.
161 if (!id.isValid()) return false;
2049 161 const QString databasePath = m_cache->databasePath();
2050
1/2
✗ Branch 31 → 32 not taken.
✓ Branch 31 → 33 taken 161 times.
161 if (databasePath.isEmpty()) return false;
2051
2052 161 const quint64 generation = ++m_nextFlagIntentGeneration;
2053
2/2
✓ Branch 33 → 34 taken 118 times.
✓ Branch 33 → 35 taken 43 times.
161 auto &generations = isSeen ? m_seenIntentGenerations : m_starIntentGenerations;
2054
1/1
✓ Branch 36 → 37 taken 161 times.
161 generations.insert(id, generation);
2055
2/2
✓ Branch 37 → 38 taken 118 times.
✓ Branch 37 → 39 taken 43 times.
161 auto &visualDesired = isSeen ? m_seenVisualDesired : m_starVisualDesired;
2056
1/1
✓ Branch 40 → 41 taken 161 times.
161 visualDesired.insert(id, desiredState);
2057
1/1
✓ Branch 42 → 43 taken 161 times.
161 m_mutations.intents().markFlag(id);
2058
1/1
✓ Branch 43 → 44 taken 161 times.
161 auto &tracker = MailJD::Performance::MutationPerformanceTracker::instance();
2059
3/5
✓ Branch 60 → 61 taken 161 times.
✓ Branch 63 → 64 taken 161 times.
✓ Branch 63 → 65 taken 161 times.
✗ Branch 124 → 125 not taken.
✗ Branch 124 → 126 not taken.
322 tracker.beginMutation(1, {isSeen
2060
2/6
✓ Branch 71 → 72 taken 104 times.
✓ Branch 71 → 73 taken 57 times.
✗ Branch 135 → 136 not taken.
✗ Branch 135 → 137 not taken.
✗ Branch 139 → 140 not taken.
✗ Branch 139 → 141 not taken.
161 ? (desiredState ? QStringLiteral("seen") : QStringLiteral("unseen"))
2061
6/10
✓ Branch 65 → 66 taken 17 times.
✓ Branch 65 → 67 taken 144 times.
✓ Branch 67 → 68 taken 26 times.
✓ Branch 67 → 69 taken 135 times.
✓ Branch 69 → 70 taken 14 times.
✓ Branch 69 → 71 taken 147 times.
✗ Branch 127 → 128 not taken.
✗ Branch 127 → 129 not taken.
✗ Branch 131 → 132 not taken.
✗ Branch 131 → 133 not taken.
161 : (desiredState ? QStringLiteral("flagged") : QStringLiteral("unflagged"))});
2062
1/1
✓ Branch 73 → 74 taken 161 times.
161 tracker.modelBadgeStarted();
2063
2/2
✓ Branch 75 → 76 taken 148 times.
✓ Branch 75 → 84 taken 13 times.
161 if (loaded) {
2064
2/2
✓ Branch 76 → 77 taken 117 times.
✓ Branch 76 → 79 taken 31 times.
148 const quint32 flags = desiredState ? (*loaded | bit) : (*loaded & ~bit);
2065
1/1
✓ Branch 81 → 82 taken 148 times.
148 m_model->updateFlags(uid, flags, folderId);
2066
3/3
✓ Branch 82 → 83 taken 142 times.
✓ Branch 82 → 84 taken 6 times.
✓ Branch 83 → 84 taken 142 times.
148 if (m_threadModel) m_threadModel->updateFlags(uid, flags, folderId);
2067 }
2068 // A row the flat window does not hold (indexed thread page, bounded
2069 // window) still changes the folder's unread total, or the folder badge
2070 // would stay stale until the folder is reloaded.
2071
6/6
✓ Branch 84 → 85 taken 118 times.
✓ Branch 84 → 89 taken 43 times.
✓ Branch 86 → 87 taken 13 times.
✓ Branch 86 → 89 taken 105 times.
✓ Branch 87 → 88 taken 8 times.
✓ Branch 87 → 89 taken 5 times.
161 const bool unreadAdjustedOutside = isSeen && !loaded && folderId == m_folder.currentFolderId;
2072
4/5
✓ Branch 90 → 91 taken 8 times.
✓ Branch 90 → 95 taken 153 times.
✓ Branch 91 → 92 taken 8 times.
✗ Branch 91 → 93 not taken.
✓ Branch 94 → 95 taken 8 times.
161 if (unreadAdjustedOutside) m_model->adjustUnreadCount(desiredState ? -1 : 1);
2073
3/3
✓ Branch 95 → 96 taken 118 times.
✓ Branch 95 → 97 taken 43 times.
✓ Branch 96 → 98 taken 118 times.
161 if (isSeen) emit seenVisualChanged(folderId, uid, desiredState);
2074
1/1
✓ Branch 97 → 98 taken 43 times.
43 else emit starVisualChanged(folderId, uid, desiredState);
2075
4/4
✓ Branch 98 → 99 taken 118 times.
✓ Branch 98 → 102 taken 43 times.
✓ Branch 99 → 100 taken 111 times.
✓ Branch 99 → 102 taken 7 times.
161 if (isSeen && folderId == m_folder.currentFolderId)
2076
2/2
✓ Branch 100 → 101 taken 111 times.
✓ Branch 101 → 102 taken 111 times.
111 emit unreadCountChanged(m_folder.currentFolder, m_model->unreadCount());
2077
1/1
✓ Branch 102 → 103 taken 161 times.
161 tracker.modelBadgeFinished();
2078
1/1
✓ Branch 103 → 104 taken 161 times.
161 tracker.perceivedCommitted();
2079
2080
2/2
✓ Branch 104 → 105 taken 161 times.
✓ Branch 111 → 112 taken 161 times.
322 MailSelectionMutationService::shared()->setFlag(this, databasePath, id,
2081
1/5
✓ Branch 108 → 109 taken 161 times.
✗ Branch 160 → 161 not taken.
✗ Branch 160 → 162 not taken.
✗ Branch 163 → 164 not taken.
✗ Branch 163 → 165 not taken.
483 kind, desiredState, [this, id, databasePath, generation, desiredState, bit, isSeen,
2082 161 unreadAdjustedOutside, unreadBasis = m_model->unreadBasis()](
2083 MailSelectionMutationService::FlagResult result) {
2084 // Accepted intents continue on the worker after a profile or account
2085 // switch. Their journal is replayed when that account returns; their
2086 // completion must not change the new account's visible state.
2087
4/8
✓ Branch 4 → 5 taken 156 times.
✗ Branch 4 → 7 not taken.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 156 times.
✓ Branch 9 → 10 taken 156 times.
✗ Branch 9 → 11 not taken.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 156 times.
156 if (databasePath != m_cache->databasePath() || id.accountId != m_folder.accountId)
2088 ✗ return;
2089
2/2
✓ Branch 13 → 14 taken 115 times.
✓ Branch 13 → 15 taken 41 times.
156 auto &generations = isSeen ? m_seenIntentGenerations : m_starIntentGenerations;
2090
2/2
✓ Branch 17 → 18 taken 3 times.
✓ Branch 17 → 19 taken 153 times.
156 if (generations.value(id) != generation) return;
2091 153 generations.remove(id);
2092
2/2
✓ Branch 20 → 21 taken 113 times.
✓ Branch 20 → 22 taken 40 times.
153 auto &visualDesired = isSeen ? m_seenVisualDesired : m_starVisualDesired;
2093 153 visualDesired.remove(id);
2094
2/2
✓ Branch 24 → 25 taken 5 times.
✓ Branch 24 → 73 taken 148 times.
153 if (!result.journaled) {
2095
2/4
✓ Branch 26 → 27 taken 5 times.
✗ Branch 26 → 30 not taken.
✓ Branch 31 → 32 taken 5 times.
✗ Branch 31 → 34 not taken.
10 if (!m_seenIntentGenerations.contains(id) &&
2096
1/2
✓ Branch 28 → 29 taken 5 times.
✗ Branch 28 → 30 not taken.
10 !m_starIntentGenerations.contains(id)) m_mutations.intents().clearFlag(id);
2097
3/3
✓ Branch 34 → 35 taken 5 times.
✓ Branch 36 → 37 taken 1 time.
✓ Branch 36 → 47 taken 4 times.
5 if (const auto current = m_model->flagsFor(id.uid, id.folderId)) {
2098
1/2
✓ Branch 38 → 39 taken 1 time.
✗ Branch 38 → 58 not taken.
1 if (bool(*current & bit) == desiredState) {
2099
1/2
✓ Branch 39 → 40 taken 1 time.
✗ Branch 39 → 42 not taken.
1 const quint32 restored = desiredState ? (*current & ~bit)
2100 ✗ : (*current | bit);
2101
1/1
✓ Branch 44 → 45 taken 1 time.
1 m_model->updateFlags(id.uid, restored, id.folderId);
2102
1/2
✓ Branch 45 → 46 taken 1 time.
✗ Branch 45 → 58 not taken.
1 if (m_threadModel)
2103
1/1
✓ Branch 46 → 58 taken 1 time.
1 m_threadModel->updateFlags(id.uid, restored, id.folderId);
2104 }
2105
5/6
✓ Branch 47 → 48 taken 3 times.
✓ Branch 47 → 52 taken 1 time.
✓ Branch 48 → 49 taken 3 times.
✗ Branch 48 → 52 not taken.
✓ Branch 53 → 54 taken 2 times.
✓ Branch 53 → 58 taken 2 times.
7 } else if (unreadAdjustedOutside && id.folderId == m_folder.currentFolderId &&
2106
2/2
✓ Branch 50 → 51 taken 2 times.
✓ Branch 50 → 52 taken 1 time.
3 m_model->unreadBasis() == unreadBasis) { // S106-184: not after a reload's total
2107
2/3
✓ Branch 54 → 55 taken 2 times.
✗ Branch 54 → 56 not taken.
✓ Branch 57 → 58 taken 2 times.
2 m_model->adjustUnreadCount(desiredState ? 1 : -1);
2108 }
2109
1/2
✓ Branch 58 → 59 taken 5 times.
✗ Branch 58 → 60 not taken.
5 if (isSeen) emit seenVisualChanged(id.folderId, id.uid, !desiredState);
2110 ✗ else emit starVisualChanged(id.folderId, id.uid, !desiredState);
2111
3/4
✓ Branch 61 → 62 taken 5 times.
✗ Branch 61 → 65 not taken.
✓ Branch 62 → 63 taken 4 times.
✓ Branch 62 → 65 taken 1 time.
5 if (isSeen && id.folderId == m_folder.currentFolderId)
2112 4 emit unreadCountChanged(m_folder.currentFolder, m_model->unreadCount());
2113
1/1
✓ Branch 70 → 71 taken 5 times.
5 emit actionError(isSeen
2114
3/7
✓ Branch 65 → 66 taken 5 times.
✗ Branch 65 → 69 not taken.
✓ Branch 66 → 67 taken 5 times.
✗ Branch 66 → 68 not taken.
✓ Branch 67 → 70 taken 5 times.
✗ Branch 68 → 70 not taken.
✗ Branch 69 → 70 not taken.
10 ? (desiredState ? tr("The read state could not be saved. Nothing was changed.")
2115 : tr("The unread state could not be saved. Nothing was changed."))
2116 : tr("The star state could not be saved. Nothing was changed."));
2117 5 return;
2118 }
2119 148 quint32 announcedFlags = result.flags;
2120
1/2
✗ Branch 74 → 75 not taken.
✓ Branch 74 → 80 taken 148 times.
148 if (m_seenVisualDesired.contains(id))
2121 ✗ announcedFlags = m_seenVisualDesired.value(id)
2122 ✗ ? (announcedFlags | MailFlag::Seen) : (announcedFlags & ~MailFlag::Seen);
2123
2/2
✓ Branch 81 → 82 taken 1 time.
✓ Branch 81 → 87 taken 147 times.
148 if (m_starVisualDesired.contains(id))
2124 2 announcedFlags = m_starVisualDesired.value(id)
2125
1/2
✓ Branch 83 → 84 taken 1 time.
✗ Branch 83 → 85 not taken.
1 ? (announcedFlags | MailFlag::Flagged) : (announcedFlags & ~MailFlag::Flagged);
2126 148 m_cache->announceFlagsCommitted(id.folderId, id.uid, announcedFlags);
2127
2/3
✓ Branch 89 → 90 taken 148 times.
✓ Branch 91 → 92 taken 148 times.
✗ Branch 91 → 94 not taken.
148 if (const auto record = m_cache->journal().mutation(result.record.token))
2128
1/1
✓ Branch 93 → 94 taken 148 times.
148 m_mutations.dispatchMutation(*record);
2129 });
2130 161 return true;
2131 483 }
2132
2133 39 void MailController::applyVisibleLabel(qint64 uid, qint64 folderId, const QString &label,
2134 bool present) {
2135 39 const int row = m_model->rowForUid(uid, folderId);
2136
1/2
✓ Branch 4 → 5 taken 39 times.
✗ Branch 4 → 26 not taken.
39 if (auto *visible = m_model->mutableHeaderAt(row)) {
2137 39 const bool has = visible->labels.contains(label);
2138
3/4
✓ Branch 6 → 7 taken 25 times.
✓ Branch 6 → 11 taken 14 times.
✓ Branch 7 → 8 taken 25 times.
✗ Branch 7 → 11 not taken.
39 if (present && !has) {
2139
1/1
✓ Branch 8 → 9 taken 25 times.
25 visible->labels.append(label);
2140
1/1
✓ Branch 9 → 10 taken 25 times.
25 visible->labels.sort(Qt::CaseInsensitive);
2141
2/4
✓ Branch 11 → 12 taken 14 times.
✗ Branch 11 → 14 not taken.
✓ Branch 12 → 13 taken 14 times.
✗ Branch 12 → 14 not taken.
14 } else if (!present && has) {
2142
1/1
✓ Branch 13 → 14 taken 14 times.
14 visible->labels.removeAll(label);
2143 }
2144
1/1
✓ Branch 15 → 16 taken 39 times.
39 const QModelIndex first = m_model->index(row, 0);
2145
3/3
✓ Branch 19 → 20 taken 39 times.
✓ Branch 20 → 21 taken 39 times.
✓ Branch 21 → 22 taken 39 times.
39 emit m_model->dataChanged(first, m_model->index(row, m_model->columnCount() - 1));
2146
3/3
✓ Branch 23 → 24 taken 36 times.
✓ Branch 23 → 25 taken 3 times.
✓ Branch 24 → 25 taken 36 times.
39 if (m_threadModel) m_threadModel->updateLabels(uid, visible->labels, folderId);
2147 }
2148 39 emit labelVisualChanged(folderId, uid, label, present);
2149 39 }
2150
2151 58 MailController::LabelVisualStart MailController::startLabelVisualMutation(
2152 qint64 uid, qint64 folderId, const QString &label, bool present,
2153 std::optional<QStringList> visibleLabels) {
2154
4/7
✓ Branch 3 → 4 taken 58 times.
✗ Branch 3 → 8 not taken.
✓ Branch 6 → 7 taken 58 times.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 58 times.
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 58 times.
58 if (label.isEmpty() || label.startsWith(QLatin1Char('\\'))) return LabelVisualStart::Unavailable;
2155
2/2
✓ Branch 12 → 13 taken 58 times.
✓ Branch 13 → 14 taken 58 times.
58 const MailHeader *loaded = m_model->headerAt(m_model->rowForUid(uid, folderId));
2156
6/6
✓ Branch 15 → 16 taken 52 times.
✓ Branch 15 → 18 taken 6 times.
✓ Branch 16 → 17 taken 35 times.
✓ Branch 16 → 18 taken 17 times.
✓ Branch 19 → 20 taken 35 times.
✓ Branch 19 → 21 taken 23 times.
58 if (!visibleLabels && loaded) visibleLabels = loaded->labels;
2157
2/2
✓ Branch 22 → 23 taken 17 times.
✓ Branch 22 → 24 taken 41 times.
58 if (!visibleLabels) return LabelVisualStart::Unavailable;
2158
2/2
✓ Branch 26 → 27 taken 5 times.
✓ Branch 26 → 28 taken 36 times.
41 if (visibleLabels->contains(label) == present) return LabelVisualStart::Unchanged;
2159 36 const MailId id{m_folder.accountId, folderId, uid};
2160 36 const QString databasePath = m_cache->databasePath();
2161
3/6
✓ Branch 31 → 32 taken 36 times.
✗ Branch 31 → 34 not taken.
✗ Branch 33 → 34 not taken.
✓ Branch 33 → 35 taken 36 times.
✗ Branch 36 → 37 not taken.
✓ Branch 36 → 38 taken 36 times.
36 if (!id.isValid() || databasePath.isEmpty()) return LabelVisualStart::Unavailable;
2162 // The durable writer requires a confirmed epoch; mailboxes never selected
2163 // (e.g. server search results) keep the synchronous journal path. The
2164 // primary cache serves the epoch from memory.
2165
3/3
✓ Branch 38 → 39 taken 36 times.
✓ Branch 39 → 40 taken 1 time.
✓ Branch 39 → 41 taken 35 times.
36 if (m_cache->uidValidity(folderId) == 0) return LabelVisualStart::Unavailable;
2166
2167 35 const quint64 generation = ++m_nextFlagIntentGeneration;
2168
2/2
✓ Branch 41 → 42 taken 35 times.
✓ Branch 42 → 43 taken 35 times.
35 m_labelIntentGenerations[id].insert(label, generation);
2169
1/1
✓ Branch 43 → 44 taken 35 times.
35 auto &tracker = MailJD::Performance::MutationPerformanceTracker::instance();
2170
7/13
✓ Branch 52 → 53 taken 35 times.
✓ Branch 55 → 56 taken 35 times.
✓ Branch 55 → 57 taken 35 times.
✓ Branch 57 → 58 taken 13 times.
✓ Branch 57 → 59 taken 22 times.
✓ Branch 59 → 60 taken 22 times.
✓ Branch 59 → 61 taken 13 times.
✗ Branch 89 → 90 not taken.
✗ Branch 89 → 91 not taken.
✗ Branch 92 → 93 not taken.
✗ Branch 92 → 94 not taken.
✗ Branch 96 → 97 not taken.
✗ Branch 96 → 98 not taken.
70 tracker.beginMutation(1, {present ? QStringLiteral("label_add") : QStringLiteral("label_remove")});
2171
1/1
✓ Branch 61 → 62 taken 35 times.
35 tracker.modelBadgeStarted();
2172
1/1
✓ Branch 62 → 63 taken 35 times.
35 applyVisibleLabel(uid, folderId, label, present);
2173
1/1
✓ Branch 63 → 64 taken 35 times.
35 tracker.modelBadgeFinished();
2174
1/1
✓ Branch 64 → 65 taken 35 times.
35 tracker.perceivedCommitted();
2175
2176
2/2
✓ Branch 65 → 66 taken 35 times.
✓ Branch 73 → 74 taken 35 times.
70 MailSelectionMutationService::shared()->setKeyword(this, databasePath, id, label, present,
2177
1/7
✓ Branch 69 → 70 taken 35 times.
✗ Branch 120 → 121 not taken.
✗ Branch 120 → 122 not taken.
✗ Branch 123 → 124 not taken.
✗ Branch 123 → 125 not taken.
✗ Branch 126 → 127 not taken.
✗ Branch 126 → 128 not taken.
70 [this, id, label, databasePath, generation, present](
2178 MailSelectionMutationService::FlagResult result) {
2179 // Accepted intents continue on the worker after an account switch; their
2180 // completion must not change the new account's visible state.
2181
4/8
✓ Branch 4 → 5 taken 29 times.
✗ Branch 4 → 7 not taken.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 29 times.
✓ Branch 9 → 10 taken 29 times.
✗ Branch 9 → 11 not taken.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 29 times.
31 if (databasePath != m_cache->databasePath() || id.accountId != m_folder.accountId) return;
2182
1/1
✓ Branch 13 → 14 taken 29 times.
29 const auto intents = m_labelIntentGenerations.find(id);
2183
5/6
✓ Branch 16 → 17 taken 29 times.
✗ Branch 16 → 20 not taken.
✓ Branch 19 → 20 taken 1 time.
✓ Branch 19 → 21 taken 28 times.
✓ Branch 22 → 23 taken 1 time.
✓ Branch 22 → 24 taken 28 times.
29 if (intents == m_labelIntentGenerations.end() || intents->value(label) != generation) return;
2184
1/1
✓ Branch 25 → 26 taken 28 times.
28 intents->remove(label);
2185
3/3
✓ Branch 28 → 29 taken 26 times.
✓ Branch 28 → 32 taken 2 times.
✓ Branch 30 → 31 taken 26 times.
28 if (intents->isEmpty()) m_labelIntentGenerations.erase(intents);
2186
2/2
✓ Branch 32 → 33 taken 1 time.
✓ Branch 32 → 51 taken 27 times.
28 if (!result.journaled) {
2187
6/6
✓ Branch 33 → 34 taken 1 time.
✓ Branch 37 → 38 taken 1 time.
✓ Branch 38 → 39 taken 1 time.
✓ Branch 39 → 40 taken 1 time.
✓ Branch 43 → 35 taken 1 time.
✓ Branch 43 → 44 taken 1 time.
2 qCWarning(lcController) << "Label journal rejected desired state:" << result.error;
2188
1/1
✓ Branch 44 → 45 taken 1 time.
1 applyVisibleLabel(id.uid, id.folderId, label, !present);
2189
3/5
✓ Branch 45 → 46 taken 1 time.
✗ Branch 45 → 47 not taken.
✓ Branch 46 → 48 taken 1 time.
✗ Branch 47 → 48 not taken.
✓ Branch 48 → 49 taken 1 time.
1 emit actionError(present ? tr("The label could not be saved. Nothing was changed.")
2190 : tr("The label could not be removed. Nothing was changed."));
2191 1 return;
2192 }
2193
1/1
✓ Branch 51 → 52 taken 27 times.
27 m_cache->announceLabelCommitted(id.folderId, id.uid, label, present);
2194
2/3
✓ Branch 53 → 54 taken 27 times.
✓ Branch 55 → 56 taken 27 times.
✗ Branch 55 → 58 not taken.
27 if (const auto record = m_cache->journal().mutation(result.record.token))
2195
1/1
✓ Branch 57 → 58 taken 27 times.
27 m_mutations.dispatchMutation(*record);
2196 });
2197 35 return LabelVisualStart::Started;
2198 106 }
2199
2200 58 void MailController::markMailAsSeen(qint64 uid) {
2201
3/3
✓ Branch 3 → 4 taken 58 times.
✓ Branch 4 → 5 taken 14 times.
✓ Branch 4 → 6 taken 44 times.
59 if (startSeenVisualMutation(uid, m_folder.currentFolderId, true)) return;
2202 // P90.15: decide the toggle from the loaded projection (O(1) hash lookup,
2203 // no SQL) and commit the visual row/badge state before any journal or
2204 // cache write. The durable phase then re-validates against the canonical
2205 // header; a failed enqueue rolls the visual state back generation-checked.
2206
1/1
✓ Branch 6 → 7 taken 44 times.
44 const auto loaded = m_model->flagsFor(uid, m_folder.currentFolderId);
2207
2/6
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 12 taken 44 times.
✗ Branch 10 → 11 not taken.
✗ Branch 10 → 12 not taken.
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 27 taken 44 times.
44 if (loaded && (*loaded & MailFlag::Seen)) {
2208 ✗ qCInfo(lcController) << "markMailAsSeen: UID" << uid
2209 ✗ << "already seen in loaded projection, skipping";
2210 ✗ return;
2211 }
2212 auto &performanceTracker =
2213
1/1
✓ Branch 27 → 28 taken 44 times.
44 MailJD::Performance::MutationPerformanceTracker::instance();
2214
3/5
✓ Branch 32 → 33 taken 44 times.
✓ Branch 35 → 36 taken 44 times.
✓ Branch 35 → 37 taken 44 times.
✗ Branch 144 → 145 not taken.
✗ Branch 144 → 146 not taken.
88 performanceTracker.beginMutation(1, {QStringLiteral("seen")});
2215 44 const bool visualFirst = loaded.has_value();
2216
1/2
✗ Branch 39 → 40 not taken.
✓ Branch 39 → 49 taken 44 times.
44 if (visualFirst) {
2217 ✗ const quint32 newFlags = *loaded | MailFlag::Seen;
2218 ✗ performanceTracker.modelBadgeStarted();
2219 ✗ m_model->updateFlags(uid, newFlags, m_folder.currentFolderId);
2220 ✗ if (m_threadModel)
2221 ✗ m_threadModel->updateFlags(uid, newFlags, m_folder.currentFolderId);
2222 ✗ emit unreadCountChanged(m_folder.currentFolder, m_model->unreadCount());
2223 ✗ performanceTracker.modelBadgeFinished();
2224 ✗ performanceTracker.perceivedCommitted();
2225 }
2226 1 const auto rollbackVisual = [this, uid, loaded, visualFirst]() {
2227
1/2
✓ Branch 2 → 3 taken 1 time.
✗ Branch 2 → 4 not taken.
1 if (!visualFirst) return;
2228 ✗ m_model->updateFlags(uid, *loaded, m_folder.currentFolderId);
2229 ✗ if (m_threadModel)
2230 ✗ m_threadModel->updateFlags(uid, *loaded, m_folder.currentFolderId);
2231 ✗ emit unreadCountChanged(m_folder.currentFolder, m_model->unreadCount());
2232 ✗ emit actionError(tr("The read state could not be saved. Nothing was changed."));
2233 44 };
2234 // Durable phase: canonical existence, exact flags and journal first …
2235
1/1
✓ Branch 49 → 50 taken 44 times.
44 auto header = m_cache->header(m_folder.currentFolderId, uid);
2236
2/2
✓ Branch 51 → 52 taken 1 time.
✓ Branch 51 → 68 taken 43 times.
44 if (!header) {
2237
6/6
✓ Branch 52 → 53 taken 1 time.
✓ Branch 56 → 57 taken 1 time.
✓ Branch 57 → 58 taken 1 time.
✓ Branch 58 → 59 taken 1 time.
✓ Branch 65 → 54 taken 1 time.
✓ Branch 65 → 66 taken 1 time.
2 qCWarning(lcController) << "markMailAsSeen: UID" << uid
2238
2/2
✓ Branch 59 → 60 taken 1 time.
✓ Branch 60 → 61 taken 1 time.
1 << "not found in cache for folderId" << m_folder.currentFolderId
2239
1/1
✓ Branch 61 → 62 taken 1 time.
1 << "— cannot mark as seen";
2240
1/1
✓ Branch 66 → 67 taken 1 time.
1 rollbackVisual();
2241 1 return;
2242 }
2243
1/2
✗ Branch 70 → 71 not taken.
✓ Branch 70 → 72 taken 43 times.
43 if (header->isSeen()) return; // Visual state already correct.
2244
1/1
✓ Branch 75 → 76 taken 43 times.
86 const qint64 mutationToken = m_mutations.persistDesiredMutation(
2245 86 m_folder.currentFolderId, uid, MutationKind::Seen, QStringLiteral("\\Seen"),
2246 true);
2247
1/2
✗ Branch 78 → 79 not taken.
✓ Branch 78 → 81 taken 43 times.
43 if (mutationToken == 0) {
2248 ✗ rollbackVisual();
2249 ✗ return;
2250 }
2251
2252 // … then the canonical cache write (re-applies the same flags through
2253 // headerFlagsChanged; idempotent for the visual state).
2254 43 quint32 newFlags = header->flags | MailFlag::Seen;
2255
1/1
✓ Branch 82 → 83 taken 43 times.
43 performanceTracker.cacheStarted();
2256
1/1
✓ Branch 83 → 84 taken 43 times.
43 m_cache->updateFlags(m_folder.currentFolderId, uid, newFlags);
2257
1/1
✓ Branch 84 → 85 taken 43 times.
43 performanceTracker.cacheFinished();
2258
1/2
✓ Branch 85 → 86 taken 43 times.
✗ Branch 85 → 94 not taken.
43 if (!visualFirst) {
2259
1/1
✓ Branch 86 → 87 taken 43 times.
43 performanceTracker.modelBadgeStarted();
2260
1/1
✓ Branch 87 → 88 taken 43 times.
43 m_model->updateFlags(uid, newFlags, m_folder.currentFolderId);
2261
1/2
✓ Branch 88 → 89 taken 43 times.
✗ Branch 88 → 90 not taken.
43 if (m_threadModel)
2262
1/1
✓ Branch 89 → 90 taken 43 times.
43 m_threadModel->updateFlags(uid, newFlags, m_folder.currentFolderId);
2263
2/2
✓ Branch 90 → 91 taken 43 times.
✓ Branch 91 → 92 taken 43 times.
43 emit unreadCountChanged(m_folder.currentFolder, m_model->unreadCount());
2264
1/1
✓ Branch 92 → 93 taken 43 times.
43 performanceTracker.modelBadgeFinished();
2265
1/1
✓ Branch 93 → 94 taken 43 times.
43 performanceTracker.perceivedCommitted();
2266 }
2267
2268
7/7
✓ Branch 94 → 95 taken 43 times.
✓ Branch 98 → 99 taken 43 times.
✓ Branch 99 → 100 taken 43 times.
✓ Branch 100 → 101 taken 43 times.
✓ Branch 101 → 102 taken 43 times.
✓ Branch 116 → 96 taken 43 times.
✓ Branch 116 → 117 taken 43 times.
86 qCInfo(lcController) << "Marking UID" << uid << "as seen (optimistic)"
2269
2/2
✓ Branch 102 → 103 taken 43 times.
✓ Branch 103 → 104 taken 43 times.
43 << "folder:" << m_folder.currentFolder
2270
2/2
✓ Branch 104 → 105 taken 43 times.
✓ Branch 106 → 107 taken 43 times.
43 << "imapState:" << static_cast<int>(m_imap->state())
2271
3/3
✓ Branch 107 → 108 taken 43 times.
✓ Branch 108 → 109 taken 43 times.
✓ Branch 109 → 110 taken 43 times.
43 << "isNotifying:" << m_imap->isNotifying()
2272
3/3
✓ Branch 110 → 111 taken 43 times.
✓ Branch 111 → 112 taken 43 times.
✓ Branch 112 → 113 taken 43 times.
43 << "isIdling:" << m_imap->isIdling();
2273
2274 // Send STORE to server via executeAfterIdle (IDLE-safe)
2275 // T-201: Track pending flag to prevent onFlagsReceived reversion
2276
1/3
✓ Branch 119 → 120 taken 43 times.
✗ Branch 175 → 176 not taken.
✗ Branch 175 → 177 not taken.
43 m_mutations.intents().markFlag(MailId{m_folder.accountId, m_folder.currentFolderId, uid});
2277
2/3
✓ Branch 122 → 123 taken 43 times.
✓ Branch 124 → 125 taken 43 times.
✗ Branch 124 → 127 not taken.
43 if (const auto record = m_cache->journal().mutation(mutationToken))
2278
1/1
✓ Branch 126 → 127 taken 43 times.
43 m_mutations.dispatchMutation(*record);
2279 132 }
2280
2281 6 void MailController::markMailAsUnseen(qint64 uid) {
2282
2/3
✓ Branch 3 → 4 taken 6 times.
✓ Branch 4 → 5 taken 6 times.
✗ Branch 4 → 6 not taken.
6 if (startSeenVisualMutation(uid, m_folder.currentFolderId, false)) return;
2283 // P90.15: same visual-first split as markMailAsSeen — the loaded row
2284 // decides the toggle, the visual state commits before journal/SQL, and a
2285 // failed durable phase restores exactly the prior flags.
2286 ✗ const auto loaded = m_model->flagsFor(uid, m_folder.currentFolderId);
2287 ✗ if (loaded && !(*loaded & MailFlag::Seen)) {
2288 ✗ qCInfo(lcController) << "markMailAsUnseen: UID" << uid
2289 ✗ << "already unseen in loaded projection, skipping";
2290 ✗ return;
2291 }
2292 auto &performanceTracker =
2293 ✗ MailJD::Performance::MutationPerformanceTracker::instance();
2294 ✗ performanceTracker.beginMutation(1, {QStringLiteral("unseen")});
2295 ✗ const bool visualFirst = loaded.has_value();
2296 ✗ if (visualFirst) {
2297 ✗ const quint32 newFlags = *loaded & ~MailFlag::Seen;
2298 ✗ performanceTracker.modelBadgeStarted();
2299 ✗ m_model->updateFlags(uid, newFlags, m_folder.currentFolderId);
2300 ✗ if (m_threadModel)
2301 ✗ m_threadModel->updateFlags(uid, newFlags, m_folder.currentFolderId);
2302 ✗ emit unreadCountChanged(m_folder.currentFolder, m_model->unreadCount());
2303 ✗ performanceTracker.modelBadgeFinished();
2304 ✗ performanceTracker.perceivedCommitted();
2305 }
2306 ✗ const auto rollbackVisual = [this, uid, loaded, visualFirst]() {
2307 ✗ if (!visualFirst) return;
2308 ✗ m_model->updateFlags(uid, *loaded, m_folder.currentFolderId);
2309 ✗ if (m_threadModel)
2310 ✗ m_threadModel->updateFlags(uid, *loaded, m_folder.currentFolderId);
2311 ✗ emit unreadCountChanged(m_folder.currentFolder, m_model->unreadCount());
2312 ✗ emit actionError(tr("The unread state could not be saved. Nothing was changed."));
2313 ✗ };
2314 ✗ auto header = m_cache->header(m_folder.currentFolderId, uid);
2315 ✗ if (!header) {
2316 ✗ qCWarning(lcController) << "markMailAsUnseen: UID" << uid
2317 ✗ << "not found in cache for folderId" << m_folder.currentFolderId;
2318 ✗ rollbackVisual();
2319 ✗ return;
2320 }
2321 ✗ if (!header->isSeen()) return; // Visual state already correct.
2322 ✗ const qint64 mutationToken = m_mutations.persistDesiredMutation(
2323 ✗ m_folder.currentFolderId, uid, MutationKind::Seen, QStringLiteral("\\Seen"),
2324 false);
2325 ✗ if (mutationToken == 0) {
2326 ✗ rollbackVisual();
2327 ✗ return;
2328 }
2329
2330 // Optimistic update: clear Seen flag locally
2331 ✗ quint32 newFlags = header->flags & ~MailFlag::Seen;
2332 ✗ performanceTracker.cacheStarted();
2333 ✗ m_cache->updateFlags(m_folder.currentFolderId, uid, newFlags);
2334 ✗ performanceTracker.cacheFinished();
2335 ✗ if (!visualFirst) {
2336 ✗ performanceTracker.modelBadgeStarted();
2337 ✗ m_model->updateFlags(uid, newFlags, m_folder.currentFolderId);
2338 ✗ if (m_threadModel)
2339 ✗ m_threadModel->updateFlags(uid, newFlags, m_folder.currentFolderId);
2340 ✗ emit unreadCountChanged(m_folder.currentFolder, m_model->unreadCount());
2341 ✗ performanceTracker.modelBadgeFinished();
2342 ✗ performanceTracker.perceivedCommitted();
2343 }
2344
2345 ✗ qCInfo(lcController) << "Marking UID" << uid << "as unseen (optimistic)"
2346 ✗ << "folder:" << m_folder.currentFolder
2347 ✗ << "imapState:" << static_cast<int>(m_imap->state());
2348
2349 // Send STORE to server via executeAfterIdle (IDLE-safe)
2350 // T-201: Track pending flag to prevent onFlagsReceived reversion
2351 ✗ m_mutations.intents().markFlag(MailId{m_folder.accountId, m_folder.currentFolderId, uid});
2352 ✗ if (const auto record = m_cache->journal().mutation(mutationToken))
2353 ✗ m_mutations.dispatchMutation(*record);
2354 ✗ }
2355
2356 7 void MailController::toggleReadStatus(qint64 uid) {
2357
1/1
✓ Branch 2 → 3 taken 7 times.
7 const auto loaded = m_model->flagsFor(uid, m_folder.currentFolderId);
2358
2/2
✓ Branch 4 → 5 taken 6 times.
✓ Branch 4 → 6 taken 1 time.
7 const auto header = loaded ? std::optional<MailHeader>{}
2359
1/1
✓ Branch 6 → 7 taken 1 time.
7 : m_cache->header(m_folder.currentFolderId, uid);
2360
4/6
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 12 taken 6 times.
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 1 time.
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 7 times.
7 if (!loaded && !header) return;
2361
2/2
✓ Branch 16 → 17 taken 6 times.
✓ Branch 16 → 19 taken 1 time.
7 const bool wasSeen = loaded ? bool(*loaded & MailFlag::Seen) : header->isSeen();
2362
2/2
✓ Branch 22 → 23 taken 1 time.
✓ Branch 22 → 24 taken 6 times.
7 if (wasSeen) {
2363
1/1
✓ Branch 23 → 25 taken 1 time.
1 markMailAsUnseen(uid);
2364 } else {
2365
1/1
✓ Branch 24 → 25 taken 6 times.
6 markMailAsSeen(uid);
2366 }
2367
2368 // T-403/Bug 23: Undo — call the seen setters directly to avoid creating
2369 // a new undo entry (which would cause an infinite chain)
2370
1/2
✓ Branch 26 → 27 taken 7 times.
✗ Branch 26 → 36 not taken.
7 if (m_undoManager) {
2371 // T-620/FUNC-01: Capture folderId by value — folder may change before undo
2372 7 qint64 folderId = m_folder.currentFolderId;
2373
1/1
✓ Branch 32 → 33 taken 7 times.
14 m_undoManager->push(
2374
4/4
✓ Branch 29 → 30 taken 1 time.
✓ Branch 29 → 31 taken 6 times.
✓ Branch 30 → 32 taken 1 time.
✓ Branch 31 → 32 taken 6 times.
14 wasSeen ? tr("Marked as unread")
2375 : tr("Marked as read"),
2376
1/1
✓ Branch 28 → 29 taken 7 times.
14 [this, uid, folderId, wasSeen]() {
2377 // T-79.E1/M5: restore in the captured folder — after a folder
2378 // switch the plain setters would resolve the UID in the wrong
2379 // folder (silent no-op, or an unrelated mail flagged).
2380
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 6 taken 2 times.
2 if (wasSeen)
2381 ✗ markMailAsSeenInFolder(uid, folderId);
2382 else
2383
1/1
✓ Branch 7 → 8 taken 2 times.
2 markMailAsUnseenInFolder(uid, folderId);
2384 2 });
2385 }
2386 7 }
2387
2388 // T-79.E1/M5: folder-aware seen setters. Delegate to the current-folder
2389 // path when possible; otherwise update cache/model under the mail's own
2390 // folder key and STORE via the body connection (search-mode safe).
2391 718 void MailController::markMailAsSeenInFolder(qint64 uid, qint64 folderId,
2392 std::optional<bool> visibleSeen) {
2393
3/3
✓ Branch 2 → 3 taken 718 times.
✓ Branch 3 → 4 taken 668 times.
✓ Branch 3 → 5 taken 50 times.
761 if (startSeenVisualMutation(uid, folderId, true, visibleSeen)) return;
2394
2/2
✓ Branch 5 → 6 taken 42 times.
✓ Branch 5 → 8 taken 8 times.
50 if (folderId == m_folder.currentFolderId) {
2395
1/1
✓ Branch 6 → 7 taken 42 times.
42 markMailAsSeen(uid);
2396 42 return;
2397 }
2398 // P90.15: same visual-first split for search/cross-folder toggles — the
2399 // loaded row decides and commits visually before journal and SQL.
2400
1/1
✓ Branch 8 → 9 taken 8 times.
8 const auto loaded = m_model->flagsFor(uid, folderId);
2401
2/6
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 14 taken 8 times.
✗ Branch 12 → 13 not taken.
✗ Branch 12 → 14 not taken.
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 8 times.
8 if (loaded && (*loaded & MailFlag::Seen)) return;
2402 auto &performanceTracker =
2403
1/1
✓ Branch 17 → 18 taken 8 times.
8 MailJD::Performance::MutationPerformanceTracker::instance();
2404
3/5
✓ Branch 22 → 23 taken 8 times.
✓ Branch 25 → 26 taken 8 times.
✓ Branch 25 → 27 taken 8 times.
✗ Branch 83 → 84 not taken.
✗ Branch 83 → 85 not taken.
16 performanceTracker.beginMutation(1, {QStringLiteral("seen")});
2405 8 const bool visualFirst = loaded.has_value();
2406 1 const auto rollbackVisual = [this, uid, folderId, loaded, visualFirst]() {
2407
1/2
✓ Branch 2 → 3 taken 1 time.
✗ Branch 2 → 4 not taken.
1 if (!visualFirst) return;
2408 ✗ m_model->updateFlags(uid, *loaded, folderId);
2409 ✗ if (m_threadModel)
2410 ✗ m_threadModel->updateFlags(uid, *loaded, folderId);
2411 ✗ emit actionError(tr("The read state could not be saved. Nothing was changed."));
2412 8 };
2413
1/2
✗ Branch 29 → 30 not taken.
✓ Branch 29 → 37 taken 8 times.
8 if (visualFirst) {
2414 ✗ const quint32 newFlags = *loaded | MailFlag::Seen;
2415 ✗ performanceTracker.modelBadgeStarted();
2416 ✗ m_model->updateFlags(uid, newFlags, folderId);
2417 ✗ if (m_threadModel)
2418 ✗ m_threadModel->updateFlags(uid, newFlags, folderId);
2419 ✗ performanceTracker.modelBadgeFinished();
2420 ✗ performanceTracker.perceivedCommitted();
2421 }
2422
1/1
✓ Branch 37 → 38 taken 8 times.
8 auto header = m_cache->header(folderId, uid);
2423
1/3
✗ Branch 39 → 40 not taken.
✓ Branch 39 → 42 taken 8 times.
✗ Branch 40 → 41 not taken.
8 if (!header) { rollbackVisual(); return; }
2424
1/2
✗ Branch 44 → 45 not taken.
✓ Branch 44 → 46 taken 8 times.
8 if (header->isSeen()) return; // Visual state already correct.
2425
1/1
✓ Branch 49 → 50 taken 8 times.
16 const qint64 mutationToken = m_mutations.persistDesiredMutation(
2426 16 folderId, uid, MutationKind::Seen, QStringLiteral("\\Seen"), true);
2427
3/3
✓ Branch 52 → 53 taken 1 time.
✓ Branch 52 → 55 taken 7 times.
✓ Branch 53 → 54 taken 1 time.
8 if (mutationToken == 0) { rollbackVisual(); return; }
2428 7 quint32 newFlags = header->flags | MailFlag::Seen;
2429
1/1
✓ Branch 56 → 57 taken 7 times.
7 performanceTracker.cacheStarted();
2430
1/1
✓ Branch 57 → 58 taken 7 times.
7 m_cache->updateFlags(folderId, uid, newFlags);
2431
1/1
✓ Branch 58 → 59 taken 7 times.
7 performanceTracker.cacheFinished();
2432
1/2
✓ Branch 59 → 60 taken 7 times.
✗ Branch 59 → 66 not taken.
7 if (!visualFirst) {
2433
1/1
✓ Branch 60 → 61 taken 7 times.
7 performanceTracker.modelBadgeStarted();
2434
1/1
✓ Branch 61 → 62 taken 7 times.
7 m_model->updateFlags(uid, newFlags, folderId);
2435
1/2
✓ Branch 62 → 63 taken 7 times.
✗ Branch 62 → 64 not taken.
7 if (m_threadModel)
2436
1/1
✓ Branch 63 → 64 taken 7 times.
7 m_threadModel->updateFlags(uid, newFlags, folderId);
2437
1/1
✓ Branch 64 → 65 taken 7 times.
7 performanceTracker.modelBadgeFinished();
2438
1/1
✓ Branch 65 → 66 taken 7 times.
7 performanceTracker.perceivedCommitted();
2439 }
2440
2/3
✓ Branch 67 → 68 taken 7 times.
✓ Branch 69 → 70 taken 7 times.
✗ Branch 69 → 72 not taken.
7 if (const auto record = m_cache->journal().mutation(mutationToken))
2441
1/1
✓ Branch 71 → 72 taken 7 times.
7 m_mutations.dispatchMutation(*record);
2442 24 }
2443
2444 19 void MailController::markMailAsUnseenInFolder(qint64 uid, qint64 folderId,
2445 std::optional<bool> visibleSeen) {
2446
3/3
✓ Branch 2 → 3 taken 19 times.
✓ Branch 3 → 4 taken 11 times.
✓ Branch 3 → 5 taken 8 times.
23 if (startSeenVisualMutation(uid, folderId, false, visibleSeen)) return;
2447
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 8 taken 8 times.
8 if (folderId == m_folder.currentFolderId) {
2448 ✗ markMailAsUnseen(uid);
2449 ✗ return;
2450 }
2451
1/1
✓ Branch 8 → 9 taken 8 times.
8 const auto loaded = m_model->flagsFor(uid, folderId);
2452
2/6
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 14 taken 8 times.
✗ Branch 12 → 13 not taken.
✗ Branch 12 → 14 not taken.
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 8 times.
8 if (loaded && !(*loaded & MailFlag::Seen)) return;
2453 auto &performanceTracker =
2454
1/1
✓ Branch 17 → 18 taken 8 times.
8 MailJD::Performance::MutationPerformanceTracker::instance();
2455
3/5
✓ Branch 22 → 23 taken 8 times.
✓ Branch 25 → 26 taken 8 times.
✓ Branch 25 → 27 taken 8 times.
✗ Branch 83 → 84 not taken.
✗ Branch 83 → 85 not taken.
16 performanceTracker.beginMutation(1, {QStringLiteral("unseen")});
2456 8 const bool visualFirst = loaded.has_value();
2457 ✗ const auto rollbackVisual = [this, uid, folderId, loaded, visualFirst]() {
2458 ✗ if (!visualFirst) return;
2459 ✗ m_model->updateFlags(uid, *loaded, folderId);
2460 ✗ if (m_threadModel)
2461 ✗ m_threadModel->updateFlags(uid, *loaded, folderId);
2462 ✗ emit actionError(tr("The unread state could not be saved. Nothing was changed."));
2463 8 };
2464
1/2
✗ Branch 29 → 30 not taken.
✓ Branch 29 → 37 taken 8 times.
8 if (visualFirst) {
2465 ✗ const quint32 newFlags = *loaded & ~MailFlag::Seen;
2466 ✗ performanceTracker.modelBadgeStarted();
2467 ✗ m_model->updateFlags(uid, newFlags, folderId);
2468 ✗ if (m_threadModel)
2469 ✗ m_threadModel->updateFlags(uid, newFlags, folderId);
2470 ✗ performanceTracker.modelBadgeFinished();
2471 ✗ performanceTracker.perceivedCommitted();
2472 }
2473
1/1
✓ Branch 37 → 38 taken 8 times.
8 auto header = m_cache->header(folderId, uid);
2474
1/3
✗ Branch 39 → 40 not taken.
✓ Branch 39 → 42 taken 8 times.
✗ Branch 40 → 41 not taken.
8 if (!header) { rollbackVisual(); return; }
2475
2/2
✓ Branch 44 → 45 taken 4 times.
✓ Branch 44 → 46 taken 4 times.
8 if (!header->isSeen()) return; // Visual state already correct.
2476
1/1
✓ Branch 49 → 50 taken 4 times.
8 const qint64 mutationToken = m_mutations.persistDesiredMutation(
2477 8 folderId, uid, MutationKind::Seen, QStringLiteral("\\Seen"), false);
2478
1/3
✗ Branch 52 → 53 not taken.
✓ Branch 52 → 55 taken 4 times.
✗ Branch 53 → 54 not taken.
4 if (mutationToken == 0) { rollbackVisual(); return; }
2479 4 quint32 newFlags = header->flags & ~MailFlag::Seen;
2480
1/1
✓ Branch 56 → 57 taken 4 times.
4 performanceTracker.cacheStarted();
2481
1/1
✓ Branch 57 → 58 taken 4 times.
4 m_cache->updateFlags(folderId, uid, newFlags);
2482
1/1
✓ Branch 58 → 59 taken 4 times.
4 performanceTracker.cacheFinished();
2483
1/2
✓ Branch 59 → 60 taken 4 times.
✗ Branch 59 → 66 not taken.
4 if (!visualFirst) {
2484
1/1
✓ Branch 60 → 61 taken 4 times.
4 performanceTracker.modelBadgeStarted();
2485
1/1
✓ Branch 61 → 62 taken 4 times.
4 m_model->updateFlags(uid, newFlags, folderId);
2486
1/2
✓ Branch 62 → 63 taken 4 times.
✗ Branch 62 → 64 not taken.
4 if (m_threadModel)
2487
1/1
✓ Branch 63 → 64 taken 4 times.
4 m_threadModel->updateFlags(uid, newFlags, folderId);
2488
1/1
✓ Branch 64 → 65 taken 4 times.
4 performanceTracker.modelBadgeFinished();
2489
1/1
✓ Branch 65 → 66 taken 4 times.
4 performanceTracker.perceivedCommitted();
2490 }
2491
2/3
✓ Branch 67 → 68 taken 4 times.
✓ Branch 69 → 70 taken 4 times.
✗ Branch 69 → 72 not taken.
4 if (const auto record = m_cache->journal().mutation(mutationToken))
2492
1/1
✓ Branch 71 → 72 taken 4 times.
4 m_mutations.dispatchMutation(*record);
2493 24 }
2494
2495 // T-519: Idempotent star setter — only changes flag if state differs
2496 2 void MailController::setStarred(qint64 uid, bool starred) {
2497
1/1
✓ Branch 3 → 4 taken 2 times.
2 setStarredInFolder(uid, m_folder.currentFolderId, starred);
2498 2 }
2499
2500 24 void MailController::setStarredInFolder(qint64 uid, qint64 folderId, bool starred,
2501 std::optional<bool> visibleStarred) {
2502
1/1
✓ Branch 2 → 3 taken 24 times.
24 const auto loaded = m_model->flagsFor(uid, folderId);
2503
5/6
✓ Branch 4 → 5 taken 16 times.
✓ Branch 4 → 7 taken 8 times.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 16 times.
✓ Branch 9 → 10 taken 8 times.
✓ Branch 9 → 20 taken 16 times.
24 if (loaded || visibleStarred) {
2504
1/2
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 14 taken 8 times.
8 const bool current = visibleStarred ? *visibleStarred
2505 8 : bool(*loaded & MailFlag::Flagged);
2506
2/2
✓ Branch 16 → 17 taken 1 time.
✓ Branch 16 → 18 taken 7 times.
21 if (current == starred) return;
2507
1/1
✓ Branch 18 → 19 taken 7 times.
7 toggleStarredInFolder(uid, folderId, visibleStarred);
2508 7 return;
2509 }
2510
1/1
✓ Branch 20 → 21 taken 16 times.
16 auto header = m_cache->header(folderId, uid);
2511
2/2
✓ Branch 22 → 23 taken 1 time.
✓ Branch 22 → 24 taken 15 times.
16 if (!header)
2512 1 return;
2513
2514 // Already in desired state → no-op
2515
2/2
✓ Branch 26 → 27 taken 5 times.
✓ Branch 26 → 28 taken 10 times.
15 if (header->isFlagged() == starred)
2516 5 return;
2517
2518
1/1
✓ Branch 29 → 30 taken 10 times.
10 toggleStarredInFolder(uid, folderId);
2519 16 }
2520
2521 6 void MailController::toggleStarred(qint64 uid) {
2522
3/3
✓ Branch 2 → 3 taken 6 times.
✓ Branch 4 → 5 taken 5 times.
✓ Branch 4 → 21 taken 1 time.
6 if (const auto loaded = m_model->flagsFor(uid, m_folder.currentFolderId)) {
2523 5 const bool wasFlagged = bool(*loaded & MailFlag::Flagged);
2524
1/2
✓ Branch 8 → 9 taken 5 times.
✗ Branch 8 → 21 not taken.
5 if (startFlagVisualMutation(uid, m_folder.currentFolderId, MutationKind::Flagged,
2525
1/1
✓ Branch 7 → 8 taken 5 times.
5 !wasFlagged)) {
2526
1/2
✓ Branch 10 → 11 taken 5 times.
✗ Branch 10 → 20 not taken.
5 if (m_undoManager) {
2527 5 const qint64 folderId = m_folder.currentFolderId;
2528
5/5
✓ Branch 13 → 14 taken 1 time.
✓ Branch 13 → 15 taken 4 times.
✓ Branch 14 → 16 taken 1 time.
✓ Branch 15 → 16 taken 4 times.
✓ Branch 16 → 17 taken 5 times.
10 m_undoManager->push(wasFlagged ? tr("Flag removed") : tr("Flag set"),
2529
1/1
✓ Branch 12 → 13 taken 5 times.
10 [this, uid, folderId, wasFlagged] {
2530
1/1
✓ Branch 3 → 4 taken 1 time.
1 setStarredInFolder(uid, folderId, wasFlagged);
2531 1 });
2532 }
2533 5 return;
2534 }
2535 }
2536
1/1
✓ Branch 22 → 23 taken 1 time.
1 auto header = m_cache->header(m_folder.currentFolderId, uid);
2537
1/2
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 26 taken 1 time.
1 if (!header)
2538 ✗ return;
2539
2540 auto &performanceTracker =
2541
1/1
✓ Branch 26 → 27 taken 1 time.
1 MailJD::Performance::MutationPerformanceTracker::instance();
2542 1 bool wasFlagged = header->isFlagged();
2543
3/5
✓ Branch 37 → 38 taken 1 time.
✓ Branch 40 → 41 taken 1 time.
✓ Branch 40 → 42 taken 1 time.
✗ Branch 125 → 126 not taken.
✗ Branch 125 → 127 not taken.
2 performanceTracker.beginMutation(
2544 ✗ 1, {wasFlagged ? QStringLiteral("unflagged")
2545
2/6
✓ Branch 42 → 43 taken 1 time.
✗ Branch 42 → 44 not taken.
✗ Branch 44 → 45 not taken.
✓ Branch 44 → 46 taken 1 time.
✗ Branch 128 → 129 not taken.
✗ Branch 128 → 130 not taken.
1 : QStringLiteral("flagged")});
2546
1/1
✓ Branch 49 → 50 taken 1 time.
1 const qint64 mutationToken = m_mutations.persistDesiredMutation(
2547 m_folder.currentFolderId, uid, MutationKind::Flagged,
2548 3 QStringLiteral("\\Flagged"), !wasFlagged);
2549
1/2
✗ Branch 52 → 53 not taken.
✓ Branch 52 → 54 taken 1 time.
1 if (mutationToken == 0)
2550 ✗ return;
2551
1/2
✗ Branch 54 → 55 not taken.
✓ Branch 54 → 57 taken 1 time.
1 quint32 newFlags = wasFlagged ? (header->flags & ~MailFlag::Flagged)
2552 1 : (header->flags | MailFlag::Flagged);
2553
2554 // Optimistic update: local cache + model first
2555
1/1
✓ Branch 59 → 60 taken 1 time.
1 performanceTracker.cacheStarted();
2556
1/1
✓ Branch 60 → 61 taken 1 time.
1 m_cache->updateFlags(m_folder.currentFolderId, uid, newFlags);
2557
1/1
✓ Branch 61 → 62 taken 1 time.
1 performanceTracker.cacheFinished();
2558
1/1
✓ Branch 62 → 63 taken 1 time.
1 performanceTracker.modelBadgeStarted();
2559
1/1
✓ Branch 63 → 64 taken 1 time.
1 m_model->updateFlags(uid, newFlags, m_folder.currentFolderId);
2560
1/2
✓ Branch 64 → 65 taken 1 time.
✗ Branch 64 → 66 not taken.
1 if (m_threadModel)
2561
1/1
✓ Branch 65 → 66 taken 1 time.
1 m_threadModel->updateFlags(uid, newFlags, m_folder.currentFolderId);
2562
1/1
✓ Branch 66 → 67 taken 1 time.
1 performanceTracker.modelBadgeFinished();
2563
1/1
✓ Branch 67 → 68 taken 1 time.
1 performanceTracker.perceivedCommitted();
2564
2565
6/6
✓ Branch 68 → 69 taken 1 time.
✓ Branch 72 → 73 taken 1 time.
✓ Branch 73 → 74 taken 1 time.
✓ Branch 74 → 75 taken 1 time.
✓ Branch 83 → 70 taken 1 time.
✓ Branch 83 → 84 taken 1 time.
2 qCInfo(lcController) << "Toggling star for UID" << uid
2566
3/4
✗ Branch 75 → 76 not taken.
✓ Branch 75 → 77 taken 1 time.
✓ Branch 78 → 79 taken 1 time.
✓ Branch 79 → 80 taken 1 time.
1 << (wasFlagged ? "OFF" : "ON") << "(optimistic)";
2567
2568 // Send STORE to server via executeAfterIdle (IDLE-safe)
2569 // T-201: Track pending flag to prevent onFlagsReceived reversion
2570
1/3
✓ Branch 86 → 87 taken 1 time.
✗ Branch 156 → 157 not taken.
✗ Branch 156 → 158 not taken.
1 m_mutations.intents().markFlag(MailId{m_folder.accountId, m_folder.currentFolderId, uid});
2571
2/3
✓ Branch 89 → 90 taken 1 time.
✓ Branch 91 → 92 taken 1 time.
✗ Branch 91 → 94 not taken.
1 if (const auto record = m_cache->journal().mutation(mutationToken))
2572
1/1
✓ Branch 93 → 94 taken 1 time.
1 m_mutations.dispatchMutation(*record);
2573
2574 // T-403/Bug 23: Undo — set flag directly to avoid infinite chain
2575
1/2
✓ Branch 96 → 97 taken 1 time.
✗ Branch 96 → 106 not taken.
1 if (m_undoManager) {
2576 // T-620/FUNC-01: Capture folderId by value — folder may change before undo
2577 1 qint64 folderId = m_folder.currentFolderId;
2578
1/1
✓ Branch 102 → 103 taken 1 time.
2 m_undoManager->push(
2579
2/4
✗ Branch 99 → 100 not taken.
✓ Branch 99 → 101 taken 1 time.
✗ Branch 100 → 102 not taken.
✓ Branch 101 → 102 taken 1 time.
2 wasFlagged ? tr("Flag removed")
2580 : tr("Flag set"),
2581
1/1
✓ Branch 98 → 99 taken 1 time.
2 [this, uid, folderId, wasFlagged]() {
2582 // Directly set flag state without creating another undo entry
2583 ✗ quint32 currentFlags = 0;
2584 ✗ auto h = m_cache->header(folderId, uid);
2585 ✗ if (h) currentFlags = h->flags;
2586 ✗ const qint64 token = m_mutations.persistDesiredMutation(
2587 folderId, uid, MutationKind::Flagged,
2588 ✗ QStringLiteral("\\Flagged"), wasFlagged);
2589 ✗ if (token == 0)
2590 ✗ return;
2591 ✗ quint32 restore = wasFlagged
2592 ✗ ? (currentFlags | MailFlag::Flagged)
2593 : (currentFlags & ~MailFlag::Flagged);
2594 ✗ m_cache->updateFlags(folderId, uid, restore);
2595 ✗ m_model->updateFlags(uid, restore, folderId);
2596 ✗ if (m_threadModel)
2597 ✗ m_threadModel->updateFlags(uid, restore, folderId);
2598 ✗ m_mutations.intents().markFlag(MailId{m_folder.accountId, folderId, uid});
2599 ✗ if (const auto record = m_cache->journal().mutation(token))
2600 ✗ m_mutations.dispatchMutation(*record);
2601 ✗ });
2602 }
2603 3 }
2604
2605 6 void MailController::addLabel(qint64 uid, const QString &label) {
2606
1/1
✓ Branch 3 → 4 taken 6 times.
6 addLabelInFolder(uid, m_folder.currentFolderId, label);
2607 6 }
2608
2609 3 void MailController::removeLabel(qint64 uid, const QString &label) {
2610
1/1
✓ Branch 3 → 4 taken 3 times.
3 removeLabelInFolder(uid, m_folder.currentFolderId, label);
2611 3 }
2612
2613 // ═══════════════════════════════════════════════════════
2614 // Mail Move (T-100/T-101)
2615 // ═══════════════════════════════════════════════════════
2616
2617 // ═══════════════════════════════════════════════════════
2618 // T-407: Cross-folder action overloads for search mode
2619 // ═══════════════════════════════════════════════════════
2620
2621 7 void MailController::toggleReadStatusInFolder(qint64 uid, qint64 folderId,
2622 std::optional<bool> visibleSeen) {
2623
1/1
✓ Branch 2 → 3 taken 7 times.
7 const auto loaded = m_model->flagsFor(uid, folderId);
2624
5/6
✓ Branch 4 → 5 taken 4 times.
✓ Branch 4 → 7 taken 3 times.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 4 times.
✓ Branch 9 → 10 taken 3 times.
✓ Branch 9 → 44 taken 4 times.
7 if (loaded || visibleSeen) {
2625
1/2
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 14 taken 3 times.
3 const bool wasSeen = visibleSeen ? *visibleSeen : bool(*loaded & MailFlag::Seen);
2626
4/8
✓ Branch 16 → 17 taken 3 times.
✗ Branch 16 → 22 not taken.
✓ Branch 18 → 19 taken 3 times.
✗ Branch 18 → 22 not taken.
✓ Branch 20 → 21 taken 3 times.
✗ Branch 20 → 22 not taken.
✓ Branch 23 → 24 taken 3 times.
✗ Branch 23 → 26 not taken.
3 if (folderId == m_folder.currentFolderId && loaded && !visibleSeen) {
2627
1/1
✓ Branch 24 → 25 taken 3 times.
3 toggleReadStatus(uid); // preserves the current-folder Undo contract
2628 5 return;
2629 }
2630 ✗ if (startSeenVisualMutation(uid, folderId, !wasSeen, visibleSeen)) {
2631 ✗ if (folderId == m_folder.currentFolderId && m_undoManager) {
2632 ✗ m_undoManager->push(wasSeen ? tr("Marked as unread") : tr("Marked as read"),
2633 ✗ [this, uid, folderId, wasSeen] {
2634 ✗ if (wasSeen) markMailAsSeenInFolder(uid, folderId);
2635 ✗ else markMailAsUnseenInFolder(uid, folderId);
2636 ✗ });
2637 }
2638 ✗ return;
2639 }
2640 }
2641 // If it's the current folder, delegate to the normal method
2642
2/2
✓ Branch 44 → 45 taken 1 time.
✓ Branch 44 → 47 taken 3 times.
4 if (folderId == m_folder.currentFolderId) {
2643
1/1
✓ Branch 45 → 46 taken 1 time.
1 toggleReadStatus(uid);
2644 1 return;
2645 }
2646
2647
1/1
✓ Branch 47 → 48 taken 3 times.
3 auto header = m_cache->header(folderId, uid);
2648
2/2
✓ Branch 49 → 50 taken 1 time.
✓ Branch 49 → 51 taken 2 times.
3 if (!header)
2649 1 return;
2650
2651 auto &performanceTracker =
2652
1/1
✓ Branch 51 → 52 taken 2 times.
2 MailJD::Performance::MutationPerformanceTracker::instance();
2653 2 bool wasSeen = header->isSeen();
2654
3/5
✓ Branch 62 → 63 taken 2 times.
✓ Branch 65 → 66 taken 2 times.
✓ Branch 65 → 67 taken 2 times.
✗ Branch 145 → 146 not taken.
✗ Branch 145 → 147 not taken.
4 performanceTracker.beginMutation(
2655
4/8
✓ Branch 67 → 68 taken 1 time.
✓ Branch 67 → 69 taken 1 time.
✓ Branch 69 → 70 taken 1 time.
✓ Branch 69 → 71 taken 1 time.
✗ Branch 148 → 149 not taken.
✗ Branch 148 → 150 not taken.
✗ Branch 152 → 153 not taken.
✗ Branch 152 → 154 not taken.
2 1, {wasSeen ? QStringLiteral("unseen") : QStringLiteral("seen")});
2656
1/1
✓ Branch 74 → 75 taken 2 times.
2 const qint64 mutationToken = m_mutations.persistDesiredMutation(
2657 6 folderId, uid, MutationKind::Seen, QStringLiteral("\\Seen"), !wasSeen);
2658
1/2
✗ Branch 77 → 78 not taken.
✓ Branch 77 → 79 taken 2 times.
2 if (mutationToken == 0)
2659 ✗ return;
2660
2/2
✓ Branch 79 → 80 taken 1 time.
✓ Branch 79 → 82 taken 1 time.
2 quint32 newFlags = wasSeen ? (header->flags & ~MailFlag::Seen)
2661 1 : (header->flags | MailFlag::Seen);
2662
2663 // Optimistic update: cache + model
2664
1/1
✓ Branch 84 → 85 taken 2 times.
2 performanceTracker.cacheStarted();
2665
1/1
✓ Branch 85 → 86 taken 2 times.
2 m_cache->updateFlags(folderId, uid, newFlags);
2666
1/1
✓ Branch 86 → 87 taken 2 times.
2 performanceTracker.cacheFinished();
2667
1/1
✓ Branch 87 → 88 taken 2 times.
2 performanceTracker.modelBadgeStarted();
2668
1/1
✓ Branch 88 → 89 taken 2 times.
2 m_model->updateFlags(uid, newFlags, folderId);
2669
1/2
✓ Branch 89 → 90 taken 2 times.
✗ Branch 89 → 91 not taken.
2 if (m_threadModel)
2670
1/1
✓ Branch 90 → 91 taken 2 times.
2 m_threadModel->updateFlags(uid, newFlags, folderId);
2671
1/1
✓ Branch 91 → 92 taken 2 times.
2 performanceTracker.modelBadgeFinished();
2672
1/1
✓ Branch 92 → 93 taken 2 times.
2 performanceTracker.perceivedCommitted();
2673
2674
6/6
✓ Branch 93 → 94 taken 2 times.
✓ Branch 97 → 98 taken 2 times.
✓ Branch 98 → 99 taken 2 times.
✓ Branch 99 → 100 taken 2 times.
✓ Branch 109 → 95 taken 2 times.
✓ Branch 109 → 110 taken 2 times.
4 qCInfo(lcController) << "T-407: Toggling read for UID" << uid
2675
2/2
✓ Branch 100 → 101 taken 2 times.
✓ Branch 101 → 102 taken 2 times.
2 << "in folder" << folderId
2676
3/3
✓ Branch 102 → 103 taken 1 time.
✓ Branch 102 → 104 taken 1 time.
✓ Branch 105 → 106 taken 2 times.
2 << (wasSeen ? "→ unread" : "→ read");
2677
2678 // IMAP via body connection (cross-folder)
2679
2/3
✓ Branch 111 → 112 taken 2 times.
✓ Branch 113 → 114 taken 2 times.
✗ Branch 113 → 116 not taken.
2 if (const auto record = m_cache->journal().mutation(mutationToken))
2680
1/1
✓ Branch 115 → 116 taken 2 times.
2 m_mutations.dispatchMutation(*record);
2681
2682
1/2
✓ Branch 118 → 119 taken 2 times.
✗ Branch 118 → 128 not taken.
2 if (m_undoManager) {
2683
1/1
✓ Branch 124 → 125 taken 2 times.
4 m_undoManager->push(
2684
4/4
✓ Branch 121 → 122 taken 1 time.
✓ Branch 121 → 123 taken 1 time.
✓ Branch 122 → 124 taken 1 time.
✓ Branch 123 → 124 taken 1 time.
4 wasSeen ? tr("Marked as unread") : tr("Marked as read"),
2685
1/1
✓ Branch 120 → 121 taken 2 times.
4 [this, uid, folderId, wasSeen]() {
2686
1/1
✓ Branch 3 → 4 taken 1 time.
1 toggleReadStatusInFolder(uid, folderId);
2687 1 });
2688 }
2689 7 }
2690
2691 52 void MailController::toggleStarredInFolder(qint64 uid, qint64 folderId,
2692 std::optional<bool> visibleStarred) {
2693
1/1
✓ Branch 2 → 3 taken 52 times.
52 const auto loaded = m_model->flagsFor(uid, folderId);
2694
5/6
✓ Branch 4 → 5 taken 14 times.
✓ Branch 4 → 7 taken 38 times.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 14 times.
✓ Branch 9 → 10 taken 38 times.
✓ Branch 9 → 30 taken 14 times.
52 if (loaded || visibleStarred) {
2695
1/2
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 14 taken 38 times.
38 const bool wasFlagged = visibleStarred ? *visibleStarred
2696 38 : bool(*loaded & MailFlag::Flagged);
2697
1/2
✓ Branch 17 → 18 taken 38 times.
✗ Branch 17 → 30 not taken.
38 if (startFlagVisualMutation(uid, folderId, MutationKind::Flagged,
2698
1/1
✓ Branch 16 → 17 taken 38 times.
38 !wasFlagged, visibleStarred)) {
2699
1/2
✓ Branch 19 → 20 taken 38 times.
✗ Branch 19 → 29 not taken.
38 if (m_undoManager)
2700
5/5
✓ Branch 22 → 23 taken 16 times.
✓ Branch 22 → 24 taken 22 times.
✓ Branch 23 → 25 taken 16 times.
✓ Branch 24 → 25 taken 22 times.
✓ Branch 25 → 26 taken 38 times.
76 m_undoManager->push(wasFlagged ? tr("Flag removed") : tr("Flag set"),
2701
1/1
✓ Branch 21 → 22 taken 38 times.
76 [this, uid, folderId, wasFlagged] {
2702
1/1
✓ Branch 3 → 4 taken 2 times.
2 setStarredInFolder(uid, folderId, wasFlagged);
2703 2 });
2704 40 return;
2705 }
2706 }
2707
2/2
✓ Branch 30 → 31 taken 1 time.
✓ Branch 30 → 33 taken 13 times.
14 if (folderId == m_folder.currentFolderId) {
2708
1/1
✓ Branch 31 → 32 taken 1 time.
1 toggleStarred(uid);
2709 1 return;
2710 }
2711
2712
1/1
✓ Branch 33 → 34 taken 13 times.
13 auto header = m_cache->header(folderId, uid);
2713
2/2
✓ Branch 35 → 36 taken 1 time.
✓ Branch 35 → 37 taken 12 times.
13 if (!header)
2714 1 return;
2715
2716 auto &performanceTracker =
2717
1/1
✓ Branch 37 → 38 taken 12 times.
12 MailJD::Performance::MutationPerformanceTracker::instance();
2718 12 bool wasFlagged = header->isFlagged();
2719
3/5
✓ Branch 48 → 49 taken 12 times.
✓ Branch 51 → 52 taken 12 times.
✓ Branch 51 → 53 taken 12 times.
✗ Branch 131 → 132 not taken.
✗ Branch 131 → 133 not taken.
24 performanceTracker.beginMutation(
2720
0/2
✗ Branch 138 → 139 not taken.
✗ Branch 138 → 140 not taken.
6 1, {wasFlagged ? QStringLiteral("unflagged")
2721
4/6
✓ Branch 53 → 54 taken 6 times.
✓ Branch 53 → 55 taken 6 times.
✓ Branch 55 → 56 taken 6 times.
✓ Branch 55 → 57 taken 6 times.
✗ Branch 134 → 135 not taken.
✗ Branch 134 → 136 not taken.
12 : QStringLiteral("flagged")});
2722
1/1
✓ Branch 60 → 61 taken 12 times.
12 const qint64 mutationToken = m_mutations.persistDesiredMutation(
2723 24 folderId, uid, MutationKind::Flagged, QStringLiteral("\\Flagged"),
2724 12 !wasFlagged);
2725
1/2
✗ Branch 63 → 64 not taken.
✓ Branch 63 → 65 taken 12 times.
12 if (mutationToken == 0)
2726 ✗ return;
2727
2/2
✓ Branch 65 → 66 taken 6 times.
✓ Branch 65 → 68 taken 6 times.
12 quint32 newFlags = wasFlagged ? (header->flags & ~MailFlag::Flagged)
2728 6 : (header->flags | MailFlag::Flagged);
2729
2730
1/1
✓ Branch 70 → 71 taken 12 times.
12 performanceTracker.cacheStarted();
2731
1/1
✓ Branch 71 → 72 taken 12 times.
12 m_cache->updateFlags(folderId, uid, newFlags);
2732
1/1
✓ Branch 72 → 73 taken 12 times.
12 performanceTracker.cacheFinished();
2733
1/1
✓ Branch 73 → 74 taken 12 times.
12 performanceTracker.modelBadgeStarted();
2734
1/1
✓ Branch 74 → 75 taken 12 times.
12 m_model->updateFlags(uid, newFlags, folderId);
2735
1/2
✓ Branch 75 → 76 taken 12 times.
✗ Branch 75 → 77 not taken.
12 if (m_threadModel)
2736
1/1
✓ Branch 76 → 77 taken 12 times.
12 m_threadModel->updateFlags(uid, newFlags, folderId);
2737
1/1
✓ Branch 77 → 78 taken 12 times.
12 performanceTracker.modelBadgeFinished();
2738
1/1
✓ Branch 78 → 79 taken 12 times.
12 performanceTracker.perceivedCommitted();
2739
2740
6/6
✓ Branch 79 → 80 taken 12 times.
✓ Branch 83 → 84 taken 12 times.
✓ Branch 84 → 85 taken 12 times.
✓ Branch 85 → 86 taken 12 times.
✓ Branch 95 → 81 taken 12 times.
✓ Branch 95 → 96 taken 12 times.
24 qCInfo(lcController) << "T-407: Toggling star for UID" << uid
2741
2/2
✓ Branch 86 → 87 taken 12 times.
✓ Branch 87 → 88 taken 12 times.
12 << "in folder" << folderId
2742
3/3
✓ Branch 88 → 89 taken 6 times.
✓ Branch 88 → 90 taken 6 times.
✓ Branch 91 → 92 taken 12 times.
12 << (wasFlagged ? "OFF" : "ON");
2743
2744
2/3
✓ Branch 97 → 98 taken 12 times.
✓ Branch 99 → 100 taken 12 times.
✗ Branch 99 → 102 not taken.
12 if (const auto record = m_cache->journal().mutation(mutationToken))
2745
1/1
✓ Branch 101 → 102 taken 12 times.
12 m_mutations.dispatchMutation(*record);
2746
2747
1/2
✓ Branch 104 → 105 taken 12 times.
✗ Branch 104 → 114 not taken.
12 if (m_undoManager) {
2748
1/1
✓ Branch 110 → 111 taken 12 times.
24 m_undoManager->push(
2749
4/4
✓ Branch 107 → 108 taken 6 times.
✓ Branch 107 → 109 taken 6 times.
✓ Branch 108 → 110 taken 6 times.
✓ Branch 109 → 110 taken 6 times.
24 wasFlagged ? tr("Flag removed") : tr("Flag set"),
2750
1/1
✓ Branch 106 → 107 taken 12 times.
24 [this, uid, folderId, wasFlagged]() {
2751
1/1
✓ Branch 2 → 3 taken 1 time.
1 auto h = m_cache->header(folderId, uid);
2752
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 1 time.
1 if (!h) return;
2753
1/1
✓ Branch 9 → 10 taken 1 time.
2 const qint64 token = m_mutations.persistDesiredMutation(
2754 folderId, uid, MutationKind::Flagged,
2755 2 QStringLiteral("\\Flagged"), wasFlagged);
2756
1/2
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 1 time.
1 if (token == 0)
2757 ✗ return;
2758 1 quint32 restore = wasFlagged
2759
1/2
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 17 taken 1 time.
1 ? (h->flags | MailFlag::Flagged)
2760 1 : (h->flags & ~MailFlag::Flagged);
2761
1/1
✓ Branch 19 → 20 taken 1 time.
1 m_cache->updateFlags(folderId, uid, restore);
2762 // T-79.E1/M4: model rows are keyed (folderId, uid) — the undo
2763 // must target the mail's own folder, not the current one.
2764
1/1
✓ Branch 20 → 21 taken 1 time.
1 m_model->updateFlags(uid, restore, folderId);
2765
1/2
✓ Branch 21 → 22 taken 1 time.
✗ Branch 21 → 23 not taken.
1 if (m_threadModel)
2766
1/1
✓ Branch 22 → 23 taken 1 time.
1 m_threadModel->updateFlags(uid, restore, folderId);
2767
2/3
✓ Branch 24 → 25 taken 1 time.
✓ Branch 26 → 27 taken 1 time.
✗ Branch 26 → 29 not taken.
1 if (const auto record = m_cache->journal().mutation(token))
2768
1/1
✓ Branch 28 → 29 taken 1 time.
1 m_mutations.dispatchMutation(*record);
2769
1/2
✓ Branch 32 → 33 taken 1 time.
✗ Branch 32 → 35 not taken.
1 });
2770 }
2771 37 }
2772
2773 35 void MailController::addLabelInFolder(qint64 uid, qint64 folderId,
2774 const QString &label,
2775 std::optional<QStringList> visibleLabels) {
2776
2/2
✓ Branch 2 → 3 taken 35 times.
✓ Branch 3 → 4 taken 35 times.
35 const auto visual = startLabelVisualMutation(uid, folderId, label, true, visibleLabels);
2777
2/2
✓ Branch 5 → 6 taken 25 times.
✓ Branch 5 → 25 taken 10 times.
35 if (visual != LabelVisualStart::Unavailable) {
2778
5/6
✓ Branch 6 → 7 taken 22 times.
✓ Branch 6 → 10 taken 3 times.
✓ Branch 8 → 9 taken 22 times.
✗ Branch 8 → 10 not taken.
✓ Branch 11 → 12 taken 22 times.
✓ Branch 11 → 24 taken 3 times.
25 if (visual == LabelVisualStart::Started && m_undoManager)
2779
4/4
✓ Branch 14 → 15 taken 22 times.
✓ Branch 15 → 16 taken 22 times.
✓ Branch 17 → 18 taken 22 times.
✓ Branch 18 → 19 taken 22 times.
44 m_undoManager->push(tr("Label '%1' added").arg(label), [this, uid, folderId, label] {
2780
1/1
✓ Branch 3 → 4 taken 2 times.
2 removeLabelInFolder(uid, folderId, label);
2781 2 });
2782 27 return;
2783 }
2784 // Mail tabs may display a cached header outside the currently loaded page.
2785 // The model is a projection, not admission authority for a durable mutation.
2786
1/1
✓ Branch 25 → 26 taken 10 times.
10 auto header = m_cache->header(folderId, uid);
2787
1/1
✓ Branch 26 → 27 taken 10 times.
10 int row = m_model->rowForUid(uid, folderId);
2788
5/6
✓ Branch 28 → 29 taken 10 times.
✗ Branch 28 → 32 not taken.
✓ Branch 31 → 32 taken 1 time.
✓ Branch 31 → 33 taken 9 times.
✓ Branch 34 → 35 taken 1 time.
✓ Branch 34 → 36 taken 9 times.
10 if (!header || header->labels.contains(label))
2789 1 return;
2790 auto &performanceTracker =
2791
1/1
✓ Branch 36 → 37 taken 9 times.
9 MailJD::Performance::MutationPerformanceTracker::instance();
2792
3/5
✓ Branch 41 → 42 taken 9 times.
✓ Branch 44 → 45 taken 9 times.
✓ Branch 44 → 46 taken 9 times.
✗ Branch 124 → 125 not taken.
✗ Branch 124 → 126 not taken.
18 performanceTracker.beginMutation(1, {QStringLiteral("label_add")});
2793
1/1
✓ Branch 47 → 48 taken 9 times.
9 const qint64 mutationToken = m_mutations.persistDesiredMutation(
2794 folderId, uid, MutationKind::Keyword, label, true);
2795
2/2
✓ Branch 48 → 49 taken 1 time.
✓ Branch 48 → 50 taken 8 times.
9 if (mutationToken == 0)
2796 1 return;
2797
2798
1/1
✓ Branch 50 → 51 taken 8 times.
8 performanceTracker.modelBadgeStarted();
2799
1/1
✓ Branch 52 → 53 taken 8 times.
8 header->labels.append(label);
2800
1/1
✓ Branch 54 → 55 taken 8 times.
8 header->labels.sort(Qt::CaseInsensitive);
2801
2/3
✓ Branch 55 → 56 taken 8 times.
✗ Branch 56 → 57 not taken.
✓ Branch 56 → 69 taken 8 times.
8 if (auto *visible = m_model->mutableHeaderAt(row)) {
2802 ✗ visible->labels = header->labels;
2803 ✗ QModelIndex idx = m_model->index(row, 0);
2804 ✗ emit m_model->dataChanged(idx, m_model->index(row, m_model->columnCount() - 1));
2805 }
2806
1/2
✓ Branch 69 → 70 taken 8 times.
✗ Branch 69 → 72 not taken.
8 if (m_threadModel)
2807
1/1
✓ Branch 71 → 72 taken 8 times.
8 m_threadModel->updateLabels(uid, header->labels, folderId);
2808
1/1
✓ Branch 72 → 73 taken 8 times.
8 performanceTracker.modelBadgeFinished();
2809
2810
1/1
✓ Branch 73 → 74 taken 8 times.
8 performanceTracker.cacheStarted();
2811
1/1
✓ Branch 74 → 75 taken 8 times.
8 m_cache->addLabel(folderId, uid, label);
2812
1/1
✓ Branch 75 → 76 taken 8 times.
8 performanceTracker.cacheFinished();
2813
1/1
✓ Branch 76 → 77 taken 8 times.
8 performanceTracker.perceivedCommitted();
2814
2815
2/3
✓ Branch 78 → 79 taken 8 times.
✓ Branch 80 → 81 taken 8 times.
✗ Branch 80 → 83 not taken.
8 if (const auto record = m_cache->journal().mutation(mutationToken))
2816
1/1
✓ Branch 82 → 83 taken 8 times.
8 m_mutations.dispatchMutation(*record);
2817
2818
1/2
✓ Branch 85 → 86 taken 8 times.
✗ Branch 85 → 98 not taken.
8 if (m_undoManager) {
2819
2/2
✓ Branch 88 → 89 taken 8 times.
✓ Branch 92 → 93 taken 8 times.
24 m_undoManager->push(
2820
2/2
✓ Branch 89 → 90 taken 8 times.
✓ Branch 91 → 92 taken 8 times.
24 tr("Label '%1' added").arg(label),
2821 16 [this, uid, folderId, label]() {
2822
1/1
✓ Branch 3 → 4 taken 2 times.
2 removeLabelInFolder(uid, folderId, label);
2823 2 });
2824 }
2825 28 }
2826
2827 23 void MailController::removeLabelInFolder(qint64 uid, qint64 folderId,
2828 const QString &label,
2829 std::optional<QStringList> visibleLabels) {
2830
2/2
✓ Branch 2 → 3 taken 23 times.
✓ Branch 3 → 4 taken 23 times.
23 const auto visual = startLabelVisualMutation(uid, folderId, label, false, visibleLabels);
2831
2/2
✓ Branch 5 → 6 taken 15 times.
✓ Branch 5 → 25 taken 8 times.
23 if (visual != LabelVisualStart::Unavailable) {
2832
5/6
✓ Branch 6 → 7 taken 13 times.
✓ Branch 6 → 10 taken 2 times.
✓ Branch 8 → 9 taken 13 times.
✗ Branch 8 → 10 not taken.
✓ Branch 11 → 12 taken 13 times.
✓ Branch 11 → 24 taken 2 times.
15 if (visual == LabelVisualStart::Started && m_undoManager)
2833
4/4
✓ Branch 14 → 15 taken 13 times.
✓ Branch 15 → 16 taken 13 times.
✓ Branch 17 → 18 taken 13 times.
✓ Branch 18 → 19 taken 13 times.
26 m_undoManager->push(tr("Label '%1' removed").arg(label), [this, uid, folderId, label] {
2834
1/1
✓ Branch 3 → 4 taken 2 times.
2 addLabelInFolder(uid, folderId, label);
2835 2 });
2836 16 return;
2837 }
2838
1/1
✓ Branch 25 → 26 taken 8 times.
8 auto header = m_cache->header(folderId, uid);
2839
1/1
✓ Branch 26 → 27 taken 8 times.
8 int row = m_model->rowForUid(uid, folderId);
2840
5/6
✓ Branch 28 → 29 taken 7 times.
✓ Branch 28 → 32 taken 1 time.
✗ Branch 31 → 32 not taken.
✓ Branch 31 → 33 taken 7 times.
✓ Branch 34 → 35 taken 1 time.
✓ Branch 34 → 36 taken 7 times.
8 if (!header || !header->labels.contains(label))
2841 1 return;
2842 auto &performanceTracker =
2843
1/1
✓ Branch 36 → 37 taken 7 times.
7 MailJD::Performance::MutationPerformanceTracker::instance();
2844
3/5
✓ Branch 41 → 42 taken 7 times.
✓ Branch 44 → 45 taken 7 times.
✓ Branch 44 → 46 taken 7 times.
✗ Branch 122 → 123 not taken.
✗ Branch 122 → 124 not taken.
14 performanceTracker.beginMutation(1, {QStringLiteral("label_remove")});
2845
1/1
✓ Branch 47 → 48 taken 7 times.
7 const qint64 mutationToken = m_mutations.persistDesiredMutation(
2846 folderId, uid, MutationKind::Keyword, label, false);
2847
1/2
✗ Branch 48 → 49 not taken.
✓ Branch 48 → 50 taken 7 times.
7 if (mutationToken == 0)
2848 ✗ return;
2849
2850
1/1
✓ Branch 50 → 51 taken 7 times.
7 performanceTracker.modelBadgeStarted();
2851
1/1
✓ Branch 52 → 53 taken 7 times.
7 header->labels.removeAll(label);
2852
2/3
✓ Branch 53 → 54 taken 7 times.
✗ Branch 54 → 55 not taken.
✓ Branch 54 → 67 taken 7 times.
7 if (auto *visible = m_model->mutableHeaderAt(row)) {
2853 ✗ visible->labels = header->labels;
2854 ✗ QModelIndex idx = m_model->index(row, 0);
2855 ✗ emit m_model->dataChanged(idx, m_model->index(row, m_model->columnCount() - 1));
2856 }
2857
1/2
✓ Branch 67 → 68 taken 7 times.
✗ Branch 67 → 70 not taken.
7 if (m_threadModel)
2858
1/1
✓ Branch 69 → 70 taken 7 times.
7 m_threadModel->updateLabels(uid, header->labels, folderId);
2859
1/1
✓ Branch 70 → 71 taken 7 times.
7 performanceTracker.modelBadgeFinished();
2860
2861
1/1
✓ Branch 71 → 72 taken 7 times.
7 performanceTracker.cacheStarted();
2862
1/1
✓ Branch 72 → 73 taken 7 times.
7 m_cache->removeLabel(folderId, uid, label);
2863
1/1
✓ Branch 73 → 74 taken 7 times.
7 performanceTracker.cacheFinished();
2864
1/1
✓ Branch 74 → 75 taken 7 times.
7 performanceTracker.perceivedCommitted();
2865
2866
2/3
✓ Branch 76 → 77 taken 7 times.
✓ Branch 78 → 79 taken 7 times.
✗ Branch 78 → 81 not taken.
7 if (const auto record = m_cache->journal().mutation(mutationToken))
2867
1/1
✓ Branch 80 → 81 taken 7 times.
7 m_mutations.dispatchMutation(*record);
2868
2869
1/2
✓ Branch 83 → 84 taken 7 times.
✗ Branch 83 → 96 not taken.
7 if (m_undoManager) {
2870
2/2
✓ Branch 86 → 87 taken 7 times.
✓ Branch 90 → 91 taken 7 times.
21 m_undoManager->push(
2871
2/2
✓ Branch 87 → 88 taken 7 times.
✓ Branch 89 → 90 taken 7 times.
21 tr("Label '%1' removed").arg(label),
2872 14 [this, uid, folderId, label]() {
2873
1/1
✓ Branch 3 → 4 taken 1 time.
1 addLabelInFolder(uid, folderId, label);
2874 1 });
2875 }
2876 22 }
2877
2878 // ── T-407 Private helpers ──
2879
2880 1 void MailController::crossFolderStoreFlag(const QString &folderPath,
2881 qint64 uid, const QString &flag,
2882 bool add, qint64 mutationToken) {
2883 1 ensureBodyConnection();
2884
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 1 time.
1 if (!m_bodyImap)
2885 ✗ return;
2886
2887
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 1 time.
1 if (m_bodyImapSelectedFolder == folderPath) {
2888 // Already on the right folder — execute immediately
2889 ✗ m_bodyImap->storeFlag(uid, flag, add, mutationToken);
2890 } else {
2891 // Need to SELECT first, then STORE
2892 auto conn = connect(
2893 1 m_bodyImap, &ImapService::folderSelected, this,
2894
0/4
✗ Branch 18 → 19 not taken.
✗ Branch 18 → 20 not taken.
✗ Branch 21 → 22 not taken.
✗ Branch 21 → 23 not taken.
2 [this, uid, flag, add, folderPath,
2895 mutationToken](const QString &path, int, quint32, quint64) {
2896 ✗ if (path == folderPath) {
2897 ✗ m_bodyImapSelectedFolder = path;
2898 ✗ m_bodyImap->storeFlag(uid, flag, add, mutationToken);
2899 }
2900 ✗ },
2901
1/1
✓ Branch 10 → 11 taken 1 time.
1 Qt::SingleShotConnection);
2902 Q_UNUSED(conn);
2903
1/1
✓ Branch 12 → 13 taken 1 time.
1 m_bodyImap->selectFolder(folderPath);
2904 1 }
2905 }
2906
2907 // T-200: Mark all mails in a folder as read
2908 11 void MailController::markFolderAllSeen(const QString &folderPath) {
2909
3/6
✓ Branch 3 → 4 taken 11 times.
✗ Branch 3 → 6 not taken.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 11 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 11 times.
11 if (m_folder.accountId.isEmpty() || folderPath.isEmpty()) return;
2910 11 const auto folderId = m_cache->ensureFolder(m_folder.accountId, folderPath);
2911
1/2
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 11 times.
11 if (folderId < 0) return;
2912
1/1
✓ Branch 16 → 17 taken 11 times.
11 markFolderSeenPage(m_folder.accountId, folderId, m_cache->uidValidity(folderId),
2913 11 0, m_cache->maxUid(folderId));
2914 }
2915
2916 23 void MailController::markFolderSeenPage(QString account, qint64 folderId,
2917 quint32 uidValidity, qint64 afterUid,
2918 qint64 throughUid) {
2919
4/7
✓ Branch 3 → 4 taken 23 times.
✗ Branch 3 → 6 not taken.
✓ Branch 4 → 5 taken 23 times.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 23 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 23 times.
23 if (account != m_folder.accountId || m_cache->uidValidity(folderId) != uidValidity) return;
2920
1/1
✓ Branch 10 → 11 taken 23 times.
23 const QString folder = m_cache->folderPath(folderId);
2921
1/2
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 23 times.
23 if (folder.isEmpty()) return;
2922
1/1
✓ Branch 14 → 15 taken 23 times.
23 const auto page = m_cache->unreadUidPage(folderId, afterUid, throughUid);
2923
1/2
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 21 taken 23 times.
23 if (!page) {
2924 ✗ emit statusMessage(tr("Could not read messages to mark as read"));
2925 ✗ return;
2926 }
2927
2/2
✓ Branch 29 → 24 taken 68 times.
✓ Branch 29 → 30 taken 23 times.
91 for (const auto uid : *page)
2928
1/1
✓ Branch 26 → 27 taken 68 times.
68 markMailAsSeenInFolder(uid, folderId);
2929
1/1
✓ Branch 33 → 34 taken 23 times.
23 settleFolderSeenPage(account, folderId, uidValidity, afterUid, throughUid, *page);
2930 23 }
2931
2932 32 void MailController::settleFolderSeenPage(QString account, qint64 folderId,
2933 quint32 uidValidity, qint64 afterUid, qint64 throughUid,
2934 QList<qint64> attemptedUids, int checks) {
2935
4/7
✓ Branch 3 → 4 taken 32 times.
✗ Branch 3 → 6 not taken.
✓ Branch 4 → 5 taken 32 times.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 32 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 32 times.
51 if (account != m_folder.accountId || m_cache->uidValidity(folderId) != uidValidity) return;
2936 // The visible Seen rows commit before the serial journal writer. Do not
2937 // treat their still-unread cache rows as failures or start the next page
2938 // until every intent in this bounded page has settled.
2939
4/4
✓ Branch 10 → 11 taken 32 times.
✓ Branch 11 → 12 taken 32 times.
✓ Branch 32 → 13 taken 77 times.
✓ Branch 32 → 33 taken 23 times.
100 for (const auto uid : attemptedUids) {
2940
2/2
✓ Branch 17 → 18 taken 68 times.
✓ Branch 17 → 19 taken 9 times.
77 if (!m_seenIntentGenerations.contains(MailId{account, folderId, uid})) continue;
2941
1/2
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 24 taken 9 times.
9 if (checks >= 3000) {
2942 ✗ emit statusMessage(tr("Could not queue all messages as read"));
2943 9 return;
2944 }
2945
1/5
✓ Branch 27 → 28 taken 9 times.
✗ Branch 93 → 94 not taken.
✗ Branch 93 → 95 not taken.
✗ Branch 96 → 97 not taken.
✗ Branch 96 → 98 not taken.
18 QTimer::singleShot(10, this, [this, account, folderId, uidValidity,
2946 9 afterUid, throughUid, attemptedUids = std::move(attemptedUids), checks]() mutable {
2947
1/1
✓ Branch 5 → 6 taken 9 times.
9 settleFolderSeenPage(account, folderId, uidValidity, afterUid, throughUid,
2948 9 std::move(attemptedUids), checks + 1);
2949 9 });
2950 9 return;
2951 }
2952
3/3
✓ Branch 34 → 36 taken 10 times.
✓ Branch 34 → 37 taken 13 times.
✓ Branch 37 → 38 taken 13 times.
23 const qint64 lastUid = attemptedUids.isEmpty() ? afterUid : attemptedUids.last();
2953
1/1
✓ Branch 39 → 40 taken 23 times.
23 const auto stillUnread = m_cache->unreadUidPage(folderId, afterUid, lastUid);
2954
3/6
✓ Branch 41 → 42 taken 23 times.
✗ Branch 41 → 45 not taken.
✗ Branch 44 → 45 not taken.
✓ Branch 44 → 46 taken 23 times.
✗ Branch 47 → 48 not taken.
✓ Branch 47 → 52 taken 23 times.
23 if (!stillUnread || !stillUnread->isEmpty()) {
2955 ✗ emit statusMessage(tr("Could not queue all messages as read"));
2956 ✗ return;
2957 }
2958
1/1
✓ Branch 52 → 53 taken 23 times.
23 const int unread = m_cache->unreadCount(folderId);
2959
1/1
✓ Branch 53 → 54 taken 23 times.
23 const QString folder = m_cache->folderPath(folderId);
2960
1/2
✗ Branch 55 → 56 not taken.
✓ Branch 55 → 57 taken 23 times.
23 if (folder.isEmpty()) return;
2961
1/1
✓ Branch 58 → 59 taken 23 times.
23 m_mutations.intents().setLastPolledUnread(folder, unread);
2962
1/1
✓ Branch 59 → 60 taken 23 times.
23 m_cache->storeBadge(folderId, unread);
2963 // The open folder's list model counts unread mail incrementally for its
2964 // loaded rows only; mail beyond the window kept the old total, and the
2965 // next flag echo (IDLE, readback) re-published that stale badge. Adopt
2966 // the settled cache count as the model's authoritative total.
2967
2/2
✓ Branch 60 → 61 taken 22 times.
✓ Branch 60 → 63 taken 1 time.
23 if (folderId == m_folder.currentFolderId)
2968
1/1
✓ Branch 62 → 63 taken 22 times.
22 m_model->setAuthoritativeCounts(m_model->totalHeaderCount(), unread);
2969
1/1
✓ Branch 63 → 64 taken 23 times.
23 emit cachedUnreadCountChanged(folder, unread);
2970
1/1
✓ Branch 64 → 65 taken 23 times.
23 emit unreadCountChanged(folder, unread);
2971
2/2
✓ Branch 66 → 67 taken 10 times.
✓ Branch 66 → 74 taken 13 times.
23 if (attemptedUids.isEmpty()) {
2972
3/3
✓ Branch 67 → 68 taken 10 times.
✓ Branch 69 → 70 taken 10 times.
✓ Branch 70 → 71 taken 10 times.
20 emit statusMessage(tr("%1 – all messages marked as read").arg(folder));
2973 10 return;
2974 }
2975 // Keep cached membership independent of the visible model and yield between
2976 // bounded pages. Later arrivals above the captured UID remain unread.
2977
1/3
✓ Branch 75 → 76 taken 13 times.
✗ Branch 113 → 114 not taken.
✗ Branch 113 → 115 not taken.
13 QTimer::singleShot(0, this, [this, account, folderId, uidValidity,
2978 after = lastUid, throughUid] {
2979
1/1
✓ Branch 3 → 4 taken 12 times.
12 markFolderSeenPage(account, folderId, uidValidity, after, throughUid);
2980 12 });
2981 33 }
2982
2983 // ═════════════════════════════════════════════════════════
2984 // T-211: Undo Move
2985 // ═════════════════════════════════════════════════════════
2986
2987 4 QList<qint64> MailController::uncachedUids(qint64 folderId, const QList<qint64> &uids) const {
2988 4 QList<MailId> ids;
2989
1/1
✓ Branch 3 → 4 taken 4 times.
4 ids.reserve(uids.size());
2990
3/5
✓ Branch 8 → 9 taken 7 times.
✓ Branch 12 → 6 taken 7 times.
✓ Branch 12 → 13 taken 4 times.
✗ Branch 39 → 40 not taken.
✗ Branch 39 → 41 not taken.
11 for (const qint64 uid : uids) ids.append(MailId{m_folder.accountId, folderId, uid});
2991 4 QSet<qint64> cached;
2992
6/6
✓ Branch 14 → 15 taken 4 times.
✓ Branch 15 → 16 taken 4 times.
✓ Branch 16 → 17 taken 4 times.
✓ Branch 19 → 20 taken 3 times.
✓ Branch 22 → 18 taken 3 times.
✓ Branch 22 → 23 taken 4 times.
7 for (const MailHeader &header : m_cache->headersByIds(ids)) cached.insert(header.uid);
2993 4 QList<qint64> missing;
2994
5/5
✓ Branch 28 → 29 taken 4 times.
✓ Branch 28 → 30 taken 3 times.
✓ Branch 29 → 30 taken 4 times.
✓ Branch 32 → 26 taken 7 times.
✓ Branch 32 → 33 taken 4 times.
11 for (const qint64 uid : uids) if (!cached.contains(uid)) missing.append(uid);
2995 4 return missing;
2996 4 }
2997
2998 16 qint64 MailController::resolveFolderId(const QString &folderPath) {
2999 16 return m_cache->ensureFolder(m_folder.accountId, folderPath);
3000 }
3001