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: 88.2% 1910 / 0 / 2166
Functions: 90.5% 238 / 0 / 263
Branches: 68.3% 2605 / 0 / 3813

presentation/QuickMailBackend.cpp
Line Branch Exec Source
1 #include "PimEditorTime.h"
2 #include "ComposeDraftStore.h"
3 #include "MailPermanentDelete.h"
4 #include "ComposeRecipients.h"
5 #include "DavAccountSettings.h"
6 #include "QuickMailBackend.h"
7 #include "RuleDraftFactory.h"
8 #include "data/MailRuleCandidates.h"
9 #include "performance/ThreadSelectionPerformanceTracker.h"
10 #include "WorkspaceState.h"
11 #include <QFutureWatcher>
12 #include <QPointer>
13 #include <QtConcurrent>
14 #include "app/core/MailSessionCore.h"
15 #include "app/core/AccountSession.h"
16 #include "data/MailCache.h"
17 #include "app/core/CalendarSyncCoordinator.h"
18 #include "data/CalendarStore.h"
19 #include "ui/ThemeManager.h"
20 #include <QColor>
21 #include <QCryptographicHash>
22 #include <QCoreApplication>
23 #include "app/core/ContactSyncCoordinator.h"
24 #include "app/core/FolderPredictionSession.h"
25 #include "app/SearchCoordinator.h"
26 #include "ui/LabelDelegate.h"
27 #include "app/SearchQuery.h"
28 #include "app/FtsIndexCoordinator.h"
29 #include "app/NamedCommandCatalog.h"
30 #include "service/MailSearchReadService.h"
31 #include "controller/MailController.h"
32 #include "controller/UndoManager.h"
33 #include "data/FolderSessionSnapshot.h"
34 #include "data/DavCredentials.h"
35 #include <QUuid>
36 #include <limits>
37 #include <optional>
38 #include "service/ImapService.h"
39 #include "service/ConnectionHealthMonitor.h"
40 #include "service/ImapResponseParser.h"
41 #include "service/MailDisplayReadService.h"
42 #include "service/MailPayloadExportService.h"
43 #include "service/Rfc2822Builder.h"
44 #include "service/SmtpService.h"
45 #include "service/MimeParser.h"
46 #include "data/MailSelectionMutation.h"
47 #include "data/FolderPreferences.h"
48 #include "data/AccountConfig.h"
49 #include "data/CredentialStore.h"
50 #include "util/MessageId.h"
51 #include "quick/ExternalWhitelistModel.h"
52 #include "ui/ExternalContentPolicy.h"
53 #include "ui/MailThreadModel.h"
54 #include <QDir>
55 #include <QFile>
56 #include <QSettings>
57 #include <QWindow>
58 #include <QMenu>
59 #include <QApplication>
60 #include <QStandardPaths>
61 #include <QElapsedTimer>
62 #include <QTimer>
63 #include <QPointer>
64 #include <QJsonArray>
65 #include <QJsonObject>
66 #include <QRegularExpression>
67 #include <QSaveFile>
68 #include <QScopedValueRollback>
69 #include <QLocale>
70 #include <algorithm>
71 #include <cmath>
72 #include <utility>
73 #include <QQuickTextDocument>
74 #include "service/NativeResourceDocument.h"
75 #include <QLoggingCategory>
76
77
4/7
✓ Branch 2 → 3 taken 20 times.
✓ Branch 2 → 8 taken 4094 times.
✓ Branch 4 → 5 taken 20 times.
✗ Branch 4 → 8 not taken.
✓ Branch 5 → 6 taken 20 times.
✗ Branch 10 → 11 not taken.
✗ Branch 10 → 12 not taken.
4114 Q_LOGGING_CATEGORY(lcQuickBackend, "mailjd.quick.backend")
78
79 795 QuickMailBackend::QuickMailBackend(MailSessionCore &session, WorkspaceState &workspace,
80 795 QString profileDirectory, QObject *parent, MailSearchReadService *searchReader)
81 795 : QObject(parent), m_session(session), m_workspace(workspace),
82
1/1
✓ Branch 9 → 10 taken 795 times.
795 m_profileDirectory(std::move(profileDirectory)), m_mail(session.headers(), {}, this),
83
1/1
✓ Branch 13 → 14 taken 795 times.
795 m_threadList(session.threads(), {}, this),
84 795 m_pim(session, workspace, m_preferences,
85 2588 {[this](const MailId &id) { return acceptsIdentity(id); },
86 24 [this] { return accountName(); },
87 100 [this](const QString &status) { setStatus(status); }},
88 this),
89
1/1
✓ Branch 20 → 21 taken 795 times.
795 m_folders(this),
90 795 m_settings(session, workspace, m_preferences, m_pim, m_profileDirectory,
91 39 {[this] { return activeAccountId(); },
92
2/4
✓ Branch 2 → 3 taken 24 times.
✗ Branch 2 → 5 not taken.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 24 times.
24 [this] { return m_actionPending || m_compose.draftCleanupActive(); },
93 ✗ [this] { loadLocalFolders(); },
94 2474 [this] { return activeAccountState(); }},
95 this),
96
2/2
✓ Branch 38 → 39 taken 795 times.
✓ Branch 40 → 41 taken 795 times.
795 m_folderSubscriptions(m_profileDirectory, this), m_messageCopies(session, this),
97 795 m_source(session, workspace, m_profileDirectory,
98 12 {[this] { return configured(); },
99 8 [this](const QString &status) { setStatus(status); }},
100 this),
101 795 m_folderActions(session, m_folders, m_folderInfos, m_availableFolders, m_folderPath,
102 795 m_profileDirectory,
103 34 {[this] { return configured(); },
104 2 [this] { return accountName(); },
105 27 [this](const QString &status) { setStatus(status); },
106 3 [this](const QString &text) { m_statusState.error(text); },
107 ✗ [this] { applyFolderList(m_folderInfos, false); },
108 ✗ [this](const QString &path) {
109 1 ++m_threadDataGeneration;
110
1/1
✓ Branch 2 → 3 taken 1 time.
1 m_threadSnapshots.remove(folderIdForPath(path));
111 1 }},
112 this),
113 795 m_compose(session, workspace, m_profileDirectory, m_threadPool, m_draftsFolder,
114 795 m_sentFolder, m_folderPathById, m_folderEpochById,
115 11 {[this](const MailId &id) { return acceptsIdentity(id); },
116 32 [this] { return accountName(); },
117 40 [this] { return accountEmail(); },
118 ✗ [this] { return activeAccountId(); },
119 60 [this](const QString &status) { setStatus(status); },
120 16 [this](const QString &text) { m_statusState.error(text); }},
121
27/49
✓ Branch 3 → 4 taken 795 times.
✓ Branch 27 → 28 taken 795 times.
✓ Branch 29 → 30 taken 795 times.
✓ Branch 36 → 37 taken 795 times.
✓ Branch 49 → 50 taken 795 times.
✓ Branch 52 → 53 taken 795 times.
✓ Branch 53 → 54 taken 795 times.
✓ Branch 78 → 79 taken 795 times.
✓ Branch 87 → 88 taken 795 times.
✓ Branch 89 → 90 taken 795 times.
✓ Branch 95 → 96 taken 795 times.
✓ Branch 98 → 99 taken 795 times.
✓ Branch 101 → 102 taken 795 times.
✓ Branch 109 → 110 taken 795 times.
✓ Branch 120 → 121 taken 795 times.
✓ Branch 121 → 122 taken 795 times.
✓ Branch 122 → 123 taken 795 times.
✓ Branch 123 → 124 taken 795 times.
✓ Branch 125 → 126 taken 795 times.
✓ Branch 127 → 128 taken 795 times.
✓ Branch 128 → 129 taken 795 times.
✓ Branch 129 → 130 taken 795 times.
✓ Branch 131 → 132 taken 795 times.
✓ Branch 133 → 134 taken 795 times.
✓ Branch 134 → 135 taken 795 times.
✓ Branch 136 → 137 taken 795 times.
✓ Branch 137 → 138 taken 795 times.
✗ Branch 259 → 260 not taken.
✗ Branch 259 → 261 not taken.
✗ Branch 262 → 263 not taken.
✗ Branch 262 → 264 not taken.
✗ Branch 265 → 266 not taken.
✗ Branch 265 → 267 not taken.
✗ Branch 271 → 272 not taken.
✗ Branch 271 → 273 not taken.
✗ Branch 274 → 275 not taken.
✗ Branch 274 → 276 not taken.
✗ Branch 283 → 284 not taken.
✗ Branch 283 → 285 not taken.
✗ Branch 286 → 287 not taken.
✗ Branch 286 → 288 not taken.
✗ Branch 289 → 290 not taken.
✗ Branch 289 → 291 not taken.
✗ Branch 292 → 293 not taken.
✗ Branch 292 → 294 not taken.
✗ Branch 298 → 299 not taken.
✗ Branch 298 → 300 not taken.
✗ Branch 301 → 302 not taken.
✗ Branch 301 → 303 not taken.
4770 this) {
122 829 m_selectionSummary.setHeaderLookup([this](const QList<MailId> &ids) { return m_session.cache()->headersByIds(ids); });
123
2/2
✓ Branch 142 → 143 taken 795 times.
✓ Branch 143 → 144 taken 795 times.
795 m_mailSort = MailSortOrder::fromString(m_preferences.mailSort());
124
1/1
✓ Branch 145 → 146 taken 795 times.
795 applyMailSort();
125
1/1
✓ Branch 146 → 147 taken 795 times.
795 wireProjectionTimers();
126
1/1
✓ Branch 147 → 148 taken 795 times.
795 m_settings.connectSettingsSync();
127
1/1
✓ Branch 148 → 149 taken 795 times.
795 wireThreadInvalidation();
128
1/1
✓ Branch 149 → 150 taken 795 times.
795 wireIndexedProviders();
129
1/1
✓ Branch 150 → 151 taken 795 times.
795 wireHeaderProjection();
130
1/1
✓ Branch 151 → 152 taken 795 times.
795 wireProjectionSignals();
131
1/1
✓ Branch 152 → 153 taken 795 times.
795 m_settings.connectDavDiscovery();
132
1/1
✓ Branch 153 → 154 taken 795 times.
795 wireComposeSource();
133
1/1
✓ Branch 154 → 155 taken 795 times.
795 wireFolderSubscriptionsAndFilters();
134
1/1
✓ Branch 155 → 156 taken 795 times.
795 wireFolderOperations();
135
1/1
✓ Branch 156 → 157 taken 795 times.
795 m_compose.connectServerDrafts();
136 795 m_compose.addressPicker().setDatabasePath(m_pim.contactModel().databasePath());
137
1/1
✓ Branch 162 → 163 taken 795 times.
795 m_pim.connectSignals();
138
1/1
✓ Branch 163 → 164 taken 795 times.
795 m_conflicts.connectSources();
139 // Sprint 100 S100.1.5: the Accounts page shows the active account's state.
140
1/1
✓ Branch 165 → 166 taken 795 times.
795 connect(session.imap(), &ImapService::stateChanged, &m_settings, &QuickAccountSettings::notifyOverviewChanged);
141
1/1
✓ Branch 167 → 168 taken 795 times.
795 connect(&m_rules, &QuickRulesBackend::stateChanged, &m_settings, &QuickAccountSettings::notifyOverviewChanged);
142
1/1
✓ Branch 169 → 170 taken 795 times.
795 connect(&m_rules, &QuickRulesBackend::documentChanged, &m_settings, &QuickAccountSettings::notifyOverviewChanged);
143
1/1
✓ Branch 171 → 172 taken 795 times.
795 wirePredictions();
144
1/1
✓ Branch 172 → 173 taken 795 times.
795 wireRemovalFollowUp();
145
1/1
✓ Branch 173 → 174 taken 795 times.
795 wireFolderChangeResets();
146
1/1
✓ Branch 174 → 175 taken 795 times.
795 wireSearch(searchReader);
147
1/1
✓ Branch 175 → 176 taken 795 times.
795 wireSearchTabs(searchReader);
148
1/1
✓ Branch 176 → 177 taken 795 times.
795 wireSearchSuggestions();
149
1/1
✓ Branch 177 → 178 taken 795 times.
795 wireSelection();
150
1/1
✓ Branch 178 → 179 taken 795 times.
795 wireAccountReload();
151
1/1
✓ Branch 179 → 180 taken 795 times.
795 wireSelectionTrace();
152
1/1
✓ Branch 180 → 181 taken 795 times.
795 m_source.connectSignals();
153
1/1
✓ Branch 181 → 182 taken 795 times.
795 wireNavigation();
154
1/1
✓ Branch 182 → 183 taken 795 times.
795 wireControllerStatus();
155
1/1
✓ Branch 183 → 184 taken 795 times.
795 wireConnectionStatus();
156 4770 }
157
158 // Thread-projection timers: provisional page and slow-rebuild flag.
159 795 void QuickMailBackend::wireProjectionTimers() {
160 795 m_provisionalPageTimer.setSingleShot(true);
161 795 m_provisionalPageTimer.setInterval(ProvisionalPageDelayMs);
162
1/1
✓ Branch 4 → 5 taken 795 times.
795 connect(&m_provisionalPageTimer, &QTimer::timeout, this, &QuickMailBackend::requestProvisionalPage);
163 795 m_threadProjectionSlowTimer.setSingleShot(true);
164
1/1
✓ Branch 7 → 8 taken 795 times.
795 connect(&m_threadProjectionSlowTimer, &QTimer::timeout, this, [this] {
165
2/4
✓ Branch 2 → 3 taken 2 times.
✗ Branch 2 → 4 not taken.
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 2 times.
2 if (!m_threadProjectionPending || m_threadProjectionSlow) return;
166 2 m_threadList.releaseHeldRows();
167 2 m_threadProjectionSlow = true;
168 2 emit threadProjectionSlowChanged();
169 });
170 795 }
171
172 // Thread projection: cache/controller invalidation of retained folder snapshots and visual moves.
173 795 void QuickMailBackend::wireThreadInvalidation() {
174 795 MailSessionCore &session = m_session;
175 719 const auto invalidateThreadFolder = [this](qint64 folderId) {
176 719 ++m_threadDataGeneration;
177 719 m_threadSnapshots.remove(folderId);
178
6/8
✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 9 taken 717 times.
✓ Branch 6 → 7 taken 2 times.
✗ Branch 6 → 9 not taken.
✓ Branch 7 → 8 taken 2 times.
✗ Branch 7 → 9 not taken.
✓ Branch 10 → 11 taken 2 times.
✓ Branch 10 → 13 taken 717 times.
719 if (m_threadList.indexed() && m_threadList.indexedFolder() == folderId && !m_threadRefreshQueued) {
179 2 m_threadRefreshQueued = true;
180
1/1
✓ Branch 11 → 12 taken 2 times.
2 QTimer::singleShot(0, this, [this] {
181 2 m_threadRefreshQueued = false;
182 2 syncThreadProjection();
183 2 });
184 }
185 719 };
186
1/1
✓ Branch 3 → 4 taken 795 times.
795 connect(session.cache(), &MailCache::headerStateReplaced, this, invalidateThreadFolder);
187
1/1
✓ Branch 6 → 7 taken 795 times.
795 connect(session.cache(), &MailCache::headerFlagsChanged, this, invalidateThreadFolder);
188
1/1
✓ Branch 9 → 10 taken 795 times.
795 connect(session.cache(), &MailCache::headerLabelChanged, this, invalidateThreadFolder);
189
1/1
✓ Branch 12 → 13 taken 795 times.
795 connect(session.cache(), &MailCache::headerRemoved, this, invalidateThreadFolder);
190 // P90.16: journal phase transitions change the pending-move admission
191 // set without touching the folder revision (e.g. a failed move must
192 // re-admit its row, a queued one must hide it). Force a projection
193 // refresh for the indexed folder only.
194
1/1
✓ Branch 15 → 16 taken 795 times.
795 connect(session.cache(), &MailCache::mutationPhaseChanged, this,
195 795 [this](const QString &account, qint64 folderId, qint64, qint64 uid,
196 bool pending) {
197 ✗ if (account != accountName() || !m_threadList.indexed() ||
198 ✗ m_threadList.indexedFolder() != folderId) return;
199 ✗ if (pending) m_threadList.markIndexedMovePending(folderId, uid);
200 ✗ queueIndexedMoveExclusionRefresh(folderId);
201 });
202 // Visual-first moves: hide the rows before the durable writer answers and
203 // keep them hidden across any exclusion refresh until it has (the journal
204 // then decides: journaled rows stay excluded, rolled-back rows return).
205
1/1
✓ Branch 18 → 19 taken 795 times.
795 connect(session.controller(), &MailController::movesVisuallyPending, this,
206 795 [this](qint64 folderId, const QList<qint64> &uids) {
207 47 const auto selected = m_workspace.mailId();
208 47 bool removedSelection = false;
209
2/2
✓ Branch 16 → 6 taken 131 times.
✓ Branch 16 → 17 taken 47 times.
178 for (const qint64 uid : uids) {
210
2/2
✓ Branch 7 → 8 taken 131 times.
✓ Branch 8 → 9 taken 131 times.
131 m_visualMoveUids[folderId].insert(uid);
211
1/1
✓ Branch 9 → 10 taken 131 times.
131 m_threadList.markIndexedMovePending(folderId, uid);
212
3/4
✓ Branch 10 → 11 taken 131 times.
✗ Branch 10 → 13 not taken.
✓ Branch 11 → 12 taken 47 times.
✓ Branch 11 → 13 taken 84 times.
131 removedSelection |= selected.folderId == folderId && selected.uid == uid;
213 }
214 // The reserved successor (P90.16 token) takes over in the same visual
215 // commit instead of after the journal-driven exclusion refresh.
216
4/6
✓ Branch 17 → 18 taken 47 times.
✗ Branch 17 → 27 not taken.
✓ Branch 19 → 20 taken 46 times.
✓ Branch 19 → 27 taken 1 time.
✓ Branch 25 → 26 taken 46 times.
✗ Branch 25 → 27 not taken.
93 if (removedSelection && m_workspace.activeTab() == 0 &&
217
8/8
✓ Branch 28 → 29 taken 46 times.
✓ Branch 28 → 30 taken 1 time.
✓ Branch 30 → 31 taken 46 times.
✓ Branch 30 → 32 taken 1 time.
✓ Branch 32 → 33 taken 46 times.
✓ Branch 32 → 34 taken 1 time.
✓ Branch 34 → 35 taken 46 times.
✓ Branch 34 → 37 taken 1 time.
93 m_workspace.area() == QStringLiteral("mail"))
218
1/1
✓ Branch 35 → 36 taken 46 times.
46 QTimer::singleShot(0, this, &QuickMailBackend::reconcileSelection);
219 47 });
220
1/1
✓ Branch 21 → 22 taken 795 times.
795 connect(session.controller(), &MailController::movesVisuallySettled, this,
221 795 [this](qint64 folderId, const QList<qint64> &uids) {
222
1/1
✓ Branch 2 → 3 taken 43 times.
43 auto visual = m_visualMoveUids.find(folderId);
223
1/2
✓ Branch 5 → 6 taken 43 times.
✗ Branch 5 → 20 not taken.
43 if (visual != m_visualMoveUids.end()) {
224
3/3
✓ Branch 10 → 11 taken 127 times.
✓ Branch 13 → 8 taken 127 times.
✓ Branch 13 → 14 taken 43 times.
170 for (const qint64 uid : uids) visual->remove(uid);
225
3/3
✓ Branch 16 → 17 taken 40 times.
✓ Branch 16 → 20 taken 3 times.
✓ Branch 18 → 19 taken 40 times.
43 if (visual->isEmpty()) m_visualMoveUids.erase(visual);
226 }
227
5/6
✓ Branch 21 → 22 taken 1 time.
✓ Branch 21 → 25 taken 42 times.
✓ Branch 23 → 24 taken 1 time.
✗ Branch 23 → 25 not taken.
✓ Branch 26 → 27 taken 1 time.
✓ Branch 26 → 28 taken 42 times.
43 if (m_threadList.indexed() && m_threadList.indexedFolder() == folderId)
228
1/1
✓ Branch 27 → 28 taken 1 time.
1 queueIndexedMoveExclusionRefresh(folderId);
229 43 });
230
1/1
✓ Branch 24 → 25 taken 795 times.
795 connect(session.cache(), &MailCache::folderHeadersRemoved, this, invalidateThreadFolder);
231
1/1
✓ Branch 27 → 28 taken 795 times.
795 connect(session.controller(), &MailController::folderIdentityInvalidated, this, invalidateThreadFolder);
232 795 }
233 // Indexed-page providers the controller reads (visible flags, row headers, adjacent headers).
234 795 void QuickMailBackend::wireIndexedProviders() {
235 795 MailSessionCore &session = m_session;
236
1/1
✓ Branch 2 → 3 taken 795 times.
795 const QPointer<QuickMailBackend> indexedOwner(this);
237
1/1
✓ Branch 6 → 7 taken 795 times.
795 session.controller()->flagSync()->setVisibleIndexedFlagsProvider([indexedOwner](qint64 folderId) {
238 ✗ QHash<qint64, quint32> flags;
239 ✗ if (!indexedOwner || !indexedOwner->threadedPresentation() ||
240 ✗ !indexedOwner->m_threadList.indexed() ||
241 ✗ indexedOwner->m_threadList.indexedFolder() != folderId) return flags;
242 ✗ const int rows = indexedOwner->m_threadList.rowCount();
243 ✗ flags.reserve(rows);
244 ✗ for (int row = 0; row < rows; ++row) {
245 ✗ const auto key = indexedOwner->m_threadList.keyAt(row);
246 ✗ if (const auto *header = indexedOwner->m_threadList.indexedHeader(folderId, key.uid))
247 ✗ flags.insert(key.uid, header->flags);
248 ✗ }
249 ✗ return flags;
250 ✗ });
251
1/1
✓ Branch 12 → 13 taken 795 times.
795 session.controller()->setIndexedHeaderProvider([indexedOwner](const MailId &id) -> std::optional<MailHeader> {
252
2/4
✓ Branch 3 → 4 taken 1 time.
✗ Branch 3 → 11 not taken.
✓ Branch 6 → 7 taken 1 time.
✗ Branch 6 → 11 not taken.
2 if (!indexedOwner || !indexedOwner->m_threadList.indexed() ||
253
3/6
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 1 time.
✓ Branch 13 → 14 taken 1 time.
✗ Branch 13 → 15 not taken.
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 1 time.
2 id.accountId != indexedOwner->accountName()) return std::nullopt;
254
1/2
✓ Branch 19 → 20 taken 1 time.
✗ Branch 19 → 21 not taken.
1 if (const auto *header = indexedOwner->m_threadList.indexedHeader(id.folderId, id.uid)) return *header;
255 ✗ return std::nullopt;
256 });
257
1/1
✓ Branch 19 → 20 taken 795 times.
795 session.controller()->bodies()->setIndexedAdjacentHeadersProvider([indexedOwner](const MailId &id) {
258 ✗ QList<MailHeader> headers;
259 ✗ if (!indexedOwner || !indexedOwner->threadedPresentation() ||
260 ✗ !indexedOwner->m_threadList.indexed() ||
261 ✗ indexedOwner->m_threadList.indexedFolder() != id.folderId ||
262 ✗ id.accountId != indexedOwner->accountName()) return headers;
263 ✗ const int row = indexedOwner->m_threadList.rowForIdentity(WorkspaceState::encode(id));
264 ✗ if (row < 0) return headers;
265 ✗ for (int offset : {1, -1, 2}) {
266 ✗ const auto key = indexedOwner->m_threadList.keyAt(row + offset);
267 ✗ if (key.uid <= 0) continue;
268 ✗ if (const auto *header = indexedOwner->m_threadList.indexedHeader(id.folderId, key.uid))
269 ✗ headers.append(*header);
270 ✗ }
271 ✗ return headers;
272 ✗ });
273 795 }
274 // Header-projection catch-up timer and indexed flag/label/seen/star updates.
275 795 void QuickMailBackend::wireHeaderProjection() {
276 795 MailSessionCore &session = m_session;
277 // Header batches commit through the worker-owned cache connection, so the
278 // GUI cache emits no per-row signal for them. The controller forwards only
279 // revision-changing commits. Coalesce those projection catch-ups while
280 // keeping the published thread page and selection mounted.
281 795 m_headerProjectionTimer.setSingleShot(true);
282 795 m_headerProjectionTimer.setInterval(150);
283
1/1
✓ Branch 4 → 5 taken 795 times.
795 connect(&m_headerProjectionTimer, &QTimer::timeout, this, [this] {
284 ✗ const QString folder = m_headerProjectionFolder;
285 ✗ m_headerProjectionFolder.clear();
286 ✗ if (threadedPresentation() && folder == m_folderPath &&
287 ✗ m_session.controller()->currentFolder() == m_folderPath)
288 ✗ syncThreadProjection();
289 ✗ });
290
1/1
✓ Branch 7 → 8 taken 795 times.
795 connect(session.controller(), &MailController::headerProjectionAdvanced, this,
291 795 [this](const QString &path, qint64 folderId, int totalCount) {
292 ✗ if (path != m_session.controller()->currentFolder() ||
293 ✗ folderId != m_session.controller()->currentFolderId()) return;
294 ✗ if (folderId <= 0) return;
295 // A cold stream can cross the indexed threshold after folder selection.
296 // Stop publishing further batches into the hidden flat model before the
297 // controller handles this very batch.
298 ✗ if (threadedPresentation() && m_session.controller()->streamedHeadersVisible() &&
299 totalCount > 200)
300 ✗ m_session.controller()->setStreamedHeadersVisible(false);
301 ✗ ++m_threadDataGeneration;
302 ✗ m_threadSnapshots.remove(folderId);
303 ✗ if (!threadedPresentation() || path != m_folderPath) return;
304 ✗ m_headerProjectionFolder = path;
305 ✗ if (!m_headerProjectionTimer.isActive()) m_headerProjectionTimer.start();
306 });
307
1/1
✓ Branch 10 → 11 taken 795 times.
795 connect(session.cache(), &MailCache::headerFlagsChanged, &m_threadList, &QuickThreadListModel::updateIndexedFlags);
308 // The controller's loaded-row Seen commit reaches the indexed list before
309 // its background journal write. The same signal restores it on rejection.
310
1/1
✓ Branch 13 → 14 taken 795 times.
795 connect(session.controller(), &MailController::seenVisualChanged, &m_threadList,
311 795 [this](qint64 folderId, qint64 uid, bool seen) {
312 73 m_threadList.applyIndexedSeen(folderId, uid, seen);
313 73 });
314
1/1
✓ Branch 16 → 17 taken 795 times.
795 connect(session.controller(), &MailController::starVisualChanged, &m_threadList,
315 795 [this](qint64 folderId, qint64 uid, bool starred) {
316 298 m_threadList.applyIndexedStar(folderId, uid, starred);
317 298 });
318
1/1
✓ Branch 19 → 20 taken 795 times.
795 connect(session.cache(), &MailCache::headerLabelChanged, &m_threadList, &QuickThreadListModel::updateIndexedLabel);
319 // The reader header's marks follow the same visual-first signals.
320
1/1
✓ Branch 22 → 23 taken 795 times.
795 connect(session.controller(), &MailController::starVisualChanged, this, [this](qint64 folderId, qint64 uid, bool starred) {
321
1/1
✓ Branch 3 → 4 taken 298 times.
339 readerMarkChanged(folderId, uid, [starred](ReaderMarks &marks) { marks.importantOverride = starred; });
322 298 });
323
1/1
✓ Branch 25 → 26 taken 795 times.
795 connect(session.controller(), &MailController::labelVisualChanged, this,
324 795 [this](qint64 folderId, qint64 uid, const QString &label, bool present) {
325
1/1
✓ Branch 3 → 4 taken 18 times.
34 readerMarkChanged(folderId, uid, [&label, present](ReaderMarks &marks) { marks.labelOverrides.insert(label, present); });
326 18 });
327 // A mail tab's mail outside the loaded rows changes in the cache only
328 // (the cross-folder flag path and its undo).
329
1/1
✓ Branch 28 → 29 taken 795 times.
795 connect(session.cache(), &MailCache::headerFlagsChanged, this, [this](qint64 folderId, qint64 uid, quint32 flags) {
330
1/1
✓ Branch 3 → 4 taken 112 times.
216 readerMarkChanged(folderId, uid, [flags](ReaderMarks &marks) { marks.importantOverride = (flags & MailFlag::Flagged) != 0; });
331 112 });
332
1/1
✓ Branch 31 → 32 taken 795 times.
795 connect(session.cache(), &MailCache::headerLabelChanged, this,
333 795 [this](qint64 folderId, qint64 uid, const QString &label, bool present) {
334
1/1
✓ Branch 3 → 4 taken 21 times.
40 readerMarkChanged(folderId, uid, [&label, present](ReaderMarks &marks) { marks.labelOverrides.insert(label, present); });
335 21 });
336
1/1
✓ Branch 33 → 34 taken 795 times.
795 connect(&m_workspace, &WorkspaceState::selectionChanged, this, &QuickMailBackend::readerMarksChanged);
337
1/1
✓ Branch 35 → 36 taken 795 times.
795 connect(&m_workspace, &WorkspaceState::activeTabChanged, this, &QuickMailBackend::readerMarksChanged);
338 // A flag change from the server (another client) reaches the cache and
339 // the loaded rows; the marks are read again.
340
1/1
✓ Branch 38 → 39 taken 795 times.
795 connect(session.headers(), &QAbstractItemModel::dataChanged, this, [this] {
341
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 4 taken 399 times.
399 if (!m_readerMarks.loaded) return;
342 399 m_readerMarks.loaded = false;
343 399 emit readerMarksChanged();
344 });
345 // Visual-first labels reach the indexed list before their journal write
346 // (and return there on rollback).
347
1/1
✓ Branch 41 → 42 taken 795 times.
795 connect(session.controller(), &MailController::labelVisualChanged, &m_threadList,
348 795 &QuickThreadListModel::updateIndexedLabel);
349 795 }
350 // Thread-list projection validators and projection state signals.
351 795 void QuickMailBackend::wireProjectionSignals() {
352 795 m_threadList.setProjectionValidator([this](const ThreadProjectionRequest &request, qint64 revision) {
353
4/10
✓ Branch 3 → 4 taken 29 times.
✗ Branch 3 → 9 not taken.
✓ Branch 6 → 7 taken 29 times.
✗ Branch 6 → 9 not taken.
✓ Branch 11 → 12 taken 29 times.
✗ Branch 11 → 13 not taken.
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 29 times.
✗ Branch 24 → 25 not taken.
✗ Branch 24 → 26 not taken.
58 if (!threadedPresentation() || request.account != accountName() ||
354
2/3
✓ Branch 7 → 8 taken 29 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 29 times.
29 request.folderId != folderIdForPath(m_folderPath)) return false;
355 // The worker page must match one coherent folder snapshot. Two separate
356 // GUI queries also paid twice for SQLite admission on every page.
357
1/1
✓ Branch 16 → 17 taken 29 times.
29 const auto state = m_session.cache()->folderPresentationState(request.folderId);
358
2/4
✓ Branch 17 → 18 taken 29 times.
✗ Branch 17 → 20 not taken.
✓ Branch 18 → 19 taken 29 times.
✗ Branch 18 → 20 not taken.
29 return request.uidValidity == state.uidValidity && revision == state.threadRevision;
359 });
360 795 m_threadList.setProjectionIdentityValidator([this](const ThreadProjectionRequest &request) {
361 ✗ return threadedPresentation() && request.account == accountName() &&
362 ✗ request.folderId == folderIdForPath(m_folderPath) &&
363 ✗ request.uidValidity == m_session.cache()->folderPresentationState(request.folderId).uidValidity;
364 });
365
1/1
✓ Branch 8 → 9 taken 795 times.
795 connect(&m_threadList, &QuickThreadListModel::projectionChanged, this, [this] {
366
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 79 times.
79 if (!m_threadList.indexed()) return;
367 79 m_threadProjectionPending = m_threadList.projectionPending();
368 // Only a page the user waits for may announce itself as slow; a
369 // background refresh keeps the published rows without a placeholder,
370 // which would also shift the list while it appears and disappears.
371
2/2
✓ Branch 7 → 8 taken 28 times.
✓ Branch 7 → 18 taken 51 times.
79 if (m_threadList.projectionBlocking()) {
372 28 armThreadProjectionSlowFlag();
373
4/4
✓ Branch 11 → 12 taken 19 times.
✓ Branch 11 → 15 taken 9 times.
✓ Branch 16 → 17 taken 19 times.
✓ Branch 16 → 22 taken 9 times.
47 if (m_threadList.provisionalPageWanted(m_threadList.indexedFolder()) &&
374
1/2
✓ Branch 13 → 14 taken 19 times.
✗ Branch 13 → 15 not taken.
19 !m_provisionalPageTimer.isActive())
375 19 m_provisionalPageTimer.start();
376 } else {
377 51 m_provisionalPageTimer.stop();
378 51 m_threadProjectionSlowTimer.stop();
379 // P90.1/P90.11 hysteresis: a synchronous folder switch re-enters
380 // pending (new projection) before any frame is painted, so the clear
381 // decision is deferred one turn — the placeholder cannot blink off
382 // for the handover gap and still clears when a page truly landed.
383 // Rows shown and no blocking load: a background refresh (including the
384 // catch-up after an older-revision page) keeps no placeholder.
385
1/1
✓ Branch 20 → 21 taken 51 times.
51 QTimer::singleShot(0, this, [this] {
386
6/7
✓ Branch 3 → 4 taken 36 times.
✓ Branch 3 → 8 taken 12 times.
✓ Branch 5 → 6 taken 36 times.
✓ Branch 6 → 7 taken 36 times.
✗ Branch 6 → 8 not taken.
✓ Branch 9 → 10 taken 36 times.
✓ Branch 9 → 11 taken 12 times.
48 if (!m_threadList.projectionBlocking() && m_threadList.rowCount() > 0)
387 36 clearThreadProjectionSlowFlag();
388 48 });
389 }
390 79 emit threadedChanged();
391 });
392
1/1
✓ Branch 10 → 11 taken 795 times.
795 connect(&m_threadList, &QuickThreadListModel::projectionReady, this, [this] {
393 33 m_threadProjectionFailed = false;
394 33 clearThreadProjectionSlowFlag();
395 33 reconcileSelection();
396 33 });
397
1/1
✓ Branch 12 → 13 taken 795 times.
795 connect(&m_threadList, &QuickThreadListModel::projectionStale, this, [this] {
398 ✗ QTimer::singleShot(0, this, &QuickMailBackend::syncThreadProjection);
399 ✗ });
400
1/1
✓ Branch 14 → 15 taken 795 times.
795 connect(&m_threadList, &QuickThreadListModel::projectionFailed, this, [this](const QString &error) {
401 ✗ m_threadProjectionFailed = true;
402 ✗ clearThreadProjectionSlowFlag();
403 ✗ m_statusState.error(tr("Conversations could not be loaded: %1").arg(error));
404 ✗ });
405 795 }
406
407 // Worker pool of compose (send and server-draft builds), folder-epoch cache
408 // maintenance and the compose source's cancellation (formerly one block; S91.3.1 split the
409 // compose part into QuickComposeBackend::connectComposeSource()).
410 795 void QuickMailBackend::wireComposeSource() {
411 795 MailSessionCore &session = m_session;
412 795 m_threadPool.setMaxThreadCount(1);
413
1/1
✓ Branch 4 → 5 taken 795 times.
795 connect(session.cache(), &MailCache::folderIdentityInvalidated, this,
414 795 [this](qint64 folderId) {
415 16 m_folderEpochById.remove(folderId);
416 16 });
417 795 m_compose.connectComposeSource();
418
1/1
✓ Branch 8 → 9 taken 795 times.
795 connect(session.cache(), &MailCache::folderUidValidityChanged, this,
419 795 [this](qint64 folderId, quint32 uidValidity) {
420
2/2
✓ Branch 3 → 4 taken 15 times.
✓ Branch 3 → 5 taken 1 time.
16 if (m_folderPathById.contains(folderId))
421 15 m_folderEpochById.insert(folderId, uidValidity);
422 16 });
423 795 }
424
425 // Folder subscriptions, status forwarding, flat-list filter and suggestion providers, image policy.
426 795 void QuickMailBackend::wireFolderSubscriptionsAndFilters() {
427 795 connect(&m_folderSubscriptions, &FolderSubscriptionEditor::saved, this,
428
1/1
✓ Branch 2 → 3 taken 795 times.
795 [this](const QString &account, const QStringList &paths) {
429
1/2
✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 8 not taken.
1 if (account == accountName()) m_session.controller()->setSubscribedFolders(paths);
430 1 });
431
1/1
✓ Branch 4 → 5 taken 795 times.
795 connect(&m_statusState, &StatusMessageState::changed, this, &QuickMailBackend::statusChanged);
432 // The flat list's quick filter also narrows the cached pages (whole
433 // folder), not only the rows already loaded into the window.
434 116 const auto flatFilter = [this] {
435 116 MailHeaderFilter filter;
436
1/1
✓ Branch 4 → 5 taken 116 times.
116 filter.text = m_mail.filterText().toCaseFolded();
437 116 filter.unread = m_mail.showUnreadOnly();
438 116 filter.starred = m_mail.showStarredOnly();
439 116 filter.attachments = m_mail.showWithAttachments();
440 116 return filter;
441 ✗ };
442 // One commit per filter intent: the controller's filtered page replaces
443 // the rows (whole folder); the proxy does not filter the loaded window
444 // first (that showed an interim, window-only result).
445 795 m_mail.setSourceFilterDriver([this, flatFilter] {
446
2/2
✓ Branch 3 → 4 taken 58 times.
✓ Branch 4 → 5 taken 58 times.
58 return m_session.controller()->setFlatFilter(flatFilter());
447 });
448
1/1
✓ Branch 9 → 10 taken 795 times.
795 connect(&m_mail, &MailFilterProxyModel::mailFilterChanged, this, [this, flatFilter] {
449
2/2
✓ Branch 3 → 4 taken 58 times.
✓ Branch 4 → 5 taken 58 times.
58 m_session.controller()->setFlatFilter(flatFilter());
450 58 });
451 795 m_session.controller()->setRetainedUnreadProvider([this](qint64 folderId) {
452 9 return m_mail.shownUnread(folderId);
453 });
454 376253 m_mail.setSuggestionProvider([this](const MailId &id) { return predictionText(id); });
455 16389 m_threadList.setSuggestionProvider([this](const MailId &id) { return predictionText(id); });
456 // S94.5.5: a task glyph on mails with an open linked task. The set holds
457 // canonical Message-IDs; an empty set skips the canonicalisation.
458 25032 const auto hasOpenTask = [this](const QString &rawMessageId) {
459
7/9
✓ Branch 3 → 4 taken 1407 times.
✓ Branch 3 → 8 taken 23625 times.
✓ Branch 4 → 5 taken 1407 times.
✓ Branch 6 → 7 taken 240 times.
✓ Branch 6 → 8 taken 1167 times.
✓ Branch 10 → 11 taken 1407 times.
✓ Branch 10 → 12 taken 23625 times.
✗ Branch 14 → 15 not taken.
✗ Branch 14 → 16 not taken.
25032 return !m_openTaskMessageIds.isEmpty() && m_openTaskMessageIds.contains(MessageId::canonicalFirst(rawMessageId));
460 795 };
461 795 m_mail.setTaskLinkProvider(hasOpenTask);
462 795 m_threadList.setTaskLinkProvider(hasOpenTask);
463 // F95-14: rows that a server rule matches show a marker.
464 25407 const auto ruleMatches = [this](const MailHeader &header) { return m_rules.matchesHeader(header); };
465 795 m_mail.setRuleMatchProvider(ruleMatches);
466 795 m_threadList.setRuleMatchProvider(ruleMatches);
467
1/1
✓ Branch 33 → 34 taken 795 times.
795 connect(&m_rules, &QuickRulesBackend::matchesChanged, this, [this] {
468 35 m_mail.ruleMatchesChanged();
469 35 m_threadList.ruleMatchesChanged();
470 35 });
471
1/1
✓ Branch 35 → 36 taken 795 times.
795 connect(&m_pim, &QuickPimBackend::taskLinksChanged, this, &QuickMailBackend::refreshTaskLinks);
472
1/1
✓ Branch 37 → 38 taken 795 times.
795 refreshTaskLinks();
473 // Any writer of the shared preference must refresh the QML mode binding.
474
1/1
✓ Branch 39 → 40 taken 795 times.
795 connect(ExternalContentPolicy::instance(), &ExternalContentPolicy::changed,
475
1/1
✓ Branch 38 → 39 taken 795 times.
795 &m_settings, &QuickAccountSettings::imagePolicyModeChanged);
476 795 }
477
478 // IMAP folder operation results (create/delete/rename/error).
479 // Folder-prediction debounce, quick-move fallback and invalidation sources.
480 795 void QuickMailBackend::wirePredictions() {
481 795 MailSessionCore &session = m_session;
482 795 WorkspaceState &workspace = m_workspace;
483 795 m_predictionDebounce.setSingleShot(true);
484 // The debounce only coalesces synchronous invalidation bursts (header
485 // dataChanged storms); a selected-mail hint must not pay a fixed delay.
486 795 m_predictionDebounce.setInterval(20);
487
1/1
✓ Branch 4 → 5 taken 795 times.
795 connect(&m_predictionDebounce, &QTimer::timeout, this, &QuickMailBackend::requestPredictions);
488 // F4.2(b): a pressed s that outran the prediction retries once the batch
489 // lands; if nothing arrives in the bounded window, the shell falls back
490 // to the manual move console like the old synchronous rejection did.
491 795 m_quickMoveFallback.setSingleShot(true);
492 795 m_quickMoveFallback.setInterval(750);
493
1/1
✓ Branch 8 → 9 taken 795 times.
795 connect(&m_quickMoveFallback, &QTimer::timeout, this, [this] {
494 ✗ if (!m_quickMoveArmed.isValid()) return;
495 ✗ disarmQuickMove();
496 ✗ emit manualMoveRequested();
497 });
498
1/1
✓ Branch 10 → 11 taken 795 times.
795 connect(this, &QuickMailBackend::suggestionsChanged, this, &QuickMailBackend::tryArmedQuickMove);
499
1/1
✓ Branch 13 → 14 taken 795 times.
795 connect(session.predictions(), &FolderPredictionSession::changed, this, &QuickMailBackend::invalidatePredictions);
500 // S105.3.5: the evidence model was (re)built — provisional (stored)
501 // suggestions are replaced by fresh ones.
502
1/1
✓ Branch 16 → 17 taken 795 times.
795 connect(FolderPredictionService::shared(), &FolderPredictionService::evidenceRebuilt, this,
503
1/1
✓ Branch 15 → 16 taken 795 times.
795 [this](const QString &account) {
504 ✗ if (account != accountName()) return;
505 ✗ m_predictions.clear();
506 ✗ invalidatePredictions();
507 });
508
1/1
✓ Branch 19 → 20 taken 795 times.
795 connect(session.predictions(), &FolderPredictionSession::trainingFinished, this,
509 795 [this](bool completed, int, const QString &error) {
510
1/5
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 10 taken 659 times.
✗ Branch 3 → 4 not taken.
✗ Branch 5 → 6 not taken.
✗ Branch 6 → 7 not taken.
659 if (!completed) setStatus(tr("Folder suggestions unavailable: %1").arg(error));
511 659 });
512
1/1
✓ Branch 21 → 22 taken 795 times.
795 connect(&workspace, &WorkspaceState::selectionChanged, this, &QuickMailBackend::invalidatePredictions);
513
1/1
✓ Branch 23 → 24 taken 795 times.
795 connect(&workspace, &WorkspaceState::activeTabChanged, this, &QuickMailBackend::invalidatePredictions);
514
1/1
✓ Branch 25 → 26 taken 795 times.
795 connect(&workspace, &WorkspaceState::areaChanged, this, &QuickMailBackend::invalidatePredictions);
515
1/1
✓ Branch 27 → 28 taken 795 times.
795 connect(&m_mail, &QAbstractItemModel::modelReset, this, &QuickMailBackend::invalidatePredictions);
516
1/1
✓ Branch 29 → 30 taken 795 times.
795 connect(&m_mail, &QAbstractItemModel::layoutChanged, this, &QuickMailBackend::invalidatePredictions);
517
1/1
✓ Branch 32 → 33 taken 795 times.
795 connect(session.headers(), &QAbstractItemModel::dataChanged, this, &QuickMailBackend::invalidatePredictions);
518
1/1
✓ Branch 35 → 36 taken 795 times.
795 connect(session.headers(), &QAbstractItemModel::rowsInserted, this, &QuickMailBackend::invalidatePredictions);
519
1/1
✓ Branch 38 → 39 taken 795 times.
795 connect(session.headers(), &QAbstractItemModel::rowsRemoved, this, &QuickMailBackend::invalidatePredictions);
520 795 }
521
522 // Selection follow-up after row removals (indexed, flat proxy, thread reset).
523 795 void QuickMailBackend::wireRemovalFollowUp() {
524 795 MailSessionCore &session = m_session;
525 // P90.16: mirror optimistic flat-model removals into the indexed thread
526 // list. Journaled moves remove rows from the flat/tree models immediately
527 // while the canonical header survives until the remote confirmation; the
528 // indexed page must drop the same identity visually so the freed slot and
529 // the reserved successor become visible in large folders too. The source
530 // model's own rowsAboutToBeRemoved still exposes the per-row identities.
531
1/1
✓ Branch 3 → 4 taken 795 times.
795 connect(session.headers(), &QAbstractItemModel::rowsAboutToBeRemoved, this,
532 795 [this](const QModelIndex &parent, int first, int last) {
533 // A flat-filter page hides rows; hidden is not deleted (S106-961). A
534 // window-slide eviction drops rows that still exist (S106-290).
535
4/4
✓ Branch 5 → 6 taken 4 times.
✓ Branch 5 → 12 taken 144 times.
✓ Branch 8 → 9 taken 3 times.
✓ Branch 8 → 12 taken 1 time.
300 if (parent.isValid() || !m_threadList.indexed() ||
536
5/6
✓ Branch 3 → 4 taken 148 times.
✗ Branch 3 → 12 not taken.
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 13 taken 2 times.
✓ Branch 14 → 15 taken 146 times.
✓ Branch 14 → 16 taken 2 times.
300 m_session.headers()->isHidingFilteredRows() ||
537 149 m_session.headers()->isEvictingRetainedRows()) return;
538 2 bool removedSelection = false;
539 2 QSet<qint64> removedIndexedUids;
540 2 const auto selected = m_workspace.mailId();
541
2/2
✓ Branch 37 → 20 taken 2 times.
✓ Branch 37 → 38 taken 2 times.
4 for (int row = first; row <= last; ++row) {
542
1/1
✓ Branch 21 → 22 taken 2 times.
2 const auto *header = m_session.headers()->headerAt(row);
543
1/2
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 24 taken 2 times.
2 if (!header) continue;
544
3/6
✓ Branch 25 → 26 taken 2 times.
✗ Branch 25 → 29 not taken.
✓ Branch 26 → 27 taken 2 times.
✗ Branch 26 → 29 not taken.
✓ Branch 30 → 31 taken 2 times.
✗ Branch 30 → 32 not taken.
4 if (selected.isValid() && selected.folderId == header->folderId &&
545
1/2
✓ Branch 27 → 28 taken 2 times.
✗ Branch 27 → 29 not taken.
2 selected.uid == header->uid)
546 2 removedSelection = true;
547
1/2
✓ Branch 33 → 34 taken 2 times.
✗ Branch 33 → 36 not taken.
2 if (header->folderId == m_threadList.indexedFolder())
548
1/1
✓ Branch 34 → 35 taken 2 times.
2 removedIndexedUids.insert(header->uid);
549 }
550
1/1
✓ Branch 39 → 40 taken 2 times.
2 m_threadList.removeIndexedUids(m_threadList.indexedFolder(), removedIndexedUids);
551 // The removal freed the selected slot: converge selection once per
552 // batched mutation, after all ranges settled (same contract as the flat
553 // follow-up below). The reserved successor wins over any row-0 fallback.
554 // (Multi-range successor mapping stays open: the asynchronous bulk
555 // selection move resolves through MailSelectionService, whose chunked
556 // removal sequencing deserves its own continuity contract.)
557
3/6
✓ Branch 40 → 41 taken 2 times.
✗ Branch 40 → 50 not taken.
✓ Branch 42 → 43 taken 2 times.
✗ Branch 42 → 50 not taken.
✓ Branch 48 → 49 taken 2 times.
✗ Branch 48 → 50 not taken.
4 if (removedSelection && m_workspace.activeTab() == 0 &&
558
4/8
✓ Branch 51 → 52 taken 2 times.
✗ Branch 51 → 53 not taken.
✓ Branch 53 → 54 taken 2 times.
✗ Branch 53 → 55 not taken.
✓ Branch 55 → 56 taken 2 times.
✗ Branch 55 → 57 not taken.
✓ Branch 57 → 58 taken 2 times.
✗ Branch 57 → 60 not taken.
4 m_workspace.area() == QStringLiteral("mail"))
559
1/1
✓ Branch 58 → 59 taken 2 times.
2 QTimer::singleShot(0, this, &QuickMailBackend::reconcileSelection);
560 2 });
561 // Removal follow-up on the PROXY signal: the proxy's own
562 // rowsAboutToBeRemoved fires while its mapping is still intact, so the
563 // selected mail's visual row is readable. The source-level signal runs
564 // after QSortFilterProxy already tore the mapping down (rowForIdentity
565 // would answer -1 there, and the old clamped lookup landed on row zero).
566 795 connect(&m_mail, &QAbstractItemModel::rowsAboutToBeRemoved, this,
567
1/1
✓ Branch 5 → 6 taken 795 times.
795 [this](const QModelIndex &parent, int first, int last) {
568 // A filtered page hides rows; a hidden selection stays (reconcileSelection).
569
4/4
✓ Branch 6 → 7 taken 143 times.
✓ Branch 6 → 15 taken 5 times.
✓ Branch 9 → 10 taken 135 times.
✓ Branch 9 → 15 taken 8 times.
291 if (parent.isValid() || m_session.headers()->isEvictingRetainedRows() ||
570
2/2
✓ Branch 11 → 12 taken 50 times.
✓ Branch 11 → 15 taken 85 times.
278 m_session.headers()->isHidingFilteredRows() ||
571
6/8
✓ Branch 3 → 4 taken 148 times.
✗ Branch 3 → 15 not taken.
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 50 times.
✓ Branch 17 → 18 taken 50 times.
✓ Branch 17 → 19 taken 98 times.
✓ Branch 19 → 20 taken 98 times.
✓ Branch 19 → 21 taken 50 times.
437 m_workspace.activeTab() != 0 || m_workspace.area() != "mail") return;
572 // Threaded mode: the visible projection spans the complete folder and is
573 // rebuilt by the controller's thread-model reset, so reconcileSelection
574 // owns the selection follow-up after real removals.
575 50 const bool threadedActive = threadedPresentation();
576
2/2
✓ Branch 22 → 23 taken 4 times.
✓ Branch 22 → 24 taken 46 times.
50 if (threadedActive) return;
577 46 const auto selected = m_workspace.mailId();
578 // Membership check on the visible model, not acceptsIdentity(): an
579 // optimistic move already relocated the cached header when the model row
580 // is removed, so identity admission would wrongly skip the follow-up.
581
3/3
✓ Branch 27 → 28 taken 46 times.
✓ Branch 28 → 29 taken 1 time.
✓ Branch 28 → 30 taken 45 times.
46 if (m_session.headers()->rowForUid(selected.uid, selected.folderId) < 0) return;
582 // The removal handler on the SOURCE model fires after QSortFilterProxy
583 // already tore down its mapping (rowForIdentity would answer -1 and the
584 // old clamped lookup landed on row zero). This handler therefore runs on
585 // the proxy's own rowsAboutToBeRemoved, where the selected mail's visual
586 // row is still readable and only visible rows are reported at all.
587
2/2
✓ Branch 30 → 31 taken 45 times.
✓ Branch 31 → 32 taken 45 times.
45 const int visualRow = m_mail.rowForIdentity(WorkspaceState::encode(selected));
588
3/4
✓ Branch 33 → 34 taken 44 times.
✓ Branch 33 → 35 taken 1 time.
✗ Branch 34 → 35 not taken.
✓ Branch 34 → 36 taken 44 times.
45 if (visualRow < first || visualRow > last) return;
589 // TEST-LOAD-TIMING: the visible neighbour, remembered by key. A page that
590 // arrives before the follow-up runs can slide the retained window, shift
591 // every row and evict the neighbour itself; an evicted (not removed)
592 // neighbour still exists and stays the target.
593
3/3
✓ Branch 37 → 38 taken 44 times.
✓ Branch 38 → 39 taken 42 times.
✓ Branch 38 → 40 taken 2 times.
44 const int neighbourRow = last + 1 < m_mail.rowCount() ? last + 1 : first - 1;
594
3/3
✓ Branch 41 → 42 taken 43 times.
✓ Branch 41 → 43 taken 1 time.
✓ Branch 42 → 44 taken 43 times.
44 const MailSelectionKey neighbour = neighbourRow >= 0 ? m_mail.keyAt(neighbourRow) : MailSelectionKey{};
595
1/1
✓ Branch 44 → 45 taken 44 times.
44 auto neighbourEvicted = std::make_shared<bool>(false);
596 44 const auto slid = connect(m_session.headers(), &MailListModel::windowSlid, this,
597
0/2
✗ Branch 76 → 77 not taken.
✗ Branch 76 → 78 not taken.
88 [this, neighbour, neighbourEvicted](int slidFirst, int slidLast) {
598 ✗ for (int row = slidFirst; row <= slidLast; ++row)
599 ✗ if (const auto *header = m_session.headers()->headerAt(row);
600 ✗ header && header->uid == neighbour.uid && header->folderId == neighbour.folderId)
601 ✗ *neighbourEvicted = true;
602
1/1
✓ Branch 48 → 49 taken 44 times.
44 });
603
1/1
✓ Branch 50 → 51 taken 44 times.
44 const auto generation = m_workspace.generation();
604 // A multi-message mutation may remove several ranges synchronously. Wait
605 // for the final surviving visual order; never select/read intermediate rows.
606
2/10
✓ Branch 54 → 55 taken 44 times.
✓ Branch 56 → 57 taken 44 times.
✗ Branch 82 → 83 not taken.
✗ Branch 82 → 84 not taken.
✗ Branch 85 → 86 not taken.
✗ Branch 85 → 87 not taken.
✗ Branch 88 → 89 not taken.
✗ Branch 88 → 90 not taken.
✗ Branch 91 → 92 not taken.
✗ Branch 91 → 93 not taken.
44 QTimer::singleShot(0, this, [this, selected, visualRow, neighbour, neighbourEvicted, slid, generation] {
607 43 disconnect(slid);
608
4/8
✓ Branch 8 → 9 taken 39 times.
✗ Branch 8 → 17 not taken.
✓ Branch 10 → 11 taken 39 times.
✗ Branch 10 → 17 not taken.
✓ Branch 23 → 24 taken 4 times.
✓ Branch 23 → 25 taken 39 times.
✗ Branch 62 → 63 not taken.
✗ Branch 62 → 64 not taken.
121 if (m_workspace.generation() != generation || m_workspace.mailId() != selected ||
609
7/11
✓ Branch 3 → 4 taken 43 times.
✓ Branch 5 → 6 taken 39 times.
✓ Branch 5 → 17 taken 4 times.
✓ Branch 13 → 14 taken 39 times.
✗ Branch 13 → 17 not taken.
✓ Branch 19 → 20 taken 39 times.
✓ Branch 19 → 21 taken 4 times.
✓ Branch 21 → 22 taken 43 times.
✗ Branch 21 → 23 not taken.
✗ Branch 57 → 58 not taken.
✗ Branch 57 → 59 not taken.
160 m_workspace.activeTab() != 0 || m_workspace.area() != "mail" ||
610
2/3
✓ Branch 15 → 16 taken 39 times.
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 18 taken 39 times.
43 m_session.headers()->rowForUid(selected.uid, selected.folderId) >= 0) return;
611 // The surviving neighbour takes over the removed visual row: the
612 // selection advances to it, never back to the top of the folder.
613
1/2
✓ Branch 25 → 26 taken 39 times.
✗ Branch 25 → 30 not taken.
39 const int neighbourNow = neighbour.uid < 0 ? -1
614
4/14
✓ Branch 27 → 28 taken 39 times.
✓ Branch 28 → 29 taken 39 times.
✓ Branch 31 → 32 taken 39 times.
✗ Branch 31 → 33 not taken.
✓ Branch 33 → 34 taken 39 times.
✗ Branch 33 → 35 not taken.
✗ Branch 66 → 67 not taken.
✗ Branch 66 → 68 not taken.
✗ Branch 70 → 71 not taken.
✗ Branch 70 → 72 not taken.
✗ Branch 74 → 75 not taken.
✗ Branch 74 → 77 not taken.
✗ Branch 75 → 76 not taken.
✗ Branch 75 → 77 not taken.
39 : m_mail.rowForIdentity(WorkspaceState::encode(MailId{accountName(), neighbour.folderId, neighbour.uid}));
615
2/2
✓ Branch 35 → 36 taken 38 times.
✓ Branch 35 → 37 taken 1 time.
39 if (neighbourNow >= 0) selectMailRow(neighbourNow);
616
1/3
✗ Branch 38 → 39 not taken.
✓ Branch 38 → 44 taken 1 time.
✗ Branch 41 → 42 not taken.
1 else if (*neighbourEvicted) m_selection.select(neighbour);
617
1/4
✗ Branch 45 → 46 not taken.
✓ Branch 45 → 50 taken 1 time.
✗ Branch 46 → 47 not taken.
✗ Branch 48 → 49 not taken.
1 else if (activeRowCount()) selectMailRow(qMin(visualRow, activeRowCount() - 1));
618 else {
619 1 m_selection.clear();
620 1 m_mainCurrent = {};
621
1/1
✓ Branch 53 → 54 taken 1 time.
1 m_workspace.select({});
622 }
623 });
624
2/2
✓ Branch 64 → 65 taken 44 times.
✓ Branch 64 → 67 taken 2 times.
46 });
625
1/1
✓ Branch 7 → 8 taken 795 times.
795 connect(&m_threadList, &QAbstractItemModel::modelAboutToBeReset, this, [this] {
626
3/4
✓ Branch 4 → 5 taken 41 times.
✓ Branch 4 → 10 taken 22 times.
✓ Branch 6 → 7 taken 41 times.
✗ Branch 6 → 10 not taken.
104 if (!threadedPresentation() || m_threadProjectionPending ||
627
8/8
✓ Branch 3 → 4 taken 63 times.
✓ Branch 3 → 10 taken 2515 times.
✓ Branch 9 → 10 taken 3 times.
✓ Branch 9 → 11 taken 38 times.
✓ Branch 12 → 13 taken 41 times.
✓ Branch 12 → 14 taken 2537 times.
✓ Branch 14 → 15 taken 2540 times.
✓ Branch 14 → 16 taken 38 times.
2718 m_workspace.activeTab() != 0 || m_workspace.area() != "mail") return;
628 38 const auto selected = m_workspace.mailId();
629
2/2
✓ Branch 18 → 19 taken 38 times.
✓ Branch 19 → 20 taken 38 times.
38 const int row = m_threadList.rowForIdentity(WorkspaceState::encode(selected));
630
6/6
✓ Branch 22 → 23 taken 28 times.
✓ Branch 22 → 24 taken 10 times.
✓ Branch 23 → 24 taken 26 times.
✓ Branch 23 → 25 taken 2 times.
✓ Branch 26 → 27 taken 36 times.
✓ Branch 26 → 28 taken 2 times.
38 if (!selected.isValid() || row < 0) return;
631
1/1
✓ Branch 28 → 29 taken 2 times.
2 const auto generation = m_workspace.generation();
632
1/3
✓ Branch 31 → 32 taken 2 times.
✗ Branch 46 → 47 not taken.
✗ Branch 46 → 48 not taken.
2 QTimer::singleShot(0, this, [this, selected, row, generation] {
633
1/2
✓ Branch 4 → 5 taken 2 times.
✗ Branch 4 → 16 not taken.
2 if (!threadedPresentation() || m_threadProjectionPending ||
634
5/11
✓ Branch 5 → 6 taken 2 times.
✓ Branch 7 → 8 taken 2 times.
✗ Branch 7 → 16 not taken.
✓ Branch 10 → 11 taken 2 times.
✗ Branch 10 → 16 not taken.
✓ Branch 12 → 13 taken 2 times.
✗ Branch 12 → 16 not taken.
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 24 taken 2 times.
✗ Branch 54 → 55 not taken.
✗ Branch 54 → 56 not taken.
6 m_workspace.generation() != generation || m_workspace.mailId() != selected ||
635
4/10
✓ Branch 3 → 4 taken 2 times.
✗ Branch 3 → 16 not taken.
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 2 times.
✓ Branch 18 → 19 taken 2 times.
✗ Branch 18 → 20 not taken.
✓ Branch 20 → 21 taken 2 times.
✗ Branch 20 → 22 not taken.
✗ Branch 49 → 50 not taken.
✗ Branch 49 → 51 not taken.
6 m_workspace.activeTab() != 0 || m_workspace.area() != "mail") return;
636 // Filtering/collapsing only hides a row. A real removal also removes
637 // its canonical identity from the owning tree. Coalesce bulk resets
638 // before selecting the final surviving neighbour.
639
1/3
✗ Branch 25 → 26 not taken.
✓ Branch 25 → 28 taken 2 times.
✗ Branch 26 → 27 not taken.
2 if (m_threadList.indexed() ? acceptsIdentity(selected)
640
2/3
✓ Branch 29 → 30 taken 2 times.
✗ Branch 32 → 33 not taken.
✓ Branch 32 → 34 taken 2 times.
2 : m_session.threads()->indexForUid(selected.uid, selected.folderId).isValid()) return;
641
4/5
✓ Branch 35 → 36 taken 2 times.
✓ Branch 36 → 37 taken 2 times.
✗ Branch 36 → 42 not taken.
✓ Branch 38 → 39 taken 2 times.
✓ Branch 40 → 41 taken 2 times.
2 if (m_threadList.rowCount()) selectMailRow(qMin(row, m_threadList.rowCount() - 1));
642 else {
643 ✗ m_selection.clear();
644 ✗ m_mainCurrent = {};
645 ✗ m_workspace.select({});
646 }
647 });
648
2/2
✓ Branch 36 → 37 taken 2 times.
✓ Branch 36 → 39 taken 36 times.
38 });
649 795 }
650
651 // Prediction resets on folder changes and row selection providers.
652 795 void QuickMailBackend::wireFolderChangeResets() {
653 795 MailSessionCore &session = m_session;
654
1/1
✓ Branch 2 → 3 taken 795 times.
795 connect(this, &QuickMailBackend::folderChanged, this, [this] {
655 // F95-15: the suggestion column (Ctrl+S) is a persistent view setting;
656 // it survives folder switches and restarts.
657 925 m_alternatePredictions.clear();
658 925 disarmQuickMove();
659 925 invalidatePredictions();
660 925 });
661
1/1
✓ Branch 5 → 6 taken 795 times.
795 connect(session.controller(), &MailController::folderIdentityInvalidated, this, [this] {
662 11 m_alternatePredictions.clear();
663 11 invalidatePredictions();
664 11 });
665 39790 m_mail.setSelectionProvider([this](qint64, qint64 uid) { return m_selection.contains(uid); });
666 7570 m_threadList.setSelectionProvider([this](qint64, qint64 uid) { return m_selection.contains(uid); });
667 795 }
668
669 // Search coordinator, cached-page presentation and FTS readiness.
670 795 void QuickMailBackend::wireSearch(MailSearchReadService *searchReader) {
671 795 MailSessionCore &session = m_session;
672 // The command bar's previews (the full search runs in search tabs).
673 795 SearchCoordinator::Deps searchDeps;
674 795 searchDeps.cache = session.cache();
675 865 searchDeps.localIndexReady = [&session] { return session.fts()->isSearchReady(); };
676 865 searchDeps.localIndexGeneration = [&session] { return session.fts()->completionGeneration(); };
677 865 searchDeps.accountScope = [this] { return accountName(); };
678
3/3
✓ Branch 7 → 8 taken 793 times.
✓ Branch 7 → 10 taken 2 times.
✓ Branch 8 → 9 taken 793 times.
795 searchDeps.readService = searchReader ? searchReader : MailSearchReadService::shared();
679 1734 searchDeps.acceptCachedResult = [this](const MailId &id, const QString &path) {
680
5/10
✓ Branch 3 → 4 taken 144 times.
✗ Branch 3 → 12 not taken.
✓ Branch 5 → 6 taken 144 times.
✗ Branch 5 → 12 not taken.
✓ Branch 8 → 9 taken 144 times.
✗ Branch 8 → 12 not taken.
✓ Branch 10 → 11 taken 144 times.
✗ Branch 10 → 12 not taken.
✓ Branch 14 → 15 taken 144 times.
✗ Branch 14 → 16 not taken.
144 return configured() && id.isValid() && id.accountId == accountName() && m_availableFolders.contains(path);
681 795 };
682
1/1
✓ Branch 12 → 13 taken 795 times.
795 m_search = std::make_unique<SearchCoordinator>(searchDeps);
683 // T-87.D1: the cached first page now arrives asynchronously from the
684 // foreground reader; reconcile the workspace selection once it committed.
685
1/1
✓ Branch 16 → 17 taken 795 times.
795 connect(session.controller(), &MailController::cachedFolderPresented, this,
686 795 [this](qint64 folderId, int, quint64) {
687 // The controller may have just ensured a folder that was absent
688 // from the account's startup identity snapshot. Retain that
689 // account-scoped identity for subsequent Quick interactions.
690 695 const QString path = m_session.controller()->currentFolder();
691
3/6
✓ Branch 4 → 5 taken 695 times.
✗ Branch 4 → 13 not taken.
✓ Branch 6 → 7 taken 695 times.
✗ Branch 6 → 13 not taken.
✓ Branch 11 → 12 taken 695 times.
✗ Branch 11 → 13 not taken.
1390 if (folderId > 0 && !path.isEmpty() &&
692
3/6
✓ Branch 14 → 15 taken 695 times.
✗ Branch 14 → 16 not taken.
✓ Branch 16 → 17 taken 695 times.
✗ Branch 16 → 18 not taken.
✓ Branch 18 → 19 taken 695 times.
✗ Branch 18 → 41 not taken.
1390 m_session.controller()->accountId() == accountName()) {
693 695 const auto previousPath = m_folderPathById.value(folderId);
694
4/6
✓ Branch 21 → 22 taken 694 times.
✓ Branch 21 → 25 taken 1 time.
✗ Branch 23 → 24 not taken.
✓ Branch 23 → 25 taken 694 times.
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 28 taken 695 times.
695 if (!previousPath.isEmpty() && previousPath != path)
695 ✗ m_folderIdByPath.remove(previousPath);
696 695 const auto previousId = m_folderIdByPath.value(path, -1);
697
3/4
✓ Branch 29 → 30 taken 694 times.
✓ Branch 29 → 33 taken 1 time.
✗ Branch 30 → 31 not taken.
✓ Branch 30 → 33 taken 694 times.
695 if (previousId > 0 && previousId != folderId) {
698 ✗ m_folderPathById.remove(previousId);
699 ✗ m_folderEpochById.remove(previousId);
700 }
701
1/1
✓ Branch 33 → 34 taken 695 times.
695 m_folderIdByPath.insert(path, folderId);
702
1/1
✓ Branch 34 → 35 taken 695 times.
695 m_folderPathById.insert(folderId, path);
703
2/2
✓ Branch 36 → 37 taken 1 time.
✓ Branch 36 → 39 taken 694 times.
695 if (!m_folderEpochById.contains(folderId))
704
1/1
✓ Branch 37 → 38 taken 1 time.
1 m_folderEpochById.insert(folderId, 0);
705 695 }
706 695 m_waitingCachedPage = false;
707
6/6
✓ Branch 41 → 42 taken 695 times.
✓ Branch 45 → 46 taken 695 times.
✓ Branch 46 → 47 taken 695 times.
✓ Branch 47 → 48 taken 695 times.
✓ Branch 51 → 43 taken 695 times.
✓ Branch 51 → 52 taken 695 times.
1390 qCDebug(lcQuickBackend) << "cached page presented for folder" << folderId;
708
1/1
✓ Branch 52 → 53 taken 695 times.
695 syncThreadProjection();
709 // The cached page is the flat thread list's complete content; an
710 // empty folder ends the previous folder's hold here.
711
3/3
✓ Branch 54 → 55 taken 686 times.
✓ Branch 54 → 56 taken 9 times.
✓ Branch 55 → 56 taken 686 times.
695 if (!m_threadList.indexed()) m_threadList.releaseHeldRows();
712
3/3
✓ Branch 56 → 57 taken 692 times.
✓ Branch 56 → 58 taken 3 times.
✓ Branch 57 → 58 taken 692 times.
695 if (!m_reconciling) reconcileSelection();
713 695 });
714
1/1
✓ Branch 19 → 20 taken 795 times.
795 connect(m_search.get(), &SearchCoordinator::statusMessage, this, &QuickMailBackend::setStatus);
715
1/1
✓ Branch 22 → 23 taken 795 times.
795 connect(session.fts(), &FtsIndexCoordinator::rebuildFinished, this, [this](bool success, quint64) {
716 ✗ if (success && configured() && !m_searchPreviewQuery.isEmpty()) m_searchDebounce.start();
717 ✗ });
718 795 }
719
720 // Sprint 101: the search tabs — one MailSearchSession per search tab.
721 795 void QuickMailBackend::wireSearchTabs(MailSearchReadService *searchReader) {
722 795 MailSessionCore &session = m_session;
723 795 MailSearchSessions::Deps deps;
724 795 deps.workspace = &m_workspace;
725 795 deps.controller = session.controller();
726
1/2
✓ Branch 3 → 4 taken 93 times.
✗ Branch 3 → 5 not taken.
888 deps.currentAccount = [this] { return configured() ? accountName() : QString(); };
727 894 deps.acceptIdentity = [this](const MailId &id) { return acceptsIdentity(id); };
728 ✗ deps.sessionDeps = [this, &session, searchReader](const QString &account) {
729 89 MailSearchSession::Deps d;
730 89 d.cache = session.cache();
731
3/3
✓ Branch 4 → 5 taken 85 times.
✓ Branch 4 → 7 taken 4 times.
✓ Branch 5 → 6 taken 85 times.
89 d.reader = searchReader ? searchReader : MailSearchReadService::shared();
732 89 d.server = session.controller()->search();
733 365 d.indexReady = [&session] { return session.fts()->isSearchReady(); };
734 479 d.indexGeneration = [&session] { return session.fts()->completionGeneration(); };
735 178 d.acceptResult = [this, account](const MailId &id, const QString &path) {
736
5/10
✓ Branch 3 → 4 taken 14751 times.
✗ Branch 3 → 14 not taken.
✓ Branch 5 → 6 taken 14751 times.
✗ Branch 5 → 14 not taken.
✓ Branch 7 → 8 taken 14751 times.
✗ Branch 7 → 14 not taken.
✓ Branch 10 → 11 taken 14751 times.
✗ Branch 10 → 14 not taken.
✓ Branch 12 → 13 taken 14751 times.
✗ Branch 12 → 14 not taken.
44253 return configured() && id.isValid() && id.accountId == account && account == accountName() &&
737
1/2
✓ Branch 15 → 16 taken 14751 times.
✗ Branch 15 → 17 not taken.
29502 m_availableFolders.contains(path);
738
1/1
✓ Branch 13 → 14 taken 89 times.
89 };
739 244 d.allowedFolders = [this] { return m_availableFolders; };
740 694 d.folderTree = [this] {
741 516 QList<SearchMenuModel::FolderRow> rows;
742
5/5
✓ Branch 2 → 3 taken 516 times.
✓ Branch 3 → 4 taken 516 times.
✓ Branch 4 → 5 taken 516 times.
✓ Branch 13 → 6 taken 1558 times.
✓ Branch 13 → 14 taken 516 times.
2074 for (const auto &row : m_folders.treeRows()) rows.append({row.path, row.name, row.depth});
743 516 return rows;
744
1/5
✓ Branch 9 → 10 taken 1558 times.
✗ Branch 19 → 20 not taken.
✗ Branch 19 → 21 not taken.
✗ Branch 22 → 23 not taken.
✗ Branch 22 → 24 not taken.
1647 };
745 230 d.serverEnabled = [&session] { return session.accounts()->networkEnabled(); };
746 2440 d.lookups = [this] {
747 2262 SearchTermModel::Lookups lookups;
748 2633 lookups.label = [](const QString &keyword) { return LabelDelegate::displayName(keyword); };
749 2262 return lookups;
750 89 };
751 89 return d;
752
1/1
✓ Branch 6 → 7 taken 795 times.
795 };
753
1/1
✓ Branch 7 → 8 taken 795 times.
795 m_searchTabs = std::make_unique<MailSearchSessions>(deps);
754
1/1
✓ Branch 11 → 12 taken 795 times.
795 m_workspace.setActiveAccount(accountName());
755
1/1
✓ Branch 14 → 15 taken 795 times.
795 connect(session.fts(), &FtsIndexCoordinator::rebuildFinished, this, [this](bool success, quint64) {
756 ✗ if (success && configured()) m_searchTabs->refreshAfterIndexReady();
757 ✗ });
758 795 }
759
760 // Search preview debounce and quick result suggestions.
761 795 void QuickMailBackend::wireSearchSuggestions() {
762 795 m_searchDebounce.setSingleShot(true);
763 795 m_searchDebounce.setInterval(20);
764
1/1
✓ Branch 4 → 5 taken 795 times.
795 connect(&m_searchDebounce, &QTimer::timeout, this, [this] {
765
3/6
✓ Branch 3 → 4 taken 37 times.
✗ Branch 3 → 7 not taken.
✓ Branch 5 → 6 taken 37 times.
✗ Branch 5 → 7 not taken.
✓ Branch 8 → 9 taken 37 times.
✗ Branch 8 → 11 not taken.
37 if (configured() && !m_availableFolders.isEmpty()) m_search->updateQuickResults(m_searchPreviewQuery);
766 37 });
767
1/1
✓ Branch 7 → 8 taken 795 times.
795 connect(m_search.get(), &SearchCoordinator::quickResultsChanged, this, [this] {
768 2105 m_searchSuggestions.clear();
769
2/2
✓ Branch 55 → 4 taken 144 times.
✓ Branch 55 → 56 taken 2105 times.
2249 for (int i = 0; i < m_search->quickResultCount(); ++i) {
770
1/1
✓ Branch 5 → 6 taken 144 times.
144 const auto result = m_search->quickResultAt(i);
771 144 const MailId id{accountName(), result.folderId, result.uid};
772
3/6
✓ Branch 8 → 9 taken 144 times.
✗ Branch 8 → 11 not taken.
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 144 times.
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 144 times.
144 if (!id.isValid() || !m_availableFolders.contains(result.folderPath)) continue;
773 // S98.1.11: structured fields for the command bar's result rows; the
774 // preformatted label stays for the legacy list until Phase 4.
775
4/4
✓ Branch 16 → 17 taken 144 times.
✓ Branch 17 → 18 taken 144 times.
✓ Branch 18 → 19 taken 144 times.
✓ Branch 29 → 30 taken 144 times.
432 m_searchSuggestions.append(QVariantMap{{"label", QString("[%1] %2 — %3").arg(result.folderPath, result.subject.left(120), result.from.left(80))},
776 144 {"identity", WorkspaceState::encode(id)}, {"subject", result.subject.left(240)},
777
2/4
✓ Branch 33 → 34 taken 720 times.
✓ Branch 33 → 35 taken 144 times.
✗ Branch 63 → 64 not taken.
✗ Branch 63 → 65 not taken.
864 {"from", result.from.left(160)}, {"folderPath", result.folderPath}});
778 144 }
779 2105 emit searchSuggestionsChanged();
780
10/14
✓ Branch 15 → 16 taken 144 times.
✓ Branch 19 → 20 taken 144 times.
✓ Branch 20 → 21 taken 144 times.
✓ Branch 21 → 22 taken 144 times.
✓ Branch 22 → 23 taken 144 times.
✓ Branch 23 → 24 taken 144 times.
✓ Branch 24 → 25 taken 144 times.
✓ Branch 25 → 26 taken 144 times.
✓ Branch 26 → 27 taken 144 times.
✓ Branch 27 → 28 taken 144 times.
✗ Branch 87 → 88 not taken.
✗ Branch 87 → 91 not taken.
✗ Branch 89 → 90 not taken.
✗ Branch 89 → 91 not taken.
2393 });
781 795 }
782
783 // Mail selection model, folder expansion and workspace selection reconcile.
784 795 void QuickMailBackend::wireSelection() {
785 795 WorkspaceState &workspace = m_workspace;
786
1/1
✓ Branch 3 → 4 taken 795 times.
795 connect(&m_selection, &QuickMailSelection::changed, this,
787 1590 [this, previousSimple = true, previous = MailSelectionKey{}]() mutable {
788
6/6
✓ Branch 3 → 4 taken 2786 times.
✓ Branch 3 → 8 taken 37 times.
✓ Branch 6 → 7 taken 2717 times.
✓ Branch 6 → 8 taken 69 times.
✓ Branch 9 → 10 taken 2786 times.
✓ Branch 9 → 11 taken 37 times.
2823 const bool simple = !m_selection.pending() && !m_selection.snapshot();
789
2/2
✓ Branch 11 → 12 taken 2717 times.
✓ Branch 11 → 13 taken 106 times.
2823 const MailSelectionKey current = simple ? m_selection.single() : MailSelectionKey{};
790
4/4
✓ Branch 14 → 15 taken 2731 times.
✓ Branch 14 → 27 taken 92 times.
✓ Branch 15 → 16 taken 2703 times.
✓ Branch 15 → 27 taken 28 times.
2823 if (previousSimple && simple) {
791 // A plain single click changes only the previous and current rows.
792 // Snapshot/range selection still compares the bounded visible window.
793
1/1
✓ Branch 18 → 19 taken 2703 times.
2703 m_mail.singleSelectionChanged(previous, current);
794
1/1
✓ Branch 23 → 24 taken 2703 times.
2703 m_threadList.singleSelectionChanged(previous, current);
795 } else {
796
1/1
✓ Branch 27 → 28 taken 120 times.
120 m_mail.selectionChanged();
797
1/1
✓ Branch 28 → 29 taken 120 times.
120 m_threadList.selectionChanged();
798 }
799 2823 previousSimple = simple;
800 2823 previous = current;
801
1/1
✓ Branch 30 → 31 taken 2823 times.
2823 emit selectionChanged();
802 2823 });
803 // F100-77: a search tab's marked rows show their count only.
804
1/1
✓ Branch 6 → 7 taken 795 times.
795 connect(this, &QuickMailBackend::selectionChanged, this, [this] {
805
3/3
✓ Branch 4 → 5 taken 26 times.
✓ Branch 4 → 6 taken 2815 times.
✓ Branch 7 → 8 taken 2841 times.
2841 m_selectionSummary.update(m_workspace.activeIsSearch() ? nullptr : m_selection.snapshot(), selectionCount());
806 2841 });
807
1/1
✓ Branch 8 → 9 taken 795 times.
795 connect(&workspace, &WorkspaceState::folderExpansionChanged, this, &QuickMailBackend::folderExpansionChanged);
808
1/1
✓ Branch 10 → 11 taken 795 times.
795 connect(this, &QuickMailBackend::accountChanged, this, &QuickMailBackend::folderExpansionChanged);
809
1/1
✓ Branch 12 → 13 taken 795 times.
795 connect(&m_selection, &QuickMailSelection::failed, this, [this] {
810 ✗ setStatus(tr("Selection failed. Select again."));
811 ✗ });
812
1/1
✓ Branch 14 → 15 taken 795 times.
795 connect(&m_selection, &QuickMailSelection::currentRequested, this, [this](MailSelectionKey key) {
813
4/8
✓ Branch 3 → 4 taken 893 times.
✗ Branch 3 → 7 not taken.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 893 times.
✓ Branch 9 → 10 taken 893 times.
✗ Branch 9 → 11 not taken.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 893 times.
893 if (m_workspace.activeTab() != 0 || m_workspace.area() != "mail") return;
814 893 QScopedValueRollback guard(m_applyingSelection, true);
815 893 m_mainCurrent = {accountName(), key.folderId, key.uid};
816
1/1
✓ Branch 17 → 18 taken 893 times.
893 m_workspace.select(m_mainCurrent);
817 1786 });
818
1/1
✓ Branch 16 → 17 taken 795 times.
795 connect(this, &QuickMailBackend::folderChanged, this, &QuickMailBackend::refreshSelectionScope);
819
1/1
✓ Branch 18 → 19 taken 795 times.
795 connect(&m_mail, &MailPresentationModel::mailFilterChanged, this, &QuickMailBackend::refreshSelectionScope);
820
1/1
✓ Branch 20 → 21 taken 795 times.
795 connect(&m_threadList, &QuickThreadListModel::mailFilterChanged, this, &QuickMailBackend::refreshSelectionScope);
821
1/1
✓ Branch 22 → 23 taken 795 times.
795 connect(&workspace, &WorkspaceState::activeTabChanged, &m_selection, &QuickMailSelection::cancelPending);
822
1/1
✓ Branch 24 → 25 taken 795 times.
795 connect(&workspace, &WorkspaceState::areaChanged, &m_selection, &QuickMailSelection::cancelPending);
823
1/1
✓ Branch 26 → 27 taken 795 times.
795 connect(&workspace, &WorkspaceState::selectionChanged, this, [this] {
824
8/8
✓ Branch 2 → 3 taken 1433 times.
✓ Branch 2 → 8 taken 136 times.
✓ Branch 4 → 5 taken 894 times.
✓ Branch 4 → 8 taken 539 times.
✓ Branch 7 → 8 taken 94 times.
✓ Branch 7 → 9 taken 800 times.
✓ Branch 10 → 11 taken 769 times.
✓ Branch 10 → 12 taken 800 times.
1569 if (m_applyingSelection || m_workspace.activeTab() != 0 || m_workspace.mailId() == m_mainCurrent) return;
825
2/2
✓ Branch 12 → 13 taken 800 times.
✓ Branch 13 → 14 taken 800 times.
800 const int row = activeRowForIdentity(m_workspace.selection());
826
4/4
✓ Branch 15 → 16 taken 721 times.
✓ Branch 15 → 21 taken 79 times.
✓ Branch 17 → 18 taken 721 times.
✓ Branch 18 → 19 taken 721 times.
800 if (row >= 0) m_selection.select(activeKeyAt(row));
827 });
828 795 }
829
830 // Account list changes and the per-account state reset.
831 795 void QuickMailBackend::wireAccountReload() {
832 795 MailSessionCore &session = m_session;
833
1/1
✓ Branch 3 → 4 taken 795 times.
795 connect(session.accounts(), &AccountSession::availableAccountsChanged, this, &QuickMailBackend::accountChanged);
834
1/1
✓ Branch 6 → 7 taken 795 times.
795 connect(session.accounts(), &AccountSession::configurationChanged, &m_rules,
835 795 &QuickRulesBackend::configurationChanged);
836 // Sprint 95: the folder menu's rule entries follow rule availability (the
837 // "Rules" button sits with the sidebar actions, F100-67).
838 892 const auto syncRulesNode = [this] {
839 892 m_folderActions.setRulesMenuEnabled(m_rules.available());
840 1687 };
841
1/1
✓ Branch 8 → 9 taken 795 times.
795 connect(&m_rules, &QuickRulesBackend::stateChanged, this, syncRulesNode);
842
1/1
✓ Branch 10 → 11 taken 795 times.
795 connect(&m_rules, &QuickRulesBackend::documentChanged, this, syncRulesNode);
843 // F98-01 (revises F95-02): the rule manager is a document tab of the mail
844 // area, shown as the "rules" area. Opening it (folder-tree node, Ctrl+Shift+L,
845 // :rules, a draft) shows it; switching to a mail tab or another area keeps
846 // it open like a mail tab; closing it returns to mail.
847
1/1
✓ Branch 12 → 13 taken 795 times.
795 connect(&m_rules, &QuickRulesBackend::managerOpenChanged, this, [this] {
848
8/8
✓ Branch 3 → 4 taken 26 times.
✓ Branch 3 → 9 taken 15 times.
✓ Branch 7 → 8 taken 12 times.
✓ Branch 7 → 9 taken 14 times.
✓ Branch 10 → 11 taken 26 times.
✓ Branch 10 → 12 taken 15 times.
✓ Branch 12 → 13 taken 12 times.
✓ Branch 12 → 20 taken 29 times.
41 if (m_rules.managerOpen() && m_workspace.area() != QLatin1String("rules"))
849
1/1
✓ Branch 16 → 17 taken 12 times.
24 m_workspace.setArea(QStringLiteral("rules"));
850
7/8
✓ Branch 21 → 22 taken 15 times.
✓ Branch 21 → 27 taken 14 times.
✓ Branch 25 → 26 taken 15 times.
✗ Branch 25 → 27 not taken.
✓ Branch 28 → 29 taken 15 times.
✓ Branch 28 → 30 taken 14 times.
✓ Branch 30 → 31 taken 15 times.
✓ Branch 30 → 38 taken 14 times.
29 else if (!m_rules.managerOpen() && m_workspace.area() == QLatin1String("rules"))
851
1/1
✓ Branch 34 → 35 taken 15 times.
30 m_workspace.setArea(QStringLiteral("mail"));
852 41 });
853
1/1
✓ Branch 14 → 15 taken 795 times.
795 connect(&m_workspace, &WorkspaceState::areaChanged, this, [this] {
854
7/8
✓ Branch 5 → 6 taken 28 times.
✓ Branch 5 → 9 taken 627 times.
✓ Branch 7 → 8 taken 14 times.
✓ Branch 7 → 9 taken 14 times.
✓ Branch 10 → 11 taken 655 times.
✗ Branch 10 → 12 not taken.
✓ Branch 12 → 13 taken 14 times.
✓ Branch 12 → 17 taken 641 times.
655 if (m_workspace.area() == QLatin1String("rules") && !m_rules.managerOpen())
855
1/1
✓ Branch 14 → 15 taken 14 times.
14 m_rules.openManager();
856 655 });
857
1/1
✓ Branch 17 → 18 taken 795 times.
795 connect(session.accounts(), &AccountSession::accountsLoaded, this, [this] {
858 793 m_rules.accountChanged();
859 793 m_compose.resetForAccountReload();
860 793 m_threadSnapshots.clear();
861 793 ++m_threadDataGeneration;
862 793 m_headerProjectionTimer.stop();
863 793 m_headerProjectionFolder.clear();
864 793 cancelSearchPreview();
865 793 m_search->cancelQuickResults();
866 793 cancelNavigation();
867 793 m_selection.clear();
868 793 m_mainCurrent = {};
869 793 clearActionContinuity();
870
1/1
✓ Branch 16 → 17 taken 793 times.
793 m_mail.setAccountId(accountName());
871
1/1
✓ Branch 19 → 20 taken 793 times.
793 m_threadList.setAccountId(accountName());
872
1/1
✓ Branch 22 → 23 taken 793 times.
793 m_workspace.setActiveAccount(accountName()); // D-15: other accounts' search tabs sleep
873 793 m_folderPath.clear();
874 793 m_previousFolder.clear();
875 793 m_availableFolders.clear();
876 793 m_folderIdByPath.clear();
877 793 m_folderPathById.clear();
878 793 m_folderEpochById.clear();
879 793 m_waitingCachedPage = false;
880 793 cancelThreadProjection();
881 793 m_threadProjectionPending = false;
882 793 clearThreadProjectionSlowFlag();
883 793 m_session.threads()->clear();
884 793 emit threadedChanged();
885 793 emit accountChanged();
886 793 loadLocalFolders();
887 793 });
888 795 }
889
890 // Selection publishing and the P90.0 selection trace probe.
891 795 void QuickMailBackend::wireSelectionTrace() {
892 795 MailSessionCore &session = m_session;
893 795 WorkspaceState &workspace = m_workspace;
894
1/1
✓ Branch 2 → 3 taken 795 times.
795 connect(&workspace, &WorkspaceState::selectionChanged, this, &QuickMailBackend::publishSelection);
895 // P90.0 trace: one probe per selection until the confirmed Quick frame.
896 // The visible page phase settles as soon as the selected row resolves in
897 // the published model; catch-up and authoritative sync follow their own
898 // owners. Zero overhead unless a THREAD_SELECTION.* scenario is active.
899 795 auto &selectionTrace = MailJD::Performance::ThreadSelectionPerformanceTracker::instance();
900
1/1
✓ Branch 7 → 8 taken 795 times.
795 connect(&workspace, &WorkspaceState::selectionChanged, this,
901 1590 [this, &selectionTrace, lastSelection = m_workspace.mailId()]() mutable {
902 1569 const auto selected = m_workspace.mailId();
903
2/2
✓ Branch 5 → 6 taken 172 times.
✓ Branch 5 → 7 taken 1397 times.
1569 if (selected == lastSelection) return;
904 1397 lastSelection = selected;
905
2/3
✓ Branch 8 → 9 taken 1397 times.
✓ Branch 9 → 10 taken 1397 times.
✗ Branch 9 → 11 not taken.
1397 if (!selectionTrace.isActive()) return;
906 ✗ if (!selected.isValid()) return;
907 ✗ selectionTrace.beginSelection(selected); // origin from input classifier
908 ✗ if (m_threadList.indexed() &&
909 ✗ m_threadList.rowForIdentity(m_workspace.selection()) >= 0)
910 ✗ selectionTrace.visiblePagePublished(m_threadList.indexRevision(),
911 ✗ m_threadList.rowCount());
912
1/2
✗ Branch 34 → 35 not taken.
✓ Branch 34 → 37 taken 1569 times.
1569 });
913 795 connect(&m_threadList, &QuickThreadListModel::projectionChanged, this,
914
1/1
✓ Branch 10 → 11 taken 795 times.
795 [this, &selectionTrace] {
915
3/7
✓ Branch 2 → 3 taken 79 times.
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 6 taken 79 times.
✗ Branch 5 → 6 not taken.
✗ Branch 5 → 7 not taken.
✓ Branch 8 → 9 taken 79 times.
✗ Branch 8 → 10 not taken.
79 if (!selectionTrace.isActive() || !m_threadList.indexed()) return;
916 ✗ const auto selected = m_workspace.mailId();
917 ✗ if (selected.isValid() &&
918 ✗ m_threadList.rowForIdentity(m_workspace.selection()) >= 0)
919 ✗ selectionTrace.visiblePagePublished(m_threadList.indexRevision(),
920 ✗ m_threadList.rowCount());
921 ✗ });
922 795 connect(&m_threadList, &QuickThreadListModel::projectionReady, this,
923
1/1
✓ Branch 12 → 13 taken 795 times.
795 [this, &selectionTrace] {
924
2/6
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 7 taken 33 times.
✗ Branch 5 → 6 not taken.
✗ Branch 5 → 7 not taken.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 11 taken 33 times.
33 if (selectionTrace.isActive() && m_threadList.indexed())
925 ✗ selectionTrace.indexCatchupFinished(m_threadList.indexRevision());
926 33 });
927
1/1
✓ Branch 15 → 16 taken 795 times.
795 connect(session.controller(), &MailController::authoritativeFolderPresented,
928 795 this, [&selectionTrace](qint64 folderId, int, quint64) {
929 ✗ if (selectionTrace.isActive())
930 ✗ selectionTrace.authoritativeSyncFinished(folderId);
931 ✗ });
932 795 }
933
934 // Keyboard navigation cancellation and selection reconcile triggers.
935 795 void QuickMailBackend::wireNavigation() {
936 795 MailSessionCore &session = m_session;
937 795 WorkspaceState &workspace = m_workspace;
938
1/1
✓ Branch 2 → 3 taken 795 times.
795 connect(&workspace, &WorkspaceState::selectionChanged, this, [this] {
939
2/2
✓ Branch 2 → 3 taken 1561 times.
✓ Branch 2 → 4 taken 8 times.
1569 if (!m_applyingNavigation) cancelNavigation();
940 1569 });
941
1/1
✓ Branch 4 → 5 taken 795 times.
795 connect(&workspace, &WorkspaceState::areaChanged, this, [this] {
942 655 cancelNavigation();
943
2/2
✓ Branch 11 → 12 taken 173 times.
✓ Branch 11 → 14 taken 482 times.
655 if (m_workspace.area() == QStringLiteral("mail"))
944
1/1
✓ Branch 12 → 13 taken 173 times.
173 QTimer::singleShot(0, this, &QuickMailBackend::reconcileSelection);
945 655 });
946
1/1
✓ Branch 7 → 8 taken 795 times.
795 connect(session.headers(), &QAbstractItemModel::modelReset, this, [this] {
947
1/1
✓ Branch 2 → 3 taken 1661 times.
1661 QTimer::singleShot(0, this, &QuickMailBackend::reconcileSelection);
948 1661 });
949 795 }
950
951 // S92.5.7: one constant status notice while the primary connection
952 // recovers from an outage, cleared when it is back (or no longer
953 // recovering, e.g. offline mode). It is a pending action with a reserved
954 // token above every journal token, so a mutation notice keeps priority and
955 // repeated reconnect attempts never change the text (no countdown flicker).
956 namespace {
957 constexpr qint64 ConnectionStatusToken = std::numeric_limits<qint64>::max();
958 }
959
960 795 void QuickMailBackend::wireConnectionStatus() {
961 795 ConnectionHealthMonitor *health = m_session.health();
962
3/6
✓ Branch 3 → 4 taken 795 times.
✗ Branch 3 → 6 not taken.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 795 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 795 times.
795 if (!health || !m_session.imap()) return;
963 // Queued: the monitor reacts to the same state change first.
964 13 const auto refresh = [this] {
965
1/1
✓ Branch 2 → 3 taken 13 times.
13 QTimer::singleShot(0, this, &QuickMailBackend::refreshConnectionStatus);
966 13 };
967
1/1
✓ Branch 10 → 11 taken 795 times.
795 connect(health, &ConnectionHealthMonitor::reconnectScheduled, this, refresh);
968
1/1
✓ Branch 12 → 13 taken 795 times.
795 connect(health, &ConnectionHealthMonitor::connectionRestored, this, refresh);
969
1/1
✓ Branch 15 → 16 taken 795 times.
795 connect(m_session.imap(), &ImapService::stateChanged, this, refresh);
970 }
971
972 11 void QuickMailBackend::refreshConnectionStatus() {
973
3/4
✓ Branch 3 → 4 taken 11 times.
✗ Branch 3 → 8 not taken.
✓ Branch 6 → 7 taken 4 times.
✓ Branch 6 → 8 taken 7 times.
11 const bool recovering = m_session.health() && m_session.health()->isRecovering();
974
1/1
✓ Branch 12 → 13 taken 11 times.
11 m_statusState.pendingAction(ConnectionStatusToken,
975
3/3
✓ Branch 9 → 10 taken 4 times.
✓ Branch 9 → 11 taken 7 times.
✓ Branch 10 → 12 taken 4 times.
22 recovering ? tr("Connection lost — reconnecting…") : QString());
976 11 }
977
978 // Controller status, folder identity, unread counts and IMAP folder list/state.
979 795 void QuickMailBackend::wireControllerStatus() {
980 795 MailSessionCore &session = m_session;
981
1/1
✓ Branch 3 → 4 taken 795 times.
795 connect(session.controller(), &MailController::statusMessage, this, &QuickMailBackend::setStatus);
982
1/1
✓ Branch 6 → 7 taken 795 times.
795 connect(session.controller(), &MailController::actionError, this,
983 795 [this](const QString &text) { m_statusState.error(text); });
984
1/1
✓ Branch 9 → 10 taken 795 times.
795 connect(session.controller(), &MailController::pendingActionChanged,
985 795 &m_statusState, &StatusMessageState::pendingAction);
986
1/1
✓ Branch 12 → 13 taken 795 times.
795 connect(session.controller(), &MailController::pendingActionsReset,
987 795 &m_statusState, &StatusMessageState::clearPendingActions);
988
1/1
✓ Branch 15 → 16 taken 795 times.
795 connect(session.controller(), &MailController::backgroundStatusMessage, this,
989 1390 [this](const QString &text) { m_statusState.progress(text); });
990
1/1
✓ Branch 18 → 19 taken 795 times.
795 connect(session.controller(), &MailController::folderIdentityInvalidated, this, [this](qint64 folderId) {
991 11 const auto account = m_session.controller()->accountId();
992 {
993 11 QScopedValueRollback reconciling(m_reconciling, true);
994 11 QScopedValueRollback applying(m_applyingSelection, true);
995
2/2
✓ Branch 7 → 8 taken 9 times.
✓ Branch 7 → 12 taken 2 times.
11 if (m_selection.folderId() == folderId) {
996
1/1
✓ Branch 8 → 9 taken 9 times.
9 m_selection.clear();
997 9 m_mainCurrent = {};
998 }
999
1/1
✓ Branch 12 → 13 taken 11 times.
11 m_workspace.invalidateFolder(account, folderId);
1000 11 }
1001
1/1
✓ Branch 15 → 16 taken 11 times.
11 publishSelection();
1002 11 });
1003
1/1
✓ Branch 21 → 22 taken 795 times.
795 connect(session.controller(), &MailController::cachedUnreadCountChanged,
1004 795 &m_folders, &FolderPresentationModel::setUnread);
1005
1/1
✓ Branch 24 → 25 taken 795 times.
795 connect(session.controller(), &MailController::unreadCountChanged,
1006 795 &m_folders, &FolderPresentationModel::setUnread);
1007
1/1
✓ Branch 27 → 28 taken 795 times.
795 connect(session.imap(), &ImapService::folderListReceived, this,
1008 828 [this](const QList<FolderInfo> &folders) { applyFolderList(folders, true); });
1009
1/1
✓ Branch 30 → 31 taken 795 times.
795 connect(session.imap(), &ImapService::errorOccurred, this, &QuickMailBackend::setStatus);
1010
1/1
✓ Branch 33 → 34 taken 795 times.
795 connect(session.imap(), &ImapService::errorOccurred, this, [this](const QString &error) {
1011 ✗ m_lastImapError = error;
1012 ✗ m_settings.notifyOverviewChanged();
1013 ✗ });
1014
1/1
✓ Branch 36 → 37 taken 795 times.
795 connect(session.imap(), &ImapService::stateChanged, this, [this](ImapService::State state) {
1015
2/2
✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 4 taken 8 times.
10 if (state == ImapService::State::Authenticated) m_lastImapError.clear();
1016
2/2
✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 10 taken 8 times.
10 if (state == ImapService::State::Authenticated) {
1017
2/2
✓ Branch 5 → 6 taken 2 times.
✓ Branch 6 → 7 taken 2 times.
2 setStatus(tr("Loading folders…"));
1018 2 m_session.imap()->listFolders();
1019
2/2
✓ Branch 10 → 11 taken 1 time.
✓ Branch 10 → 15 taken 7 times.
8 } else if (state == ImapService::State::Connecting) {
1020
2/2
✓ Branch 11 → 12 taken 1 time.
✓ Branch 12 → 13 taken 1 time.
1 setStatus(tr("Connecting…"));
1021 }
1022 10 });
1023 795 }
1024
1025
1026 795 QuickMailBackend::~QuickMailBackend() {
1027 795 m_compose.shutdown();
1028 795 m_settings.destroyPendingRequests();
1029 795 cancelThreadProjection();
1030 795 m_threadPool.waitForDone();
1031 795 FolderPredictionService::shared()->cancel(this);
1032 795 cancelNavigation();
1033 795 }
1034
1035 17949 qint64 QuickMailBackend::selectionCount() const {
1036
2/2
✓ Branch 3 → 4 taken 657 times.
✓ Branch 3 → 20 taken 17292 times.
17949 if (m_workspace.activeIsSearch()) {
1037 657 const MailSearchSession *search = m_searchTabs->active();
1038
6/6
✓ Branch 6 → 7 taken 439 times.
✓ Branch 6 → 10 taken 218 times.
✓ Branch 8 → 9 taken 84 times.
✓ Branch 8 → 10 taken 355 times.
✓ Branch 11 → 12 taken 84 times.
✓ Branch 11 → 14 taken 573 times.
657 if (search && search->markedCount() > 1) return search->markedCount();
1039
2/2
✓ Branch 16 → 17 taken 336 times.
✓ Branch 16 → 18 taken 237 times.
573 return m_workspace.mailId().isValid() ? 1 : 0;
1040 }
1041 17292 return m_selection.count();
1042 }
1043 4704 QStringList QuickMailBackend::selectedLabels() const {
1044 4704 const auto selected = m_workspace.mailId();
1045
2/2
✓ Branch 5 → 6 taken 4580 times.
✓ Branch 5 → 7 taken 124 times.
4704 if (!selected.isValid()) return {};
1046
2/3
✓ Branch 7 → 8 taken 124 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 124 times.
124 if (const auto *visible = m_threadList.indexedHeader(selected.folderId, selected.uid))
1047 ✗ return visible->labels;
1048
1/1
✓ Branch 11 → 12 taken 124 times.
124 const int row = m_session.headers()->rowForUid(selected.uid, selected.folderId);
1049
3/3
✓ Branch 13 → 14 taken 124 times.
✓ Branch 14 → 15 taken 100 times.
✓ Branch 14 → 16 taken 24 times.
124 if (const auto *visible = m_session.headers()->headerAt(row))
1050 100 return visible->labels;
1051 // A selected tab may point outside the bounded visible page.
1052
1/1
✓ Branch 17 → 18 taken 24 times.
24 const auto header = m_session.cache()->header(selected.folderId, selected.uid);
1053
1/2
✓ Branch 19 → 20 taken 24 times.
✗ Branch 19 → 22 not taken.
24 return header ? header->labels : QStringList{};
1054 4704 }
1055 2 QVariantList QuickMailBackend::pendingActionDetails() const {
1056 // Read on demand when the status details open; bounded so a large offline
1057 // backlog cannot stall the GUI thread.
1058 2 constexpr int MaximumRows = 200;
1059 2 QVariantList rows;
1060 2 auto *cache = m_session.cache();
1061
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 2 times.
2 if (!cache) return rows;
1062
1/1
✓ Branch 8 → 9 taken 2 times.
2 const auto records = cache->journal().pendingMutations(m_session.controller()->accountId());
1063 2 const qint64 now = QDateTime::currentSecsSinceEpoch();
1064
2/2
✓ Branch 170 → 13 taken 2 times.
✓ Branch 170 → 171 taken 2 times.
4 for (const auto &record : records) {
1065
1/2
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 2 times.
2 if (record.phase == MutationPhase::Confirmed) continue;
1066
1/2
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 2 times.
2 if (rows.size() >= MaximumRows) break;
1067 2 QString action;
1068
1/8
✓ Branch 19 → 20 taken 2 times.
✗ Branch 19 → 26 not taken.
✗ Branch 19 → 32 not taken.
✗ Branch 19 → 46 not taken.
✗ Branch 19 → 53 not taken.
✗ Branch 19 → 60 not taken.
✗ Branch 19 → 67 not taken.
✗ Branch 19 → 71 not taken.
2 switch (record.kind) {
1069 2 case MutationKind::Seen:
1070
2/4
✓ Branch 20 → 21 taken 2 times.
✗ Branch 20 → 22 not taken.
✓ Branch 21 → 23 taken 2 times.
✗ Branch 22 → 23 not taken.
2 action = record.desiredState ? tr("Mark as read") : tr("Mark as unread"); break;
1071 ✗ case MutationKind::Flagged:
1072 ✗ action = record.desiredState ? tr("Star") : tr("Remove star"); break;
1073 ✗ case MutationKind::Keyword:
1074 ✗ action = record.desiredState ? tr("Add label %1").arg(record.flag)
1075 ✗ : tr("Remove label %1").arg(record.flag); break;
1076 ✗ case MutationKind::Move: action = tr("Move to %1").arg(record.targetFolder); break;
1077 ✗ case MutationKind::Copy: action = tr("Copy to %1").arg(record.targetFolder); break;
1078 ✗ case MutationKind::Append: action = tr("Save copy in %1").arg(record.targetFolder); break;
1079 ✗ case MutationKind::Undo: action = tr("Undo move"); break;
1080 }
1081 2 QString state;
1082
1/8
✓ Branch 71 → 72 taken 2 times.
✗ Branch 71 → 76 not taken.
✗ Branch 71 → 80 not taken.
✗ Branch 71 → 84 not taken.
✗ Branch 71 → 88 not taken.
✗ Branch 71 → 92 not taken.
✗ Branch 71 → 96 not taken.
✗ Branch 71 → 97 not taken.
2 switch (record.phase) {
1083
1/1
✓ Branch 72 → 73 taken 2 times.
2 case MutationPhase::Queued: state = tr("Waiting to be sent"); break;
1084 ✗ case MutationPhase::Dispatched: state = tr("Sent, waiting for the server"); break;
1085 ✗ case MutationPhase::AwaitingVerification: state = tr("Sent, server result being checked"); break;
1086 ✗ case MutationPhase::Ambiguous: state = tr("Server result unclear, will be checked again"); break;
1087 ✗ case MutationPhase::Failed: state = tr("Failed"); break;
1088 ✗ case MutationPhase::Quarantined: state = tr("Stopped, needs attention"); break;
1089 ✗ case MutationPhase::Confirmed: break;
1090 }
1091 ✗ QString subject;
1092
1/2
✓ Branch 97 → 98 taken 2 times.
✗ Branch 97 → 105 not taken.
2 if (record.mailId.uid > 0)
1093
2/3
✓ Branch 98 → 99 taken 2 times.
✓ Branch 100 → 101 taken 2 times.
✗ Branch 100 → 103 not taken.
2 if (const auto header = cache->header(record.mailId.folderId, record.mailId.uid))
1094 2 subject = header->subject;
1095
1/2
✓ Branch 105 → 106 taken 2 times.
✗ Branch 105 → 108 not taken.
2 const qint64 minutes = record.createdAt > 0 ? qMax<qint64>(0, now - record.createdAt) / 60 : 0;
1096
1/1
✓ Branch 143 → 144 taken 2 times.
2 rows.append(QVariantMap{
1097 4 {QStringLiteral("action"), action},
1098
1/3
✗ Branch 117 → 118 not taken.
✓ Branch 117 → 119 taken 2 times.
✗ Branch 118 → 120 not taken.
4 {QStringLiteral("subject"), subject.isEmpty() ? tr("(no subject)") : subject},
1099 4 {QStringLiteral("folder"), record.folderPath},
1100 4 {QStringLiteral("state"), state},
1101 4 {QStringLiteral("error"), record.error},
1102
2/4
✓ Branch 136 → 137 taken 2 times.
✗ Branch 136 → 138 not taken.
✓ Branch 137 → 140 taken 2 times.
✗ Branch 139 → 140 not taken.
4 {QStringLiteral("age"), minutes < 1 ? tr("just now") : tr("%n min ago", nullptr, int(qMin<qint64>(minutes, 100000)))},
1103
2/4
✓ Branch 147 → 148 taken 12 times.
✓ Branch 147 → 149 taken 2 times.
✗ Branch 218 → 219 not taken.
✗ Branch 218 → 220 not taken.
16 });
1104 2 }
1105 2 return rows;
1106 16 }
1107
1108 // ─── Q09: CardDAV contact sync ───
1109
1110 44 void QuickMailBackend::previewSearch(const QString &query) {
1111 44 cancelSearchPreview();
1112
4/4
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 9 taken 43 times.
✓ Branch 5 → 6 taken 1 time.
✓ Branch 6 → 7 taken 1 time.
44 if (query.size() > 4096) { setStatus(tr("Search query is too long.")); return; }
1113
6/13
✓ Branch 10 → 11 taken 43 times.
✗ Branch 10 → 16 not taken.
✓ Branch 12 → 13 taken 43 times.
✗ Branch 12 → 16 not taken.
✓ Branch 13 → 14 taken 43 times.
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 43 times.
✓ Branch 18 → 19 taken 43 times.
✗ Branch 18 → 20 not taken.
✗ Branch 20 → 21 not taken.
✓ Branch 20 → 22 taken 43 times.
✗ Branch 28 → 29 not taken.
✗ Branch 28 → 30 not taken.
43 if (!configured() || m_availableFolders.isEmpty() || query.trimmed().isEmpty()) return;
1114 43 m_searchPreviewQuery = query;
1115 43 m_searchDebounce.start();
1116 }
1117 1242 void QuickMailBackend::cancelSearchPreview() {
1118 1242 m_search->cancelQuickResults();
1119 1242 m_searchDebounce.stop();
1120 1242 m_searchPreviewQuery.clear();
1121 1242 m_searchSuggestions.clear();
1122 1242 emit searchSuggestionsChanged();
1123 1242 }
1124 77 bool QuickMailBackend::submitSearch(const QString &query, int suggestion) {
1125
4/4
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 8 taken 76 times.
✓ Branch 4 → 5 taken 1 time.
✓ Branch 5 → 6 taken 1 time.
77 if (query.size() > 4096) { setStatus(tr("Search query is too long.")); return false; }
1126
5/6
✓ Branch 9 → 10 taken 74 times.
✓ Branch 9 → 12 taken 2 times.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 74 times.
✓ Branch 14 → 15 taken 2 times.
✓ Branch 14 → 16 taken 74 times.
76 if (!configured() || m_availableFolders.isEmpty()) return false;
1127
2/2
✓ Branch 16 → 17 taken 5 times.
✓ Branch 16 → 58 taken 69 times.
74 if (suggestion >= 0) {
1128
3/6
✓ Branch 18 → 19 taken 5 times.
✗ Branch 18 → 21 not taken.
✗ Branch 20 → 21 not taken.
✓ Branch 20 → 22 taken 5 times.
✗ Branch 23 → 24 not taken.
✓ Branch 23 → 25 taken 5 times.
5 if (query != m_searchPreviewQuery || suggestion >= m_searchSuggestions.size()) return false;
1129
1/1
✓ Branch 26 → 27 taken 5 times.
5 const auto entry = m_searchSuggestions.at(suggestion).toMap();
1130
3/3
✓ Branch 28 → 29 taken 5 times.
✓ Branch 29 → 30 taken 5 times.
✓ Branch 30 → 31 taken 5 times.
5 const auto identity = entry.value("identity").toMap();
1131
3/4
✓ Branch 34 → 35 taken 5 times.
✓ Branch 35 → 36 taken 5 times.
✗ Branch 37 → 38 not taken.
✓ Branch 37 → 39 taken 5 times.
5 if (!acceptsIdentity(WorkspaceState::decode(identity))) return false;
1132
6/6
✓ Branch 40 → 41 taken 5 times.
✓ Branch 41 → 42 taken 5 times.
✓ Branch 42 → 43 taken 5 times.
✓ Branch 43 → 44 taken 5 times.
✓ Branch 48 → 49 taken 1 time.
✓ Branch 48 → 53 taken 4 times.
5 if (m_workspace.openMail(identity, entry.value("subject").toString()) < 0) {
1133
2/2
✓ Branch 49 → 50 taken 1 time.
✓ Branch 50 → 51 taken 1 time.
1 setStatus(tr("Cannot open another tab. Close a tab and retry."));
1134 1 return false;
1135 }
1136
1/1
✓ Branch 53 → 54 taken 4 times.
4 cancelSearchPreview();
1137 4 return true;
1138 5 }
1139 69 return openSearch(query, false);
1140 }
1141 93 bool QuickMailBackend::openSearch(const QString &query, bool newTab) {
1142
1/4
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 8 taken 93 times.
✗ Branch 4 → 5 not taken.
✗ Branch 5 → 6 not taken.
93 if (query.size() > WorkspaceState::MaximumQueryCharacters) { setStatus(tr("Search query is too long.")); return false; }
1143
1/2
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 93 times.
93 if (!configured()) return false;
1144 93 cancelSearchPreview();
1145
1/2
✓ Branch 14 → 15 taken 93 times.
✗ Branch 14 → 16 not taken.
93 if (m_searchTabs->open(query, newTab)) return true;
1146 ✗ setStatus(tr("Cannot open another tab. Close a tab and retry."));
1147 ✗ return false;
1148 }
1149 872 QStringList QuickMailBackend::expandedFolders() const {
1150
1/1
✓ Branch 3 → 4 taken 872 times.
872 return m_workspace.expandedFolders(accountName());
1151 }
1152 23 bool QuickMailBackend::setFolderExpanded(const QString &path, bool expanded) {
1153
1/1
✓ Branch 2 → 3 taken 23 times.
23 const auto index = m_folders.indexForPath(path);
1154
7/9
✓ Branch 4 → 5 taken 22 times.
✓ Branch 4 → 9 taken 1 time.
✓ Branch 6 → 7 taken 22 times.
✗ Branch 6 → 9 not taken.
✓ Branch 7 → 8 taken 22 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 22 times.
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 13 taken 22 times.
23 if (!configured() || !index.isValid() || m_folders.rowCount(index) == 0) return false;
1155
2/3
✓ Branch 14 → 15 taken 22 times.
✓ Branch 16 → 17 taken 22 times.
✗ Branch 16 → 18 not taken.
22 if (m_workspace.setFolderExpanded(accountName(), path, expanded)) return true;
1156 ✗ setStatus(tr("Expanded folder state exceeds storage limits."));
1157 ✗ return false;
1158 }
1159
1160 1035 void QuickMailBackend::refreshSelectionScope() {
1161 1035 MailSelectionQuery query;
1162 1035 query.accountId = accountName();
1163
1/1
✓ Branch 6 → 7 taken 1035 times.
1035 query.folderId = folderIdForPath(m_folderPath);
1164
2/2
✓ Branch 8 → 9 taken 51 times.
✓ Branch 8 → 10 taken 984 times.
1035 query.text = threadedPresentation() ? m_threadList.filterText() : m_mail.filterText();
1165
2/2
✓ Branch 14 → 15 taken 51 times.
✓ Branch 14 → 17 taken 984 times.
1035 query.unreadOnly = threadedPresentation() ? m_threadList.showUnreadOnly() : m_mail.showUnreadOnly();
1166
2/2
✓ Branch 20 → 21 taken 51 times.
✓ Branch 20 → 23 taken 984 times.
1035 query.starredOnly = threadedPresentation() ? m_threadList.showStarredOnly() : m_mail.showStarredOnly();
1167
2/2
✓ Branch 26 → 27 taken 51 times.
✓ Branch 26 → 29 taken 984 times.
1035 query.attachmentsOnly = threadedPresentation() ? m_threadList.showWithAttachments() : m_mail.showWithAttachments();
1168 1035 const auto generation = m_session.accounts()->generation();
1169 1035 const auto databasePath = m_session.cache()->databasePath();
1170 1035 const auto folder = m_folderPath;
1171 1035 MailSelectionKey initialSingle;
1172
2/2
✓ Branch 38 → 39 taken 1029 times.
✓ Branch 38 → 48 taken 6 times.
1035 if (m_workspace.activeTab() == 0) {
1173
2/2
✓ Branch 39 → 40 taken 1029 times.
✓ Branch 40 → 41 taken 1029 times.
1029 const int row = activeRowForIdentity(m_workspace.selection());
1174
1/1
✓ Branch 42 → 43 taken 1029 times.
1029 const auto key = activeKeyAt(row);
1175
4/4
✓ Branch 43 → 44 taken 298 times.
✓ Branch 43 → 46 taken 731 times.
✓ Branch 44 → 45 taken 83 times.
✓ Branch 44 → 46 taken 215 times.
1029 if (key.folderId == query.folderId && key.uid >= 0) initialSingle = key;
1176 1029 }
1177
4/4
✓ Branch 51 → 52 taken 82 times.
✓ Branch 51 → 54 taken 1 time.
✓ Branch 57 → 58 taken 83 times.
✓ Branch 57 → 59 taken 952 times.
1118 m_mainCurrent = initialSingle.uid >= 0 && m_workspace.area() == "mail"
1178
2/2
✓ Branch 48 → 49 taken 83 times.
✓ Branch 48 → 54 taken 952 times.
2153 ? MailId{accountName(), initialSingle.folderId, initialSingle.uid}
1179 1035 : MailId{};
1180 // F-41: Shift+click ranges follow the presented order.
1181 1035 m_selection.setVisibleRangeProvider(
1182 1035 [this](const MailSelectionKey &anchor, const MailSelectionKey &key) {
1183 6 return visibleRangeBetween(anchor, key);
1184 });
1185
1/1
✓ Branch 69 → 70 taken 1035 times.
2070 m_selection.configure(databasePath, query,
1186
1/5
✓ Branch 66 → 67 taken 1035 times.
✗ Branch 94 → 95 not taken.
✗ Branch 94 → 96 not taken.
✗ Branch 97 → 98 not taken.
✗ Branch 97 → 99 not taken.
2070 [this, query, generation, databasePath, folder](const MailSelectionSnapshot &snapshot) {
1187
1/4
✓ Branch 6 → 7 taken 40 times.
✗ Branch 6 → 27 not taken.
✗ Branch 38 → 39 not taken.
✗ Branch 38 → 40 not taken.
80 return m_workspace.activeTab() == 0 && m_workspace.area() == "mail" &&
1188
2/4
✓ Branch 9 → 10 taken 40 times.
✗ Branch 9 → 27 not taken.
✓ Branch 13 → 14 taken 40 times.
✗ Branch 13 → 27 not taken.
80 m_session.accounts()->generation() == generation &&
1189
3/8
✓ Branch 15 → 16 taken 40 times.
✗ Branch 15 → 27 not taken.
✓ Branch 17 → 18 taken 40 times.
✗ Branch 17 → 27 not taken.
✓ Branch 30 → 31 taken 40 times.
✗ Branch 30 → 32 not taken.
✗ Branch 34 → 35 not taken.
✗ Branch 34 → 36 not taken.
160 m_session.cache()->databasePath() == databasePath && m_folderPath == folder &&
1190
1/2
✓ Branch 19 → 20 taken 40 times.
✗ Branch 19 → 27 not taken.
80 m_availableFolders.contains(folder) && snapshot.accountId == query.accountId &&
1191
3/6
✓ Branch 3 → 4 taken 40 times.
✗ Branch 3 → 27 not taken.
✓ Branch 20 → 21 taken 40 times.
✗ Branch 20 → 27 not taken.
✓ Branch 22 → 23 taken 40 times.
✗ Branch 22 → 27 not taken.
120 snapshot.folderId == query.folderId && snapshot.folderPath == folder &&
1192
3/5
✓ Branch 24 → 25 taken 40 times.
✓ Branch 25 → 26 taken 40 times.
✗ Branch 25 → 27 not taken.
✓ Branch 28 → 29 taken 40 times.
✗ Branch 28 → 30 not taken.
120 m_session.cache()->uidValidity(query.folderId) == snapshot.uidValidity;
1193 }, initialSingle);
1194 1035 }
1195 239 void QuickMailBackend::beginMailRowSelection(int row, int modifiers) {
1196
1/1
✓ Branch 2 → 3 taken 239 times.
239 auto &trace = MailJD::Performance::ThreadSelectionPerformanceTracker::instance();
1197 ✗ if (!trace.isActive() || modifiers != 0 || m_workspace.activeTab() != 0 ||
1198
4/13
✓ Branch 3 → 4 taken 239 times.
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 13 taken 239 times.
✗ Branch 10 → 11 not taken.
✗ Branch 10 → 13 not taken.
✗ Branch 12 → 13 not taken.
✗ Branch 12 → 14 not taken.
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 239 times.
✓ Branch 17 → 18 taken 239 times.
✗ Branch 17 → 19 not taken.
✗ Branch 37 → 38 not taken.
✗ Branch 37 → 39 not taken.
239 m_workspace.area() != "mail" || !configured()) return;
1199 ✗ const auto key = activeKeyAt(row);
1200 ✗ const MailId id{accountName(), key.folderId, key.uid};
1201 ✗ if (id.isValid() && id != m_workspace.mailId()) trace.beginSelection(id);
1202 ✗ }
1203
1204 187 void QuickMailBackend::selectMailRow(int row, int modifiers) {
1205
8/10
✓ Branch 3 → 4 taken 15 times.
✓ Branch 3 → 10 taken 172 times.
✓ Branch 6 → 7 taken 15 times.
✗ Branch 6 → 10 not taken.
✓ Branch 8 → 9 taken 15 times.
✗ Branch 8 → 10 not taken.
✓ Branch 11 → 12 taken 15 times.
✓ Branch 11 → 13 taken 172 times.
✓ Branch 13 → 14 taken 15 times.
✓ Branch 13 → 39 taken 172 times.
187 if (m_workspace.activeIsSearch() && m_workspace.area() == "mail" && configured()) {
1206 // Sprint 101: a result of the active search tab (single selection).
1207
1/1
✓ Branch 15 → 16 taken 15 times.
15 MailSearchSession *search = m_searchTabs->active();
1208
2/3
✓ Branch 16 → 17 taken 15 times.
✗ Branch 16 → 18 not taken.
✓ Branch 17 → 19 taken 15 times.
15 const MailId id = search ? search->mailIdAt(row) : MailId{};
1209
4/7
✓ Branch 19 → 20 taken 15 times.
✗ Branch 19 → 22 not taken.
✓ Branch 20 → 21 taken 15 times.
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 23 taken 15 times.
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 26 taken 15 times.
15 if (!search || !acceptsIdentity(id)) return;
1210 // S101.6.1 (D-13): Ctrl/Shift mark results for a bulk action.
1211 15 const auto keys = Qt::KeyboardModifiers(modifiers);
1212
3/3
✓ Branch 31 → 32 taken 4 times.
✓ Branch 31 → 33 taken 11 times.
✓ Branch 32 → 34 taken 4 times.
15 if (keys & (Qt::ControlModifier | Qt::ShiftModifier)) search->mark(row, keys);
1213
1/1
✓ Branch 33 → 34 taken 11 times.
11 else search->clearMarks();
1214
1/1
✓ Branch 34 → 35 taken 15 times.
15 m_workspace.select(id);
1215
1/1
✓ Branch 35 → 36 taken 15 times.
15 emit selectionChanged();
1216 15 return;
1217 15 }
1218
5/10
✓ Branch 40 → 41 taken 172 times.
✗ Branch 40 → 46 not taken.
✓ Branch 43 → 44 taken 172 times.
✗ Branch 43 → 46 not taken.
✗ Branch 45 → 46 not taken.
✓ Branch 45 → 47 taken 172 times.
✓ Branch 48 → 49 taken 172 times.
✗ Branch 48 → 50 not taken.
✗ Branch 50 → 51 not taken.
✓ Branch 50 → 52 taken 172 times.
172 if (m_workspace.activeTab() != 0 || m_workspace.area() != "mail" || !configured()) return;
1219 172 beginMailRowSelection(row, modifiers);
1220
2/2
✓ Branch 55 → 56 taken 172 times.
✓ Branch 56 → 57 taken 172 times.
172 m_selection.select(activeKeyAt(row), Qt::KeyboardModifiers(modifiers));
1221 }
1222 23 void QuickMailBackend::selectAllMails() {
1223
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 10 taken 21 times.
23 if (m_workspace.activeIsSearch()) {
1224
1/2
✓ Branch 6 → 7 taken 2 times.
✗ Branch 6 → 8 not taken.
2 if (MailSearchSession *search = m_searchTabs->active()) search->markAllLoaded(); // the loaded rows (D-13)
1225 2 emit selectionChanged();
1226 2 return;
1227 }
1228
5/10
✓ Branch 11 → 12 taken 21 times.
✗ Branch 11 → 18 not taken.
✓ Branch 14 → 15 taken 21 times.
✗ Branch 14 → 18 not taken.
✓ Branch 16 → 17 taken 21 times.
✗ Branch 16 → 18 not taken.
✓ Branch 19 → 20 taken 21 times.
✗ Branch 19 → 21 not taken.
✓ Branch 21 → 22 taken 21 times.
✗ Branch 21 → 23 not taken.
21 if (m_workspace.activeTab() == 0 && m_workspace.area() == "mail" && configured()) m_selection.selectAll();
1229 }
1230 812 void QuickMailBackend::prunePredictionsToScope() {
1231 812 QSet<MailId> keep;
1232 812 const auto selected = m_workspace.mailId();
1233
3/3
✓ Branch 6 → 7 taken 773 times.
✓ Branch 6 → 9 taken 39 times.
✓ Branch 7 → 8 taken 773 times.
812 if (selected.isValid()) keep.insert(selected);
1234
5/6
✓ Branch 10 → 11 taken 20 times.
✓ Branch 10 → 13 taken 792 times.
✓ Branch 11 → 12 taken 20 times.
✗ Branch 11 → 13 not taken.
✓ Branch 14 → 15 taken 20 times.
✓ Branch 14 → 25 taken 792 times.
812 if (suggestionColumnShown() && m_predictionFirst >= 0)
1235
3/3
✓ Branch 22 → 23 taken 535 times.
✓ Branch 24 → 16 taken 515 times.
✓ Branch 24 → 25 taken 20 times.
535 for (int row = m_predictionFirst; row <= qMin(m_predictionLast, activeRowCount() - 1); ++row) {
1236
1/1
✓ Branch 16 → 17 taken 515 times.
515 const auto key = activeKeyAt(row);
1237 515 keep.insert({accountName(), key.folderId, key.uid});
1238 515 }
1239
3/3
✓ Branch 25 → 26 taken 812 times.
✓ Branch 36 → 27 taken 259 times.
✓ Branch 36 → 37 taken 812 times.
1071 for (auto it = m_predictions.begin(); it != m_predictions.end();)
1240
3/3
✓ Branch 29 → 30 taken 205 times.
✓ Branch 29 → 33 taken 54 times.
✓ Branch 31 → 32 taken 205 times.
259 if (!keep.contains(it.key())) it = m_predictions.erase(it); else ++it;
1241 1327 }
1242
1243 1647 void QuickMailBackend::watchSelectionRows(int first, int last) {
1244
1/1
✓ Branch 2 → 3 taken 1647 times.
1647 emit viewportWatched(first, last);
1245
1/2
✓ Branch 3 → 4 taken 1647 times.
✗ Branch 3 → 6 not taken.
1647 if (last >= first) m_pageRows = qMax(1, last - first + 1);
1246
4/4
✓ Branch 6 → 7 taken 942 times.
✓ Branch 6 → 8 taken 705 times.
✓ Branch 7 → 8 taken 107 times.
✓ Branch 7 → 12 taken 835 times.
1647 if (m_predictionFirst != first || m_predictionLast != last) {
1247 812 m_predictionFirst = first;
1248 812 m_predictionLast = last;
1249
1/1
✓ Branch 8 → 9 taken 812 times.
812 prunePredictionsToScope();
1250
3/3
✓ Branch 10 → 11 taken 20 times.
✓ Branch 10 → 12 taken 792 times.
✓ Branch 11 → 12 taken 20 times.
812 if (suggestionColumnShown()) invalidatePredictions();
1251 }
1252
4/5
✓ Branch 12 → 13 taken 1024 times.
✓ Branch 12 → 14 taken 623 times.
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 18 taken 1024 times.
✓ Branch 15 → 16 taken 623 times.
1647 if (first < 0 || last < first) { m_selection.watch({}); return; }
1253
1/4
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 23 taken 1024 times.
✗ Branch 19 → 20 not taken.
✗ Branch 20 → 21 not taken.
1024 if (qint64(last) - first >= 4096) { setStatus(tr("Selection failed. Select again.")); return; }
1254 1024 QList<qint64> uids;
1255
5/5
✓ Branch 24 → 25 taken 6263 times.
✓ Branch 25 → 26 taken 6263 times.
✓ Branch 28 → 29 taken 7287 times.
✓ Branch 30 → 24 taken 6263 times.
✓ Branch 30 → 31 taken 1024 times.
7287 for (int row = first; row <= qMin(last, activeRowCount() - 1); ++row) uids.append(activeKeyAt(row).uid);
1256
1/1
✓ Branch 31 → 32 taken 1024 times.
1024 m_selection.watch(uids);
1257 1024 }
1258
1259 2414 bool QuickMailBackend::canFetchMoreMails() const {
1260
3/3
✓ Branch 3 → 4 taken 55 times.
✓ Branch 3 → 6 taken 2359 times.
✓ Branch 8 → 9 taken 2359 times.
2414 return threadedPresentation() ? m_threadList.hasMoreIndexed() : m_session.headers()->canFetchMore({});
1261 }
1262
1263 21 void QuickMailBackend::fetchMoreMails() {
1264
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 21 times.
21 if (!canFetchMoreMails()) return;
1265
2/2
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 8 taken 20 times.
21 if (threadedPresentation()) m_threadList.fetchIndexed();
1266
1/1
✓ Branch 10 → 11 taken 20 times.
20 else m_session.headers()->fetchMore({});
1267 }
1268
1269 1488 bool QuickMailBackend::canFetchPreviousMails() const {
1270
4/4
✓ Branch 3 → 4 taken 87 times.
✓ Branch 3 → 7 taken 1401 times.
✓ Branch 5 → 6 taken 2 times.
✓ Branch 5 → 7 taken 85 times.
1488 return threadedPresentation() && m_threadList.hasMoreIndexed(true);
1271 }
1272
1273 1 void QuickMailBackend::fetchPreviousMails() {
1274
1/2
✓ Branch 3 → 4 taken 1 time.
✗ Branch 3 → 5 not taken.
1 if (canFetchPreviousMails()) m_threadList.fetchIndexed(true);
1275 1 }
1276
1277 3804 void QuickMailBackend::cancelNavigation() {
1278 3804 const bool pending = navigationPending();
1279 3804 MailDisplayReadService::shared()->cancel(this);
1280 3804 m_navigation.clear();
1281 3804 m_navigationActive = false;
1282
2/2
✓ Branch 6 → 7 taken 2 times.
✓ Branch 6 → 8 taken 3802 times.
3804 if (pending) emit navigationChanged();
1283 3804 }
1284
1285 24 void QuickMailBackend::startNavigation() {
1286
7/8
✓ Branch 2 → 3 taken 21 times.
✓ Branch 2 → 7 taken 3 times.
✓ Branch 4 → 5 taken 13 times.
✓ Branch 4 → 7 taken 8 times.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 13 times.
✓ Branch 9 → 10 taken 11 times.
✓ Branch 9 → 11 taken 13 times.
24 if (m_navigationActive || m_navigation.isEmpty() || m_workspace.activeTab() == 0) return;
1287 13 const auto selected = m_workspace.mailId();
1288
1/1
✓ Branch 13 → 14 taken 13 times.
13 const auto generation = m_workspace.generation();
1289 13 const auto accountGeneration = m_session.accounts()->generation();
1290 13 const auto databasePath = m_session.cache()->databasePath();
1291
1/1
✓ Branch 18 → 19 taken 13 times.
13 const int direction = m_navigation.takeFirst();
1292 13 m_navigationActive = true;
1293
1/1
✓ Branch 19 → 20 taken 13 times.
13 emit navigationChanged();
1294
2/2
✓ Branch 20 → 21 taken 13 times.
✓ Branch 25 → 26 taken 13 times.
26 MailDisplayReadService::shared()->navigate(this, databasePath, selected, direction,
1295
1/5
✓ Branch 24 → 25 taken 13 times.
✗ Branch 38 → 39 not taken.
✗ Branch 38 → 40 not taken.
✗ Branch 41 → 42 not taken.
✗ Branch 41 → 43 not taken.
26 [this, selected, generation, accountGeneration, databasePath](MailDisplaySnapshot result) {
1296
2/6
✓ Branch 7 → 8 taken 11 times.
✗ Branch 7 → 17 not taken.
✗ Branch 23 → 24 not taken.
✓ Branch 23 → 26 taken 11 times.
✗ Branch 55 → 56 not taken.
✗ Branch 55 → 57 not taken.
22 if (m_workspace.generation() != generation || m_workspace.mailId() != selected ||
1297
1/2
✓ Branch 10 → 11 taken 11 times.
✗ Branch 10 → 17 not taken.
11 m_session.accounts()->generation() != accountGeneration ||
1298
6/13
✓ Branch 2 → 3 taken 11 times.
✓ Branch 4 → 5 taken 11 times.
✗ Branch 4 → 17 not taken.
✓ Branch 14 → 15 taken 11 times.
✗ Branch 14 → 17 not taken.
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 18 taken 11 times.
✓ Branch 19 → 20 taken 11 times.
✗ Branch 19 → 21 not taken.
✓ Branch 21 → 22 taken 11 times.
✗ Branch 21 → 23 not taken.
✗ Branch 51 → 52 not taken.
✗ Branch 51 → 53 not taken.
22 m_session.cache()->databasePath() != databasePath || m_workspace.activeTab() == 0) {
1299 ✗ cancelNavigation();
1300 ✗ return;
1301 }
1302 11 m_navigationActive = false;
1303
6/6
✓ Branch 26 → 27 taken 9 times.
✓ Branch 26 → 30 taken 2 times.
✓ Branch 28 → 29 taken 1 time.
✓ Branch 28 → 30 taken 8 times.
✓ Branch 31 → 32 taken 1 time.
✓ Branch 31 → 33 taken 10 times.
11 if (result.outcome == MailDisplaySnapshot::Outcome::Ready && !m_availableFolders.contains(result.folderPath))
1304 1 result.outcome = MailDisplaySnapshot::Outcome::HeaderMissing;
1305
2/2
✓ Branch 33 → 34 taken 8 times.
✓ Branch 33 → 40 taken 3 times.
11 if (result.outcome == MailDisplaySnapshot::Outcome::Ready) {
1306 8 QScopedValueRollback guard(m_applyingNavigation, true);
1307 8 m_workspace.replaceActiveMail({selected.accountId, selected.folderId, result.header.uid}, result.header.subject);
1308
2/2
✓ Branch 40 → 41 taken 2 times.
✓ Branch 40 → 48 taken 1 time.
11 } else if (result.outcome != MailDisplaySnapshot::Outcome::Boundary) {
1309 2 m_navigation.clear();
1310
1/1
✓ Branch 45 → 46 taken 2 times.
2 setStatus(result.outcome == MailDisplaySnapshot::Outcome::HeaderMissing
1311
2/4
✓ Branch 42 → 43 taken 2 times.
✗ Branch 42 → 44 not taken.
✓ Branch 43 → 45 taken 2 times.
✗ Branch 44 → 45 not taken.
4 ? tr("This message is no longer available. Select another message.")
1312 : tr("Could not navigate locally. Retry."));
1313 }
1314 11 startNavigation();
1315 11 emit navigationChanged();
1316
1/3
✓ Branch 36 → 37 taken 8 times.
✗ Branch 61 → 62 not taken.
✗ Branch 61 → 63 not taken.
21 }, m_mailSort); // F100-66: next/previous in the list order
1317 13 }
1318
1319 3753 QString QuickMailBackend::activeAccountId() const {
1320 3753 const auto &account = m_session.accounts()->primary();
1321
2/2
✓ Branch 5 → 6 taken 3692 times.
✓ Branch 5 → 8 taken 61 times.
3753 return account ? account->id : QString{};
1322 }
1323 2474 AccountOverview::Active QuickMailBackend::activeAccountState() {
1324 2474 AccountOverview::Active active;
1325 2474 active.accountId = activeAccountId();
1326
2/2
✓ Branch 6 → 7 taken 27 times.
✓ Branch 6 → 8 taken 2447 times.
2474 if (active.accountId.isEmpty()) return active;
1327 2447 const auto state = m_session.imap()->state();
1328
4/5
✓ Branch 11 → 12 taken 2447 times.
✗ Branch 11 → 16 not taken.
✓ Branch 13 → 14 taken 2447 times.
✓ Branch 14 → 15 taken 4 times.
✓ Branch 14 → 16 taken 2443 times.
2447 const bool recovering = m_session.health() && m_session.health()->isRecovering();
1329
5/6
✓ Branch 17 → 18 taken 2445 times.
✓ Branch 17 → 20 taken 2 times.
✓ Branch 18 → 19 taken 2443 times.
✓ Branch 18 → 20 taken 2 times.
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 27 taken 2443 times.
2447 if (state == ImapService::State::Authenticated || state == ImapService::State::Selected ||
1330 state == ImapService::State::Idling)
1331 4 active.mailState = QStringLiteral("connected");
1332
4/7
✓ Branch 27 → 28 taken 2443 times.
✗ Branch 27 → 31 not taken.
✓ Branch 29 → 30 taken 2443 times.
✗ Branch 30 → 31 not taken.
✓ Branch 30 → 32 taken 2443 times.
✗ Branch 33 → 34 not taken.
✓ Branch 33 → 50 taken 2443 times.
2443 else if (state == ImapService::State::Error || m_lastImapError.startsWith(QLatin1String("Authentication failed")))
1333 ✗ active.mailState = m_lastImapError.startsWith(QLatin1String("Authentication failed")) ? QStringLiteral("needs-password")
1334 ✗ : QStringLiteral("error");
1335
3/4
✓ Branch 50 → 51 taken 10 times.
✓ Branch 50 → 59 taken 2433 times.
✓ Branch 51 → 52 taken 10 times.
✗ Branch 51 → 59 not taken.
2443 else if (state != ImapService::State::Disconnected && !recovering)
1336 10 active.mailState = QStringLiteral("connecting");
1337 else
1338 2433 active.mailState = QStringLiteral("offline");
1339 2447 active.mailError = m_lastImapError;
1340 2447 active.rulesState = m_rules.state();
1341
1/1
✓ Branch 70 → 71 taken 2447 times.
2447 active.enabledRules = m_rules.enabledRuleCount();
1342
3/3
✓ Branch 71 → 72 taken 2447 times.
✓ Branch 76 → 77 taken 2447 times.
✓ Branch 77 → 78 taken 2447 times.
4894 active.vacation = m_rules.vacation().value(QStringLiteral("active")).toBool();
1343 2447 return active;
1344 ✗ }
1345
1346 4015 QVariantList QuickMailBackend::accountChoices() const {
1347 4015 return m_session.accounts()->availableAccounts();
1348 }
1349 7 bool QuickMailBackend::selectAccount(const QString &id) {
1350
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 7 times.
7 if (id == activeAccountId()) return true;
1351
4/8
✓ Branch 7 → 8 taken 7 times.
✗ Branch 7 → 13 not taken.
✓ Branch 9 → 10 taken 7 times.
✗ Branch 9 → 13 not taken.
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 7 times.
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 20 taken 7 times.
7 if (m_actionPending || m_compose.draftCleanupActive() || m_session.undo()->busy()) {
1352 ✗ m_statusState.error(tr("Wait for the current send or action to finish before changing accounts."));
1353 ✗ return false;
1354 }
1355
1/2
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 27 taken 7 times.
7 if (!m_session.accounts()->selectAccount(id)) {
1356 ✗ m_statusState.error(tr("The account could not be selected or its selection could not be saved."));
1357 ✗ return false;
1358 }
1359 7 m_compose.resetForAccountSwitch();
1360 7 m_workspace.clearHtmlConsent();
1361
1/1
✓ Branch 30 → 31 taken 7 times.
7 m_workspace.retainMailIdentities([this](const MailId &mail) { return acceptsIdentity(mail); });
1362 7 m_workspace.activateTab(0);
1363 7 return true;
1364 }
1365
1366 841 QString QuickMailBackend::accountEmail() const {
1367 841 const auto &account = m_session.accounts()->primary();
1368
2/2
✓ Branch 5 → 6 taken 833 times.
✓ Branch 5 → 8 taken 8 times.
841 return account ? account->email : QString{};
1369 }
1370
1371 234987 QString QuickMailBackend::accountName() const {
1372 234987 const auto &account = m_session.accounts()->primary();
1373
2/2
✓ Branch 5 → 6 taken 233816 times.
✓ Branch 5 → 8 taken 1171 times.
234987 return account ? account->name : QString{};
1374 }
1375
1376 42818 bool QuickMailBackend::configured() const { return m_session.accounts()->primary().has_value(); }
1377
1378 1277 QAbstractItemModel *QuickMailBackend::mailList() {
1379
2/2
✓ Branch 3 → 4 taken 204 times.
✓ Branch 3 → 5 taken 1073 times.
1277 return threadedPresentation() ? static_cast<QAbstractItemModel *>(&m_threadList)
1380 1277 : static_cast<QAbstractItemModel *>(&m_mail);
1381 }
1382
1383 57372 bool QuickMailBackend::threadedPresentation() const {
1384 57372 return m_threaded;
1385 }
1386
1387 846 void QuickMailBackend::setThreaded(bool on) {
1388
2/2
✓ Branch 2 → 3 taken 796 times.
✓ Branch 2 → 4 taken 50 times.
846 if (m_threaded == on) return;
1389 50 m_threaded = on;
1390
2/2
✓ Branch 4 → 5 taken 35 times.
✓ Branch 4 → 6 taken 15 times.
50 if (on) {
1391 35 syncThreadProjection();
1392 } else {
1393 15 m_session.controller()->setStreamedHeadersVisible(true);
1394 15 m_headerProjectionTimer.stop();
1395 15 m_headerProjectionFolder.clear();
1396 15 cancelThreadProjection();
1397 15 m_threadList.releaseHeldRows();
1398 15 m_threadList.leaveIndexedFolder();
1399 15 m_threadProjectionPending = false;
1400 15 clearThreadProjectionSlowFlag();
1401 15 m_session.threads()->clear();
1402 15 m_session.controller()->refreshFlatPageIfNeeded(true);
1403 }
1404 50 refreshSelectionScope();
1405 50 emit threadedChanged();
1406 // The identity-based selection state survives the model swap; the list view
1407 // re-derives its current row through rowForIdentity after the swap.
1408
5/5
✓ Branch 20 → 21 taken 35 times.
✓ Branch 20 → 22 taken 15 times.
✓ Branch 21 → 23 taken 35 times.
✓ Branch 22 → 23 taken 15 times.
✓ Branch 23 → 24 taken 50 times.
50 setStatus(on ? tr("Thread view enabled.") : tr("Thread view disabled."));
1409 }
1410
1411 28 void QuickMailBackend::armThreadProjectionSlowFlag() {
1412 // One timer belongs to the current pending interval. Repeated projection
1413 // changes cannot queue stale callbacks that mark a new folder slow early.
1414
3/6
✓ Branch 2 → 3 taken 28 times.
✗ Branch 2 → 6 not taken.
✓ Branch 4 → 5 taken 28 times.
✗ Branch 4 → 6 not taken.
✓ Branch 7 → 8 taken 28 times.
✗ Branch 7 → 9 not taken.
28 if (!m_threadProjectionSlow && !m_threadProjectionSlowTimer.isActive())
1415 28 m_threadProjectionSlowTimer.start(m_threadProjectionSlowThresholdMs);
1416 28 }
1417
1418 905 void QuickMailBackend::clearThreadProjectionSlowFlag() {
1419 905 m_threadProjectionSlowTimer.stop();
1420
2/2
✓ Branch 3 → 4 taken 903 times.
✓ Branch 3 → 5 taken 2 times.
905 if (!m_threadProjectionSlow) return;
1421 2 m_threadProjectionSlow = false;
1422 2 emit threadProjectionSlowChanged();
1423 }
1424
1425 1 void QuickMailBackend::setThreadProjectionSlowThresholdMs(int ms) {
1426 1 m_threadProjectionSlowThresholdMs = qMax(0, ms);
1427 1 }
1428
1429 2373 void QuickMailBackend::cancelThreadProjection() {
1430 2373 m_threadProjectionSlowTimer.stop();
1431 2373 m_threadList.cancelIndexedRequest();
1432 2373 ++m_threadProjectionGeneration;
1433 // S106-291: m_threadPool is the compose worker (send build, server-draft
1434 // build) since the S91 split; clear() here deleted a queued send without
1435 // running it. The projection is fenced by the generation alone.
1436 2373 }
1437
1438 16 void QuickMailBackend::retainThreadSnapshot(qint64 folderId, qint64 externalVersion) {
1439 16 const auto data = m_session.threads()->prepared();
1440
3/6
✓ Branch 5 → 6 taken 16 times.
✗ Branch 5 → 7 not taken.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 16 times.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 16 times.
16 if (!data || externalVersion < 0) return;
1441 16 const qint64 cost = qMax(qint64(1), (data->retainedBytes + 1023) / 1024);
1442
1/2
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 16 times.
16 if (cost > m_threadSnapshots.maxCost()) return;
1443
1/4
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 24 taken 16 times.
✗ Branch 34 → 35 not taken.
✗ Branch 34 → 37 not taken.
16 m_threadSnapshots.insert(folderId, new ThreadSnapshot{data, externalVersion,
1444
3/5
✓ Branch 16 → 17 taken 16 times.
✓ Branch 19 → 20 taken 16 times.
✓ Branch 20 → 21 taken 16 times.
✗ Branch 31 → 32 not taken.
✗ Branch 31 → 33 not taken.
16 m_session.cache()->uidValidity(folderId)}, int(cost));
1445 16 }
1446
1447 24 bool QuickMailBackend::restoreThreadSnapshot(qint64 folderId) {
1448 24 const auto *snapshot = m_threadSnapshots.object(folderId);
1449
2/2
✓ Branch 3 → 4 taken 20 times.
✓ Branch 3 → 5 taken 4 times.
24 if (!snapshot) return false;
1450
2/4
✓ Branch 7 → 8 taken 4 times.
✗ Branch 7 → 11 not taken.
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 16 taken 4 times.
8 if (snapshot->externalVersion != m_session.cache()->externalDataVersion() ||
1451
1/2
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 4 times.
4 snapshot->uidValidity != m_session.cache()->uidValidity(folderId)) {
1452 ✗ m_threadSnapshots.remove(folderId);
1453 ✗ return false;
1454 }
1455 4 cancelThreadProjection();
1456 4 m_threadProjectionPending = false;
1457 4 clearThreadProjectionSlowFlag();
1458
2/2
✓ Branch 22 → 23 taken 3 times.
✓ Branch 22 → 28 taken 1 time.
4 if (m_session.threads()->prepared() != snapshot->data)
1459
1/1
✓ Branch 25 → 26 taken 3 times.
3 m_session.threads()->setPrepared(snapshot->data);
1460 4 emit threadedChanged();
1461 4 return true;
1462 }
1463
1464 732 void QuickMailBackend::syncThreadProjection() {
1465
2/2
✓ Branch 3 → 4 taken 682 times.
✓ Branch 3 → 5 taken 50 times.
766 if (!threadedPresentation()) return;
1466
1/1
✓ Branch 5 → 6 taken 50 times.
50 const auto folderId = folderIdForPath(m_folderPath);
1467
2/2
✓ Branch 6 → 7 taken 5 times.
✓ Branch 6 → 14 taken 45 times.
50 if (folderId < 0) {
1468
1/1
✓ Branch 7 → 8 taken 5 times.
5 cancelThreadProjection();
1469
1/1
✓ Branch 8 → 9 taken 5 times.
5 m_threadList.leaveIndexedFolder();
1470 5 m_threadProjectionPending = false;
1471
1/1
✓ Branch 9 → 10 taken 5 times.
5 clearThreadProjectionSlowFlag();
1472
1/1
✓ Branch 11 → 12 taken 5 times.
5 m_session.threads()->clear();
1473
1/1
✓ Branch 12 → 13 taken 5 times.
5 emit threadedChanged();
1474 5 return;
1475 }
1476
1/1
✓ Branch 15 → 16 taken 45 times.
45 const auto state = m_session.cache()->folderPresentationState(folderId);
1477
6/6
✓ Branch 17 → 18 taken 34 times.
✓ Branch 17 → 19 taken 11 times.
✓ Branch 18 → 19 taken 15 times.
✓ Branch 18 → 20 taken 19 times.
✓ Branch 21 → 22 taken 26 times.
✓ Branch 21 → 46 taken 19 times.
45 if (m_threadList.indexed() || state.headerCount > 200) {
1478 26 m_session.controller()->setStreamedHeadersVisible(false);
1479 26 const auto selected = m_workspace.mailId();
1480
3/3
✓ Branch 32 → 33 taken 15 times.
✓ Branch 32 → 35 taken 11 times.
✓ Branch 38 → 39 taken 26 times.
67 m_threadList.configureIndexedFolder(m_session.cache()->databasePath(), folderId,
1481 26 state.uidValidity, state.threadRevision,
1482
2/6
✓ Branch 33 → 34 taken 15 times.
✗ Branch 33 → 35 not taken.
✓ Branch 40 → 41 taken 26 times.
✗ Branch 40 → 42 not taken.
✗ Branch 69 → 70 not taken.
✗ Branch 69 → 71 not taken.
67 selected.accountId == accountName() && selected.folderId == folderId ? selected.uid : 0,
1483
1/1
✓ Branch 29 → 30 taken 26 times.
52 m_session.cache()->journal().pendingMoveUids(accountName(), folderId));
1484 26 return;
1485 26 }
1486 19 m_session.controller()->setStreamedHeadersVisible(true);
1487
2/2
✓ Branch 48 → 49 taken 1 time.
✓ Branch 48 → 50 taken 18 times.
19 if (m_waitingCachedPage) { m_threadProjectionPending = true; return; }
1488
3/3
✓ Branch 50 → 51 taken 18 times.
✓ Branch 51 → 52 taken 2 times.
✓ Branch 51 → 53 taken 16 times.
18 if (restoreThreadSnapshot(folderId)) return;
1489
1/1
✓ Branch 53 → 54 taken 16 times.
16 cancelThreadProjection();
1490 16 m_threadProjectionPending = false;
1491
1/1
✓ Branch 54 → 55 taken 16 times.
16 clearThreadProjectionSlowFlag();
1492
1/1
✓ Branch 58 → 59 taken 16 times.
16 m_session.threads()->setHeaders(m_session.headers()->allHeaders());
1493
2/2
✓ Branch 60 → 61 taken 16 times.
✓ Branch 61 → 62 taken 16 times.
16 retainThreadSnapshot(folderId, m_session.cache()->externalDataVersion());
1494
1/1
✓ Branch 62 → 63 taken 16 times.
16 emit threadedChanged();
1495 }
1496
1497 1 void QuickMailBackend::queueIndexedMoveExclusionRefresh(qint64 folderId) {
1498 // A burst of Move journal transitions in one event turn refreshes the
1499 // exclusion set once. The visible row was already hidden by the caller.
1500 // The read is one small indexed journal query; WAL readers never wait
1501 // for another connection's write lock (mailjd-bench data.gui_read_*).
1502
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 1 time.
1 if (m_moveExclusionRefreshQueued.contains(folderId)) return;
1503
1/1
✓ Branch 5 → 6 taken 1 time.
1 m_moveExclusionRefreshQueued.insert(folderId);
1504 1 const auto generation = m_session.accounts()->generation();
1505
1/1
✓ Branch 8 → 9 taken 1 time.
1 QTimer::singleShot(0, this, [this, folderId, generation] {
1506
1/1
✓ Branch 2 → 3 taken 1 time.
1 m_moveExclusionRefreshQueued.remove(folderId);
1507 1 if (generation != m_session.accounts()->generation() ||
1508
4/8
✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 10 not taken.
✓ Branch 7 → 8 taken 1 time.
✗ Branch 7 → 10 not taken.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 1 time.
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 1 time.
1 !m_threadList.indexed() || m_threadList.indexedFolder() != folderId) return;
1509 1 bool ok = false;
1510 auto exclusions =
1511
1/1
✓ Branch 17 → 18 taken 1 time.
1 m_session.cache()->journal().pendingMoveUids(accountName(), folderId, &ok);
1512
1/1
✓ Branch 20 → 21 taken 1 time.
1 exclusions.unite(m_visualMoveUids.value(folderId));
1513
1/2
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 28 taken 1 time.
1 if (!ok) {
1514 // Keep the conservative visible exclusion; release a waiting page.
1515 ✗ m_threadList.abandonIndexedMoveRefresh();
1516 ✗ setStatus(tr("Folder move state could not be refreshed"));
1517 ✗ return;
1518 }
1519
1/1
✓ Branch 28 → 29 taken 1 time.
1 m_threadList.refreshIndexedExclusions(exclusions);
1520
1/2
✓ Branch 31 → 32 taken 1 time.
✗ Branch 31 → 34 not taken.
1 });
1521 }
1522
1523 11530 int QuickMailBackend::activeRowCount() const {
1524
4/4
✓ Branch 3 → 4 taken 1007 times.
✓ Branch 3 → 7 taken 10523 times.
✓ Branch 5 → 6 taken 1007 times.
✓ Branch 8 → 9 taken 10523 times.
11530 return threadedPresentation() ? m_threadList.rowCount() : m_mail.rowCount();
1525 }
1526
1527 663 QVariantMap QuickMailBackend::activeMailAt(int row) const {
1528
2/2
✓ Branch 3 → 4 taken 27 times.
✓ Branch 3 → 5 taken 636 times.
663 return threadedPresentation() ? m_threadList.mailAt(row) : m_mail.mailAt(row);
1529 }
1530
1531 17641 int QuickMailBackend::activeRowForIdentity(const QVariantMap &identity) const {
1532
2/2
✓ Branch 3 → 4 taken 983 times.
✓ Branch 3 → 6 taken 16658 times.
17641 return threadedPresentation() ? m_threadList.rowForIdentity(identity)
1533 17641 : m_mail.rowForIdentity(identity);
1534 }
1535
1536 5486 int QuickMailBackend::loadedRowForIdentity(const MailId &id) const {
1537
4/8
✓ Branch 3 → 4 taken 5486 times.
✗ Branch 3 → 7 not taken.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 5486 times.
✓ Branch 9 → 10 taken 5486 times.
✗ Branch 9 → 11 not taken.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 5486 times.
5486 if (!id.isValid() || id.accountId != accountName()) return -1;
1538
2/2
✓ Branch 13 → 14 taken 5486 times.
✓ Branch 14 → 15 taken 5486 times.
5486 return activeRowForIdentity(WorkspaceState::encode(id));
1539 }
1540
1541 12190 MailSelectionKey QuickMailBackend::activeKeyAt(int row) const {
1542
2/2
✓ Branch 3 → 4 taken 1656 times.
✓ Branch 3 → 5 taken 10534 times.
12190 return threadedPresentation() ? m_threadList.keyAt(row) : m_mail.keyAt(row);
1543 }
1544
1545 // F-41 (SPRINT-104): the identities the active list visibly shows between
1546 // two rows — the range Shift+click must select, in the presented (sort or
1547 // thread) order. Empty when an endpoint is not currently presented (the
1548 // flat window slid past it) or the span exceeds the explicit capture
1549 // bound; the caller then falls back to the date/UID range.
1550 6 QList<qint64> QuickMailBackend::visibleRangeBetween(const MailSelectionKey &anchor,
1551 const MailSelectionKey &key) const {
1552 6 QList<qint64> uids;
1553
4/8
✓ Branch 3 → 4 taken 6 times.
✗ Branch 3 → 7 not taken.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 6 times.
✓ Branch 9 → 10 taken 6 times.
✗ Branch 9 → 11 not taken.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 6 times.
6 if (m_workspace.activeTab() != 0 || m_workspace.area() != "mail")
1554 ✗ return uids;
1555
1/1
✓ Branch 13 → 14 taken 6 times.
6 const int count = activeRowCount();
1556 6 int anchorRow = -1, keyRow = -1;
1557
5/6
✓ Branch 26 → 27 taken 253 times.
✗ Branch 26 → 29 not taken.
✓ Branch 27 → 15 taken 220 times.
✓ Branch 27 → 28 taken 33 times.
✓ Branch 28 → 15 taken 27 times.
✓ Branch 28 → 29 taken 6 times.
253 for (int row = 0; row < count && (anchorRow < 0 || keyRow < 0); ++row) {
1558
1/1
✓ Branch 15 → 16 taken 247 times.
247 const auto rowKey = activeKeyAt(row);
1559
5/6
✓ Branch 16 → 17 taken 220 times.
✓ Branch 16 → 20 taken 27 times.
✓ Branch 17 → 18 taken 220 times.
✗ Branch 17 → 20 not taken.
✓ Branch 18 → 19 taken 6 times.
✓ Branch 18 → 20 taken 214 times.
247 if (anchorRow < 0 && rowKey.folderId == anchor.folderId && rowKey.uid == anchor.uid)
1560 6 anchorRow = row;
1561
4/6
✓ Branch 20 → 21 taken 247 times.
✗ Branch 20 → 24 not taken.
✓ Branch 21 → 22 taken 247 times.
✗ Branch 21 → 24 not taken.
✓ Branch 22 → 23 taken 6 times.
✓ Branch 22 → 24 taken 241 times.
247 if (keyRow < 0 && rowKey.folderId == key.folderId && rowKey.uid == key.uid)
1562 6 keyRow = row;
1563 247 }
1564
2/4
✓ Branch 29 → 30 taken 6 times.
✗ Branch 29 → 31 not taken.
✗ Branch 30 → 31 not taken.
✓ Branch 30 → 32 taken 6 times.
6 if (anchorRow < 0 || keyRow < 0) return uids;
1565 6 const int from = qMin(anchorRow, keyRow), to = qMax(anchorRow, keyRow);
1566
1/2
✗ Branch 34 → 35 not taken.
✓ Branch 34 → 36 taken 6 times.
6 if (to - from + 1 > MailSelectionQuery::MaximumExplicitUids) return uids;
1567
1/1
✓ Branch 36 → 37 taken 6 times.
6 uids.reserve(to - from + 1);
1568
2/2
✓ Branch 42 → 38 taken 33 times.
✓ Branch 42 → 43 taken 6 times.
39 for (int row = from; row <= to; ++row)
1569
2/2
✓ Branch 38 → 39 taken 33 times.
✓ Branch 39 → 40 taken 33 times.
33 uids.append(activeKeyAt(row).uid);
1570 6 return uids;
1571 ✗ }
1572
1573 9951 bool QuickMailBackend::acceptsIdentity(const MailId &id) const {
1574
10/10
✓ Branch 3 → 4 taken 9911 times.
✓ Branch 3 → 9 taken 40 times.
✓ Branch 5 → 6 taken 5493 times.
✓ Branch 5 → 9 taken 4418 times.
✓ Branch 8 → 9 taken 25 times.
✓ Branch 8 → 10 taken 5468 times.
✓ Branch 11 → 12 taken 5493 times.
✓ Branch 11 → 13 taken 4458 times.
✓ Branch 13 → 14 taken 4483 times.
✓ Branch 13 → 15 taken 5468 times.
9951 if (!configured() || !id.isValid() || id.accountId != accountName()) return false;
1575 // P90.17: folder admission answers from the in-memory identity cache —
1576 // no per-call SQLite round trips on the click/selection paths. Only the
1577 // canonical header-existence probe (one indexed lookup) remains for
1578 // identities without loaded-row evidence.
1579 5468 const auto path = m_folderPathById.constFind(id.folderId);
1580
2/2
✓ Branch 18 → 19 taken 7 times.
✓ Branch 18 → 20 taken 5461 times.
5468 if (path == m_folderPathById.constEnd()) return false;
1581
2/2
✓ Branch 22 → 23 taken 5453 times.
✓ Branch 22 → 26 taken 8 times.
10914 if (!m_availableFolders.contains(path.value()) ||
1582
3/4
✗ Branch 25 → 26 not taken.
✓ Branch 25 → 27 taken 5453 times.
✓ Branch 28 → 29 taken 8 times.
✓ Branch 28 → 30 taken 5453 times.
10914 m_folderIdByPath.value(path.value(), -1) != id.folderId) return false;
1583 // The active projection is already scoped to the current account and
1584 // exposes this exact identity. Do not re-read its header on every click.
1585
3/3
✓ Branch 30 → 31 taken 5453 times.
✓ Branch 31 → 32 taken 4477 times.
✓ Branch 31 → 33 taken 976 times.
5453 if (loadedRowForIdentity(id) >= 0) return true;
1586 // Restored selections and notifications can target a row outside the
1587 // loaded page; retain the canonical existence check for those paths.
1588
1/1
✓ Branch 34 → 35 taken 976 times.
976 return m_session.cache()->header(id.folderId, id.uid).has_value();
1589 }
1590
1591 2406 void QuickMailBackend::publishSelection() {
1592
2/2
✓ Branch 2 → 3 taken 92 times.
✓ Branch 2 → 4 taken 2314 times.
2406 if (m_reconciling) return;
1593 2314 const auto id = m_workspace.mailId();
1594
4/4
✓ Branch 6 → 7 taken 2314 times.
✓ Branch 7 → 8 taken 1245 times.
✓ Branch 7 → 9 taken 1069 times.
✓ Branch 8 → 11 taken 1245 times.
2314 if (acceptsIdentity(id)) requestSelectedMail(id);
1595
1/1
✓ Branch 10 → 11 taken 1069 times.
1069 else m_session.controller()->clearDisplaySelection();
1596 2314 }
1597
1598 1767 void QuickMailBackend::requestSelectedMail(const MailId &id) {
1599 1767 const MailHeader *preview = threadedPresentation()
1600
2/2
✓ Branch 3 → 4 taken 77 times.
✓ Branch 3 → 6 taken 1690 times.
1767 ? m_threadList.indexedHeader(id.folderId, id.uid) : nullptr;
1601
2/2
✓ Branch 7 → 8 taken 50 times.
✓ Branch 7 → 13 taken 1717 times.
1767 if (preview) {
1602 // Selection tracing may publish signals before the controller copies the
1603 // hint. Keep the indexed page's storage out of that reentrant interval.
1604 50 const MailHeader stable = *preview;
1605
1/1
✓ Branch 10 → 11 taken 50 times.
50 m_session.controller()->onMailSelected(id, stable);
1606 50 } else {
1607 1717 m_session.controller()->onMailSelected(id);
1608 }
1609 1767 }
1610
1611 1218 void QuickMailBackend::setStatus(const QString &status) {
1612 1218 m_statusState.feedback(status);
1613 1218 }
1614
1615 793 void QuickMailBackend::loadLocalFolders() {
1616
2/2
✓ Branch 3 → 4 taken 8 times.
✓ Branch 3 → 12 taken 785 times.
793 if (!configured()) {
1617
1/1
✓ Branch 5 → 6 taken 8 times.
8 applyFolderList({}, false);
1618
2/2
✓ Branch 7 → 8 taken 8 times.
✓ Branch 8 → 9 taken 8 times.
8 setStatus(tr("No accounts configured"));
1619
1/1
✓ Branch 10 → 11 taken 8 times.
8 emit folderChanged();
1620 12 return;
1621 }
1622 785 const auto &account = *m_session.accounts()->primary();
1623
3/3
✓ Branch 15 → 16 taken 785 times.
✓ Branch 16 → 17 taken 785 times.
✓ Branch 17 → 18 taken 785 times.
785 const QString snapshotPath = QDir(m_profileDirectory).filePath("folder-session.json");
1624
1/1
✓ Branch 20 → 21 taken 785 times.
785 const auto snapshot = FolderSessionSnapshot::load(snapshotPath, account);
1625 785 QList<FolderInfo> folders;
1626
2/2
✓ Branch 22 → 23 taken 9 times.
✓ Branch 22 → 25 taken 776 times.
785 if (snapshot) folders = *snapshot;
1627
3/3
✓ Branch 25 → 26 taken 776 times.
✓ Branch 26 → 27 taken 772 times.
✓ Branch 26 → 42 taken 4 times.
776 else if (!QFile::exists(snapshotPath)) {
1628
5/5
✓ Branch 28 → 29 taken 772 times.
✓ Branch 29 → 30 taken 772 times.
✓ Branch 30 → 31 taken 772 times.
✓ Branch 39 → 32 taken 1831 times.
✓ Branch 39 → 40 taken 772 times.
2603 for (const auto &path : m_session.cache()->allFolderPaths(account.name))
1629 2603 folders.append({ImapResponseParser::decodeMailboxName(path), path, {}, {}});
1630 }
1631 // A rejected snapshot is not permission to reuse a different server's cache.
1632
1/1
✓ Branch 42 → 43 taken 785 times.
785 applyFolderList(folders, false);
1633
7/7
✓ Branch 44 → 45 taken 776 times.
✓ Branch 44 → 48 taken 9 times.
✓ Branch 45 → 46 taken 776 times.
✓ Branch 46 → 47 taken 4 times.
✓ Branch 46 → 48 taken 772 times.
✓ Branch 49 → 50 taken 4 times.
✓ Branch 49 → 54 taken 781 times.
785 if (!snapshot && QFile::exists(snapshotPath)) {
1634
2/2
✓ Branch 50 → 51 taken 4 times.
✓ Branch 51 → 52 taken 4 times.
4 setStatus(tr("Local folder state is unavailable or belongs to another account. Connect to refresh folders."));
1635 4 return;
1636 }
1637
5/5
✓ Branch 56 → 57 taken 2 times.
✓ Branch 56 → 58 taken 779 times.
✓ Branch 57 → 59 taken 2 times.
✓ Branch 58 → 59 taken 779 times.
✓ Branch 59 → 60 taken 781 times.
781 setStatus(m_session.accounts()->networkEnabled() ? tr("Loading account…")
1638 : tr("Offline · Local mail"));
1639 2624 }
1640
1641 826 void QuickMailBackend::rebuildFolderIdentityCache() {
1642 826 m_folderIdByPath.clear();
1643 826 m_folderPathById.clear();
1644 826 m_folderEpochById.clear();
1645
2/2
✓ Branch 6 → 7 taken 8 times.
✓ Branch 6 → 8 taken 818 times.
826 if (!configured()) return;
1646
5/5
✓ Branch 10 → 11 taken 818 times.
✓ Branch 12 → 13 taken 818 times.
✓ Branch 13 → 14 taken 818 times.
✓ Branch 21 → 15 taken 2136 times.
✓ Branch 21 → 22 taken 818 times.
2954 for (const auto &identity : m_session.cache()->folderIdentities(accountName())) {
1647
1/1
✓ Branch 16 → 17 taken 2136 times.
2136 m_folderIdByPath.insert(identity.path, identity.id);
1648
1/1
✓ Branch 17 → 18 taken 2136 times.
2136 m_folderPathById.insert(identity.id, identity.path);
1649
1/1
✓ Branch 18 → 19 taken 2136 times.
2136 m_folderEpochById.insert(identity.id, identity.uidValidity);
1650 818 }
1651 }
1652
1653 1886 qint64 QuickMailBackend::folderIdForPath(const QString &path) const {
1654
5/6
✓ Branch 3 → 4 taken 1667 times.
✓ Branch 3 → 6 taken 219 times.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 1667 times.
✓ Branch 8 → 9 taken 219 times.
✓ Branch 8 → 10 taken 1667 times.
1886 if (path.isEmpty() || !configured()) return -1;
1655 // The map was loaded for the active account. The controller may still
1656 // retain its previous folder ID during an account handover.
1657 1667 const auto known = m_folderIdByPath.constFind(path);
1658
2/2
✓ Branch 13 → 14 taken 1665 times.
✓ Branch 13 → 16 taken 2 times.
1667 if (known != m_folderIdByPath.cend()) return known.value();
1659 // A folder from a fresh server list may not have been ensured yet. Keep
1660 // that cold-path lookup; known folders use the in-memory identity map.
1661
1/1
✓ Branch 18 → 19 taken 2 times.
2 return m_session.cache()->findFolderId(accountName(), path);
1662 }
1663
1664 // Sprint 95: what the rule backend reads from the mail session.
1665 795 QuickRulesBackend::Deps QuickMailBackend::rulesDeps() {
1666 795 QuickRulesBackend::Deps deps;
1667 15877 deps.account = [this]() -> std::optional<AccountConfig> { return m_session.accounts()->primary(); };
1668 795 deps.configuration = [this](const QString &id) { return m_session.accounts()->configuration(id); };
1669 1621 deps.imapCapabilities = [this] {
1670
1/2
✓ Branch 3 → 4 taken 31 times.
✗ Branch 3 → 6 not taken.
31 return m_session.imap() ? m_session.imap()->serverCapabilities() : QStringList{};
1671 795 };
1672 1744 deps.folders = [this] { return m_folderInfos; };
1673 5731 deps.decodeMailbox = [](const QString &path) { return ImapResponseParser::decodeMailboxName(path); };
1674 795 deps.profileDirectory = m_profileDirectory;
1675 259 const auto headerFields = [this](qint64 folderId, qint64 uid) -> QMap<QString, QString> {
1676
1/1
✓ Branch 3 → 4 taken 259 times.
259 const auto snapshot = m_session.cache()->replySnapshot(folderId, uid);
1677
3/6
✓ Branch 5 → 6 taken 259 times.
✗ Branch 5 → 9 not taken.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 259 times.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 259 times.
259 if (!snapshot || snapshot->rawSource.isEmpty()) return {};
1678 259 QMap<QString, QString> fields;
1679
1/1
✓ Branch 14 → 15 taken 259 times.
259 const auto raw = MimeParser::messageHeaders(snapshot->rawSource);
1680
4/4
✓ Branch 15 → 16 taken 259 times.
✓ Branch 25 → 26 taken 1817 times.
✓ Branch 27 → 17 taken 1558 times.
✓ Branch 27 → 28 taken 259 times.
1817 for (auto it = raw.cbegin(); it != raw.cend(); ++it)
1681
3/3
✓ Branch 18 → 19 taken 1558 times.
✓ Branch 20 → 21 taken 1558 times.
✓ Branch 21 → 22 taken 1558 times.
1558 fields.insert(it.key().toLower(), ImapResponseParser::decodeRfc2047(it.value()));
1682 259 return fields;
1683 259 };
1684 795 deps.cachedHeaderFields = headerFields;
1685 798 deps.suggestionsEnabled = [this] { return m_preferences.ruleSuggestions(); };
1686 1816 deps.displayedMail = [this, headerFields] {
1687 226 QuickRulesBackend::Deps::DisplayedMail mail;
1688 226 const MailId id = m_workspace.mailId();
1689
3/3
✓ Branch 4 → 5 taken 226 times.
✓ Branch 5 → 6 taken 17 times.
✓ Branch 5 → 7 taken 209 times.
226 if (!acceptsIdentity(id)) return mail;
1690 209 mail.folderId = id.folderId;
1691 209 mail.uid = id.uid;
1692 209 mail.folderPath = m_folderPathById.value(id.folderId, m_folderPath);
1693
1/1
✓ Branch 10 → 11 taken 209 times.
209 mail.headers = headerFields(id.folderId, id.uid);
1694
2/3
✓ Branch 13 → 14 taken 209 times.
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 77 taken 209 times.
209 if (mail.headers.isEmpty())
1695 ✗ for (const auto &header : m_session.cache()->headersByIds({id})) {
1696 ✗ mail.headers.insert(QStringLiteral("from"), header.from);
1697 ✗ mail.headers.insert(QStringLiteral("to"), header.to);
1698 ✗ mail.headers.insert(QStringLiteral("subject"), header.subject);
1699 // S95.1.11: the list chip without the raw source.
1700 ✗ if (header.recipientsKnown) {
1701 ✗ if (!header.cc.isEmpty()) mail.headers.insert(QStringLiteral("cc"), header.cc);
1702 ✗ if (!header.listId.isEmpty()) mail.headers.insert(QStringLiteral("list-id"), QLatin1Char('<') + header.listId + QLatin1Char('>'));
1703 }
1704 ✗ }
1705
1/1
✓ Branch 77 → 78 taken 209 times.
209 mail.predictedFolder = visiblePrediction(id).first;
1706 209 return mail;
1707
0/5
✗ Branch 17 → 18 not taken.
✗ Branch 91 → 92 not taken.
✗ Branch 91 → 95 not taken.
✗ Branch 93 → 94 not taken.
✗ Branch 93 → 95 not taken.
1021 };
1708 1615 deps.cachedHeaders = [this](const QString &path) {
1709 // G16: only the columns a rule reads (MailRuleCandidates), not headers().
1710 25 const qint64 folderId = folderIdForPath(path);
1711
1/2
✓ Branch 3 → 4 taken 25 times.
✗ Branch 3 → 7 not taken.
25 return folderId > 0 ? MailRuleCandidates::read(m_session.cache()->database(), folderId) : QList<MailHeader>{};
1712 795 };
1713 1591 deps.moveMails = [this](const QString &source, const QList<qint64> &uids, const QString &target) {
1714 1 m_session.controller()->moves()->moveMailsToFolderFrom(uids, folderIdForPath(source), source, target);
1715 795 };
1716 1590 deps.flagMail = [this](const QString &source, qint64 uid, const QString &kind, const QString &flag) {
1717 ✗ const qint64 folderId = folderIdForPath(source);
1718 ✗ const bool add = kind == QLatin1String("add");
1719 ✗ if (flag == QLatin1String("\\Seen")) {
1720 ✗ if (add) m_session.controller()->markMailAsSeenInFolder(uid, folderId);
1721 ✗ else m_session.controller()->markMailAsUnseenInFolder(uid, folderId);
1722 ✗ } else if (flag == QLatin1String("\\Flagged")) {
1723 ✗ m_session.controller()->setStarredInFolder(uid, folderId, add);
1724 ✗ } else if (!flag.startsWith(QLatin1Char('\\'))) {
1725 ✗ if (add) m_session.controller()->addLabelInFolder(uid, folderId, flag);
1726 ✗ else m_session.controller()->removeLabelInFolder(uid, folderId, flag);
1727 }
1728 795 };
1729 795 return deps;
1730 }
1731
1732 826 void QuickMailBackend::applyFolderList(const QList<FolderInfo> &folders, bool authoritative) {
1733 826 m_folderInfos = folders;
1734
2/2
✓ Branch 3 → 4 taken 33 times.
✓ Branch 3 → 5 taken 793 times.
826 if (authoritative)
1735
1/1
✓ Branch 4 → 5 taken 33 times.
33 m_rules.foldersReady();
1736 // F-49 (SPRINT-104): special folders resolve in three steps — the
1737 // user's assignment (settings, per account), the server's special-use
1738 // flag, and the common names matched on the LAST path component (a
1739 // prefixed "INBOX.Trash" or "INBOX/Gesendete Elemente" answers like the
1740 // bare name; the exact whole-path match of before missed them).
1741
1/1
✓ Branch 5 → 6 taken 826 times.
826 const QString delimiter = m_folderActions.folderDelimiter();
1742 4130 const auto resolve = [this, &folders, &delimiter](const char *settingKey,
1743 bool (FolderInfo::*flag)() const,
1744 std::initializer_list<const char *> names) {
1745
1/1
✓ Branch 3 → 4 taken 4130 times.
8260 return QuickFolderActions::resolveSpecialFolder(folders, delimiter, accountName(),
1746
1/1
✓ Branch 5 → 6 taken 4130 times.
12390 QLatin1String(settingKey), flag, names);
1747 826 };
1748
1/1
✓ Branch 6 → 7 taken 826 times.
826 m_junkFolder = resolve("junk", &FolderInfo::isJunk, {"Junk", "Spam", "Junk-E-Mail"});
1749
1/1
✓ Branch 9 → 10 taken 826 times.
826 m_archiveFolder = resolve("archive", &FolderInfo::isArchive, {"Archive", "Archiv"});
1750
1/1
✓ Branch 12 → 13 taken 826 times.
1652 m_trashFolder = resolve("trash", &FolderInfo::isTrash,
1751 826 {"Trash", "Deleted", "Deleted Items", "Papierkorb", "Gelöschte Objekte", "Gelöschte Elemente"});
1752
1/1
✓ Branch 15 → 16 taken 826 times.
826 m_draftsFolder = resolve("drafts", &FolderInfo::isDrafts, {"Drafts", "Entwürfe", "Draft Messages"});
1753
1/1
✓ Branch 18 → 19 taken 826 times.
1652 m_sentFolder = resolve("sent", &FolderInfo::isSent,
1754 826 {"Sent", "Gesendet", "Sent Items", "Gesendete Objekte", "Gesendete Elemente", "Sent Messages"});
1755
1/1
✓ Branch 21 → 22 taken 826 times.
826 invalidatePredictions();
1756 {
1757 // Reconciliation may change tabs and the list together. Publish only the
1758 // final identity, never an intermediate tab from a rejected account snapshot.
1759 826 QScopedValueRollback guard(m_reconciling, true);
1760
1/1
✓ Branch 25 → 26 taken 826 times.
826 const auto account = m_session.accounts()->primary();
1761
1/1
✓ Branch 26 → 27 taken 826 times.
826 const auto hidden = FolderPreferences::loadHidden(m_profileDirectory);
1762
3/3
✓ Branch 28 → 29 taken 818 times.
✓ Branch 28 → 30 taken 8 times.
✓ Branch 31 → 32 taken 826 times.
826 m_folders.setFolders(account ? folders : QList<FolderInfo>{}, hidden);
1763
1/1
✓ Branch 33 → 34 taken 826 times.
826 m_folderActions.refreshFolderAppearance();
1764 826 QStringList selectable;
1765
4/4
✓ Branch 35 → 36 taken 818 times.
✓ Branch 35 → 46 taken 8 times.
✓ Branch 44 → 38 taken 2338 times.
✓ Branch 44 → 45 taken 818 times.
3164 if (account) for (const auto &folder : folders)
1766
4/4
✓ Branch 39 → 40 taken 2338 times.
✓ Branch 40 → 41 taken 2326 times.
✓ Branch 40 → 42 taken 12 times.
✓ Branch 41 → 42 taken 2326 times.
2338 if (!folder.isNoselect()) selectable.append(folder.path);
1767
3/3
✓ Branch 46 → 47 taken 826 times.
✓ Branch 47 → 48 taken 826 times.
✓ Branch 48 → 49 taken 826 times.
826 m_availableFolders = QSet<QString>(selectable.begin(), selectable.end());
1768
1/1
✓ Branch 51 → 52 taken 826 times.
826 rebuildFolderIdentityCache();
1769
1/1
✓ Branch 53 → 54 taken 826 times.
826 const auto subscribed = FolderPreferences::accountSubscriptions(m_profileDirectory, accountName(), selectable);
1770
3/3
✓ Branch 57 → 58 taken 8 times.
✓ Branch 57 → 59 taken 818 times.
✓ Branch 60 → 61 taken 826 times.
826 m_session.controller()->setSubscribedFolders(!account ? QStringList{} : subscribed);
1771
2/2
✓ Branch 63 → 64 taken 818 times.
✓ Branch 63 → 98 taken 8 times.
826 if (account) {
1772
1/1
✓ Branch 66 → 67 taken 818 times.
818 const auto badges = m_session.cache()->loadAllBadges(account->name);
1773
4/4
✓ Branch 67 → 68 taken 818 times.
✓ Branch 73 → 74 taken 838 times.
✓ Branch 75 → 69 taken 20 times.
✓ Branch 75 → 76 taken 818 times.
838 for (auto it = badges.cbegin(); it != badges.cend(); ++it)
1774
1/1
✓ Branch 71 → 72 taken 20 times.
20 m_folders.setUnread(it.key(), it.value());
1775
2/2
✓ Branch 76 → 77 taken 33 times.
✓ Branch 76 → 96 taken 785 times.
818 if (authoritative) {
1776
2/2
✓ Branch 77 → 78 taken 33 times.
✓ Branch 79 → 80 taken 33 times.
33 m_workspace.retainFolderBranches(account->name, m_folders.branchPaths());
1777
1/1
✓ Branch 82 → 83 taken 33 times.
33 m_session.controller()->reconcileAvailableFolders(selectable);
1778
5/6
✓ Branch 84 → 85 taken 33 times.
✓ Branch 85 → 86 taken 33 times.
✓ Branch 86 → 87 taken 33 times.
✓ Branch 87 → 88 taken 33 times.
✗ Branch 91 → 92 not taken.
✓ Branch 91 → 96 taken 33 times.
33 if (!FolderSessionSnapshot::save(QDir(m_profileDirectory).filePath("folder-session.json"),
1779 *account, folders))
1780 ✗ setStatus(tr("Could not save local folder state"));
1781 }
1782 818 }
1783 // Runtime reconciliation must retain ephemeral consent for surviving tabs;
1784 // serializing/restoring a session deliberately discards that consent.
1785
1/1
✓ Branch 99 → 100 taken 826 times.
848 m_workspace.retainMailIdentities([this](const MailId &id) { return acceptsIdentity(id); });
1786 const auto mainIdentity = WorkspaceState::decode(
1787
4/4
✓ Branch 102 → 103 taken 826 times.
✓ Branch 103 → 104 taken 826 times.
✓ Branch 104 → 105 taken 826 times.
✓ Branch 105 → 106 taken 826 times.
826 m_workspace.data(m_workspace.index(0), WorkspaceState::IdentityRole).toMap());
1788
1/1
✓ Branch 109 → 110 taken 826 times.
826 const auto mainPath = m_session.cache()->folderPath(mainIdentity.folderId);
1789 826 const auto previousPath = m_folderPath;
1790
3/3
✓ Branch 111 → 112 taken 826 times.
✓ Branch 112 → 113 taken 801 times.
✓ Branch 112 → 120 taken 25 times.
826 if (!m_folders.isSelectable(m_folderPath))
1791
4/4
✓ Branch 113 → 114 taken 801 times.
✓ Branch 114 → 115 taken 4 times.
✓ Branch 114 → 116 taken 797 times.
✓ Branch 116 → 117 taken 797 times.
801 m_folderPath = m_folders.isSelectable(mainPath) ? mainPath : m_folders.firstSelectable();
1792
2/2
✓ Branch 121 → 122 taken 197 times.
✓ Branch 121 → 127 taken 629 times.
826 if (m_folderPath.isEmpty()) {
1793
1/1
✓ Branch 123 → 124 taken 197 times.
197 m_session.headers()->clear();
1794
1/1
✓ Branch 125 → 126 taken 197 times.
197 m_session.threads()->clear();
1795
1/1
✓ Branch 126 → 150 taken 197 times.
197 emit folderChanged();
1796
4/6
✓ Branch 127 → 128 taken 597 times.
✓ Branch 127 → 130 taken 32 times.
✓ Branch 129 → 130 taken 597 times.
✗ Branch 129 → 131 not taken.
✓ Branch 132 → 133 taken 629 times.
✗ Branch 132 → 150 not taken.
629 } else if (authoritative || m_folderPath != previousPath) {
1797
5/6
✓ Branch 134 → 135 taken 6 times.
✓ Branch 134 → 138 taken 623 times.
✓ Branch 136 → 137 taken 6 times.
✗ Branch 136 → 138 not taken.
✓ Branch 139 → 140 taken 6 times.
✓ Branch 139 → 142 taken 623 times.
629 if (mainIdentity.isValid() && mainPath == m_folderPath)
1798
1/1
✓ Branch 141 → 142 taken 6 times.
6 m_session.controller()->setRestoreAnchor(m_folderPath, mainIdentity.uid);
1799
2/2
✓ Branch 143 → 144 taken 604 times.
✓ Branch 143 → 149 taken 25 times.
629 if (m_folderPath != previousPath) {
1800
1/1
✓ Branch 145 → 146 taken 604 times.
604 m_mail.filter({}, false);
1801
1/1
✓ Branch 147 → 148 taken 604 times.
604 m_threadList.clearFilters();
1802
1/1
✓ Branch 148 → 149 taken 604 times.
604 emit folderChanged();
1803 }
1804
1/1
✓ Branch 149 → 150 taken 629 times.
629 presentCachedFolder(m_folderPath);
1805 }
1806 826 }
1807
1/1
✓ Branch 158 → 159 taken 826 times.
826 reconcileSelection();
1808
1/1
✓ Branch 159 → 160 taken 826 times.
826 publishSelection();
1809 826 }
1810
1811 6327 QString QuickMailBackend::specialFolder(const QString &role) const {
1812
7/7
✓ Branch 4 → 5 taken 1804 times.
✓ Branch 4 → 20 taken 4523 times.
✓ Branch 8 → 9 taken 1804 times.
✓ Branch 9 → 10 taken 1789 times.
✓ Branch 9 → 13 taken 15 times.
✓ Branch 15 → 16 taken 1789 times.
✓ Branch 15 → 17 taken 15 times.
9920 if (role == QLatin1String("inbox")) return m_folders.isSelectable(QStringLiteral("INBOX")) ? QStringLiteral("INBOX") : QString();
1813
2/2
✓ Branch 22 → 23 taken 1195 times.
✓ Branch 22 → 24 taken 3328 times.
4523 if (role == QLatin1String("sent")) return m_sentFolder;
1814
2/2
✓ Branch 26 → 27 taken 1186 times.
✓ Branch 26 → 28 taken 2142 times.
3328 if (role == QLatin1String("drafts")) return m_draftsFolder;
1815
2/2
✓ Branch 30 → 31 taken 1172 times.
✓ Branch 30 → 32 taken 970 times.
2142 if (role == QLatin1String("archive")) return m_archiveFolder;
1816
1/2
✓ Branch 34 → 35 taken 970 times.
✗ Branch 34 → 36 not taken.
970 if (role == QLatin1String("junk")) return m_junkFolder;
1817 ✗ return {};
1818 }
1819
1820 298 bool QuickMailBackend::selectFolder(const QString &pathArgument) {
1821 // Copy: callers may pass a backend member by reference (folder.previous
1822 // passes m_previousFolder), and the assignments below would otherwise
1823 // mutate the argument mid-function and re-select the previous folder.
1824 298 const QString path = pathArgument;
1825
7/7
✓ Branch 4 → 5 taken 296 times.
✓ Branch 4 → 7 taken 2 times.
✓ Branch 5 → 6 taken 296 times.
✓ Branch 6 → 7 taken 7 times.
✓ Branch 6 → 8 taken 289 times.
✓ Branch 9 → 10 taken 9 times.
✓ Branch 9 → 11 taken 289 times.
298 if (!configured() || !m_folders.isSelectable(path)) return false;
1826
1/1
✓ Branch 11 → 12 taken 289 times.
289 cancelSearchPreview();
1827 289 clearActionContinuity();
1828
2/4
✓ Branch 17 → 18 taken 289 times.
✗ Branch 17 → 22 not taken.
✓ Branch 19 → 20 taken 289 times.
✗ Branch 19 → 22 not taken.
867 const bool flatFilterActive = !m_mail.filterText().isEmpty() || m_mail.showUnreadOnly() ||
1829
3/6
✓ Branch 15 → 16 taken 289 times.
✗ Branch 15 → 22 not taken.
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 23 taken 289 times.
✓ Branch 24 → 25 taken 289 times.
✗ Branch 24 → 26 not taken.
867 m_mail.showStarredOnly() || m_mail.showWithAttachments();
1830
1/2
✓ Branch 30 → 31 taken 289 times.
✗ Branch 30 → 35 not taken.
578 const bool threadFilterActive = !m_threadList.filterText().isEmpty() ||
1831
3/6
✓ Branch 28 → 29 taken 289 times.
✗ Branch 28 → 35 not taken.
✓ Branch 32 → 33 taken 289 times.
✗ Branch 32 → 35 not taken.
✗ Branch 34 → 35 not taken.
✓ Branch 34 → 36 taken 289 times.
867 m_threadList.showUnreadOnly() || m_threadList.showStarredOnly() ||
1832
1/2
✓ Branch 37 → 38 taken 289 times.
✗ Branch 37 → 39 not taken.
578 m_threadList.showWithAttachments();
1833 // The settled main folder already owns its cached page and controller
1834 // generation. Clear local filters in place; re-entering the folder would
1835 // also clear the reader and start an unrelated cached-page read.
1836
2/2
✓ Branch 44 → 45 taken 194 times.
✓ Branch 44 → 61 taken 3 times.
197 if (path == m_folderPath &&
1837
3/6
✓ Branch 46 → 47 taken 194 times.
✗ Branch 46 → 61 not taken.
✓ Branch 66 → 67 taken 177 times.
✓ Branch 66 → 75 taken 112 times.
✗ Branch 113 → 114 not taken.
✗ Branch 113 → 115 not taken.
680 m_workspace.area() == QLatin1String("mail") &&
1838
2/2
✓ Branch 47 → 48 taken 181 times.
✓ Branch 47 → 61 taken 13 times.
388 m_workspace.activeTab() == 0 && !m_waitingCachedPage &&
1839
3/4
✓ Branch 48 → 49 taken 180 times.
✓ Branch 48 → 61 taken 1 time.
✓ Branch 49 → 50 taken 180 times.
✗ Branch 49 → 61 not taken.
181 !m_threadProjectionPending && !m_threadProjectionFailed &&
1840
2/4
✓ Branch 51 → 52 taken 180 times.
✗ Branch 51 → 61 not taken.
✓ Branch 55 → 56 taken 180 times.
✗ Branch 55 → 61 not taken.
360 !m_selection.pending() &&
1841
6/8
✓ Branch 40 → 41 taken 197 times.
✓ Branch 40 → 61 taken 92 times.
✓ Branch 62 → 63 taken 180 times.
✓ Branch 62 → 64 taken 109 times.
✓ Branch 64 → 65 taken 197 times.
✓ Branch 64 → 66 taken 92 times.
✗ Branch 109 → 110 not taken.
✗ Branch 109 → 111 not taken.
666 m_session.controller()->currentFolder() == path &&
1842
5/7
✓ Branch 56 → 57 taken 180 times.
✓ Branch 57 → 58 taken 3 times.
✓ Branch 57 → 60 taken 177 times.
✓ Branch 58 → 59 taken 3 times.
✗ Branch 58 → 60 not taken.
✗ Branch 59 → 60 not taken.
✓ Branch 59 → 61 taken 3 times.
180 (activeRowCount() > 0 || flatFilterActive || threadFilterActive)) {
1843
2/4
✓ Branch 67 → 68 taken 177 times.
✗ Branch 67 → 69 not taken.
✗ Branch 68 → 69 not taken.
✓ Branch 68 → 74 taken 177 times.
177 if (flatFilterActive || threadFilterActive) {
1844 ✗ m_mail.filter({}, false);
1845 ✗ m_threadList.clearFilters();
1846 ✗ reconcileSelection();
1847 }
1848 177 return true;
1849 }
1850 {
1851 112 QScopedValueRollback guard(m_reconciling, true);
1852
2/2
✓ Branch 77 → 78 taken 92 times.
✓ Branch 77 → 79 taken 20 times.
112 if (path != m_folderPath) m_previousFolder = m_folderPath;
1853
2/2
✓ Branch 80 → 81 taken 92 times.
✓ Branch 80 → 83 taken 20 times.
112 if (path != m_folderPath)
1854
2/2
✓ Branch 81 → 82 taken 92 times.
✓ Branch 82 → 83 taken 92 times.
92 MailJD::Performance::ThreadSelectionPerformanceTracker::instance().folderSwitched();
1855 112 m_folderPath = path;
1856
2/2
✓ Branch 84 → 85 taken 106 times.
✓ Branch 84 → 88 taken 6 times.
112 if (!m_folderSwitchInProgress) {
1857 // The list rebuilds only its visible delegates in this frame; the
1858 // cache buffer refills on the next event-loop turn.
1859 106 m_folderSwitchInProgress = true;
1860
1/1
✓ Branch 85 → 86 taken 106 times.
106 emit folderSwitchInProgressChanged();
1861
1/1
✓ Branch 86 → 87 taken 106 times.
106 QTimer::singleShot(0, this, [this] {
1862 88 m_folderSwitchInProgress = false;
1863 88 emit folderSwitchInProgressChanged();
1864 88 });
1865 }
1866
1/1
✓ Branch 91 → 92 taken 112 times.
224 m_workspace.setArea(QStringLiteral("mail"));
1867
1/1
✓ Branch 94 → 95 taken 112 times.
112 m_workspace.activateTab(0);
1868
1/1
✓ Branch 95 → 96 taken 112 times.
112 m_workspace.select({});
1869
1/1
✓ Branch 98 → 99 taken 112 times.
112 m_mail.filter({}, false);
1870
1/1
✓ Branch 100 → 101 taken 112 times.
112 m_threadList.clearFilters();
1871
1/1
✓ Branch 101 → 102 taken 112 times.
112 emit folderChanged();
1872
1/1
✓ Branch 102 → 103 taken 112 times.
112 presentCachedFolder(path);
1873 112 }
1874
1/1
✓ Branch 104 → 105 taken 112 times.
112 reconcileSelection();
1875 112 return true;
1876 298 }
1877
1878 2 bool QuickMailBackend::markFolderRead(const QString &path) {
1879 // Folder context menu (F3.7): reuse the Widgets controller path
1880 // (FolderTree::markAllReadRequested -> MailController::markFolderAllSeen).
1881
3/6
✓ Branch 3 → 4 taken 2 times.
✗ Branch 3 → 6 not taken.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 2 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 2 times.
2 if (!configured() || !m_folders.isSelectable(path)) return false;
1882 2 m_session.controller()->markFolderAllSeen(path);
1883 2 return true;
1884 }
1885
1886 768 void QuickMailBackend::restoreWorkspaceSelection() {
1887 768 const auto selected = m_workspace.mailId();
1888
3/3
✓ Branch 4 → 5 taken 768 times.
✓ Branch 5 → 6 taken 763 times.
✓ Branch 5 → 7 taken 5 times.
768 if (!acceptsIdentity(selected)) return;
1889
2/2
✓ Branch 8 → 9 taken 4 times.
✓ Branch 8 → 20 taken 1 time.
5 if (m_workspace.activeTab() == 0) {
1890
1/1
✓ Branch 10 → 11 taken 4 times.
4 const auto path = m_session.cache()->folderPath(selected.folderId);
1891
2/3
✓ Branch 11 → 12 taken 4 times.
✓ Branch 12 → 13 taken 4 times.
✗ Branch 12 → 18 not taken.
4 if (m_folders.isSelectable(path)) {
1892 4 m_folderPath = path;
1893
1/1
✓ Branch 14 → 15 taken 4 times.
4 emit folderChanged();
1894
1/1
✓ Branch 16 → 17 taken 4 times.
4 m_session.controller()->setRestoreAnchor(path, selected.uid);
1895
1/1
✓ Branch 17 → 18 taken 4 times.
4 presentCachedFolder(path);
1896 }
1897 4 }
1898
1/1
✓ Branch 20 → 21 taken 5 times.
5 requestSelectedMail(selected);
1899 768 }
1900
1901 45 void QuickMailBackend::reserveActionContinuity(int lastRemovedVisualRow) {
1902 45 m_actionAnchor = m_workspace.mailId();
1903 45 m_actionVisualRow = lastRemovedVisualRow;
1904 45 m_actionSuccessor = MailId{};
1905
6/12
✓ Branch 7 → 8 taken 45 times.
✗ Branch 7 → 14 not taken.
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 45 times.
✓ Branch 16 → 17 taken 45 times.
✗ Branch 16 → 18 not taken.
✓ Branch 18 → 19 taken 45 times.
✗ Branch 18 → 20 not taken.
✓ Branch 20 → 21 taken 45 times.
✗ Branch 20 → 22 not taken.
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 26 taken 45 times.
90 if (m_workspace.activeTab() != 0 || m_workspace.area() != QStringLiteral("mail")) {
1906 ✗ m_actionAnchor = MailId{}; m_actionVisualRow = -1;
1907 ✗ return;
1908 }
1909 45 const int count = activeRowCount();
1910
4/8
✓ Branch 27 → 28 taken 45 times.
✗ Branch 27 → 31 not taken.
✓ Branch 28 → 29 taken 45 times.
✗ Branch 28 → 31 not taken.
✗ Branch 30 → 31 not taken.
✓ Branch 30 → 32 taken 45 times.
✗ Branch 33 → 34 not taken.
✓ Branch 33 → 37 taken 45 times.
45 if (count <= 0 || lastRemovedVisualRow < 0 || !m_actionAnchor.isValid()) {
1911 ✗ m_actionAnchor = MailId{}; m_actionVisualRow = -1;
1912 ✗ return;
1913 }
1914 // Prefer the mail that takes over the freed visual slot; at the list end
1915 // fall back to the predecessor. Identity wins over the row number later.
1916 45 const int target = lastRemovedVisualRow + 1 < count
1917
2/2
✓ Branch 37 → 38 taken 43 times.
✓ Branch 37 → 39 taken 2 times.
45 ? lastRemovedVisualRow + 1 : qMax(0, lastRemovedVisualRow - 1);
1918
5/5
✓ Branch 41 → 42 taken 45 times.
✓ Branch 43 → 44 taken 45 times.
✓ Branch 44 → 45 taken 45 times.
✓ Branch 45 → 46 taken 45 times.
✓ Branch 46 → 47 taken 45 times.
45 m_actionSuccessor = WorkspaceState::decode(activeMailAt(target).value("identity").toMap());
1919 }
1920
1921 2713 void QuickMailBackend::clearActionContinuity() {
1922 2713 m_actionAnchor = MailId{};
1923 2713 m_actionSuccessor = MailId{};
1924 2713 m_actionVisualRow = -1;
1925 2713 }
1926
1927 3400 void QuickMailBackend::reconcileSelection() {
1928 // An action that removed the selected mail reserved its successor (P90.16).
1929 // The exclusion refresh it starts is a background page load with the rows
1930 // still mounted; the successor is taken from them at once instead of after
1931 // that page (which takes long in huge folders: 0.76 s at 1M mails).
1932
3/4
✓ Branch 2 → 3 taken 49 times.
✓ Branch 2 → 6 taken 3351 times.
✓ Branch 4 → 5 taken 49 times.
✗ Branch 4 → 6 not taken.
3400 const bool actionWindow = m_actionVisualRow >= 0 && m_actionAnchor.isValid();
1933
1/2
✓ Branch 10 → 11 taken 71 times.
✗ Branch 10 → 18 not taken.
3471 const bool rowsMountedDuringRefresh = m_threadList.indexed() && threadedPresentation() &&
1934
5/6
✓ Branch 8 → 9 taken 71 times.
✓ Branch 8 → 18 taken 3329 times.
✓ Branch 12 → 13 taken 48 times.
✓ Branch 12 → 18 taken 23 times.
✓ Branch 14 → 15 taken 48 times.
✗ Branch 14 → 18 not taken.
3519 !m_threadList.projectionBlocking() && !m_threadList.holdingPreviousFolder() &&
1935
2/3
✓ Branch 15 → 16 taken 48 times.
✓ Branch 16 → 17 taken 48 times.
✗ Branch 16 → 18 not taken.
48 activeRowCount() > 0;
1936
2/2
✓ Branch 20 → 21 taken 2064 times.
✓ Branch 20 → 34 taken 1333 times.
3397 if (m_reconciling || m_waitingCachedPage ||
1937
7/8
✓ Branch 21 → 22 taken 17 times.
✓ Branch 21 → 24 taken 2047 times.
✓ Branch 22 → 23 taken 2 times.
✓ Branch 22 → 34 taken 15 times.
✓ Branch 23 → 24 taken 2 times.
✗ Branch 23 → 34 not taken.
✓ Branch 25 → 26 taken 1932 times.
✓ Branch 25 → 34 taken 117 times.
4113 (m_threadProjectionPending && !(actionWindow && rowsMountedDuringRefresh)) ||
1938
12/12
✓ Branch 19 → 20 taken 3397 times.
✓ Branch 19 → 34 taken 3 times.
✓ Branch 31 → 32 taken 1640 times.
✓ Branch 31 → 34 taken 292 times.
✓ Branch 36 → 37 taken 1932 times.
✓ Branch 36 → 38 taken 1468 times.
✓ Branch 38 → 39 taken 1932 times.
✓ Branch 38 → 40 taken 1468 times.
✓ Branch 40 → 41 taken 1932 times.
✓ Branch 40 → 42 taken 1468 times.
✓ Branch 42 → 43 taken 1769 times.
✓ Branch 42 → 63 taken 1631 times.
10486 m_workspace.activeTab() != 0 || m_workspace.area() != QStringLiteral("mail") ||
1939
2/2
✓ Branch 33 → 34 taken 9 times.
✓ Branch 33 → 35 taken 1631 times.
1640 !configured()) {
1940
7/7
✓ Branch 43 → 44 taken 1769 times.
✓ Branch 47 → 48 taken 1769 times.
✓ Branch 48 → 49 taken 1769 times.
✓ Branch 49 → 50 taken 1769 times.
✓ Branch 50 → 51 taken 1769 times.
✓ Branch 61 → 45 taken 1769 times.
✓ Branch 61 → 62 taken 1769 times.
3538 qCDebug(lcQuickBackend) << "reconcile deferred: reconciling" << m_reconciling << "waitingCachedPage"
1941
3/3
✓ Branch 51 → 52 taken 1769 times.
✓ Branch 52 → 53 taken 1769 times.
✓ Branch 53 → 54 taken 1769 times.
1769 << m_waitingCachedPage << "projectionPending" << m_threadProjectionPending
1942
4/4
✓ Branch 54 → 55 taken 1769 times.
✓ Branch 55 → 56 taken 1769 times.
✓ Branch 56 → 57 taken 1769 times.
✓ Branch 57 → 58 taken 1769 times.
1769 << "actionWindow" << actionWindow << "mounted" << rowsMountedDuringRefresh;
1943 1780 return;
1944 }
1945 1631 const auto selected = m_workspace.mailId();
1946 // P90.16: a reserved action token whose anchor is still selected marks
1947 // exactly the removal window: the selection points at a mail that was just
1948 // moved away, so "same indexed folder" must not keep it authoritative.
1949
3/4
✓ Branch 65 → 66 taken 49 times.
✓ Branch 65 → 73 taken 1582 times.
✓ Branch 67 → 68 taken 49 times.
✗ Branch 67 → 73 not taken.
1680 const bool actionRemovedSelection = m_actionVisualRow >= 0 && m_actionAnchor.isValid() &&
1950
3/4
✓ Branch 69 → 70 taken 49 times.
✗ Branch 69 → 72 not taken.
✓ Branch 71 → 72 taken 13 times.
✓ Branch 71 → 73 taken 36 times.
49 (!selected.isValid() || selected == m_actionAnchor);
1951 // The flat filter is a page read (MailController::setFlatFilter): its page
1952 // holds only the matching rows, so the selected mail of the open folder is
1953 // absent from the header model without being deleted.
1954
3/4
✓ Branch 76 → 77 taken 52 times.
✓ Branch 76 → 81 taken 1579 times.
✓ Branch 79 → 80 taken 52 times.
✗ Branch 79 → 81 not taken.
1683 const bool hiddenByFlatFilter = m_session.controller()->flatFilterActive() &&
1955 52 selected.folderId == m_session.controller()->currentFolderId();
1956
5/5
✓ Branch 82 → 83 taken 1618 times.
✓ Branch 82 → 97 taken 13 times.
✓ Branch 83 → 84 taken 1618 times.
✓ Branch 84 → 85 taken 521 times.
✓ Branch 84 → 97 taken 1097 times.
2152 if (!actionRemovedSelection && acceptsIdentity(selected) &&
1957
8/10
✓ Branch 85 → 86 taken 521 times.
✓ Branch 86 → 87 taken 521 times.
✓ Branch 87 → 88 taken 48 times.
✓ Branch 87 → 96 taken 473 times.
✓ Branch 98 → 99 taken 521 times.
✓ Branch 98 → 100 taken 1110 times.
✓ Branch 100 → 101 taken 517 times.
✓ Branch 100 → 103 taken 1114 times.
✗ Branch 174 → 175 not taken.
✗ Branch 174 → 176 not taken.
2152 (activeRowForIdentity(WorkspaceState::encode(selected)) >= 0 ||
1958
6/7
✓ Branch 89 → 90 taken 48 times.
✓ Branch 90 → 91 taken 43 times.
✓ Branch 90 → 96 taken 5 times.
✓ Branch 91 → 92 taken 4 times.
✓ Branch 91 → 96 taken 39 times.
✗ Branch 93 → 94 not taken.
✓ Branch 93 → 97 taken 4 times.
52 m_session.headers()->rowForUid(selected.uid, selected.folderId) >= 0 || hiddenByFlatFilter ||
1959
0/2
✗ Branch 95 → 96 not taken.
✗ Branch 95 → 97 not taken.
4 (m_threadList.indexed() && selected.folderId == m_threadList.indexedFolder()))) {
1960 // A queued source reset / first-page completion may arrive after a user
1961 // filters the proxy. Hidden is not deleted: keep the current reader and
1962 // never implicitly select/read the first surviving filtered result.
1963
1/1
✓ Branch 101 → 102 taken 517 times.
517 requestSelectedMail(selected);
1964 // Continuity is already established by a valid selection; a stale token
1965 // from an older action must never redirect a later reconcile.
1966 517 clearActionContinuity();
1967
3/3
✓ Branch 103 → 104 taken 1114 times.
✓ Branch 104 → 105 taken 596 times.
✓ Branch 104 → 158 taken 518 times.
1114 } else if (activeRowCount() > 0) {
1968 // P90.16: an action-removal token reserves the surviving neighbour. It
1969 // applies only while the workspace still points at the removed mail (or
1970 // was cleared by exactly that removal); row 0 stays a last resort for
1971 // genuinely anchor-less cases (fresh folder, filter, search handoff).
1972
2/4
✓ Branch 107 → 108 taken 11 times.
✗ Branch 107 → 116 not taken.
✓ Branch 110 → 111 taken 11 times.
✗ Branch 110 → 116 not taken.
22 if (m_actionVisualRow >= 0 && m_actionAnchor.isValid() &&
1973
6/6
✓ Branch 105 → 106 taken 11 times.
✓ Branch 105 → 116 taken 585 times.
✓ Branch 117 → 118 taken 11 times.
✓ Branch 117 → 119 taken 585 times.
✓ Branch 119 → 120 taken 11 times.
✓ Branch 119 → 145 taken 585 times.
618 m_actionAnchor.accountId == accountName() &&
1974
2/4
✓ Branch 112 → 113 taken 11 times.
✗ Branch 112 → 115 not taken.
✓ Branch 114 → 115 taken 11 times.
✗ Branch 114 → 116 not taken.
11 (!selected.isValid() || selected == m_actionAnchor)) {
1975 11 int row = m_actionSuccessor.isValid()
1976
4/8
✓ Branch 121 → 122 taken 11 times.
✗ Branch 121 → 125 not taken.
✓ Branch 122 → 123 taken 11 times.
✓ Branch 123 → 124 taken 11 times.
✓ Branch 126 → 127 taken 11 times.
✗ Branch 126 → 128 not taken.
✗ Branch 178 → 179 not taken.
✗ Branch 178 → 180 not taken.
11 ? activeRowForIdentity(WorkspaceState::encode(m_actionSuccessor)) : -1;
1977
1/4
✗ Branch 128 → 129 not taken.
✓ Branch 128 → 132 taken 11 times.
✗ Branch 129 → 130 not taken.
✗ Branch 130 → 131 not taken.
11 if (row < 0) row = qBound(0, m_actionVisualRow, activeRowCount() - 1);
1978 11 clearActionContinuity();
1979
5/5
✓ Branch 133 → 134 taken 11 times.
✓ Branch 135 → 136 taken 11 times.
✓ Branch 136 → 137 taken 11 times.
✓ Branch 137 → 138 taken 11 times.
✓ Branch 138 → 139 taken 11 times.
11 m_workspace.selectIdentity(activeMailAt(row).value("identity").toMap());
1980 11 return;
1981 }
1982 585 clearActionContinuity();
1983
5/5
✓ Branch 146 → 147 taken 585 times.
✓ Branch 148 → 149 taken 585 times.
✓ Branch 149 → 150 taken 585 times.
✓ Branch 150 → 151 taken 585 times.
✓ Branch 151 → 152 taken 585 times.
585 m_workspace.selectIdentity(activeMailAt(0).value("identity").toMap());
1984 } else {
1985 518 clearActionContinuity();
1986
1/1
✓ Branch 159 → 160 taken 518 times.
518 m_workspace.select({});
1987 }
1988 1631 }
1989
1990 745 void QuickMailBackend::presentCachedFolder(const QString &path) {
1991 // A queued model-reset callback can run before the foreground first page.
1992 // An empty transient model is not evidence that a restored mail disappeared.
1993
1/1
✓ Branch 2 → 3 taken 745 times.
745 m_statusState.reset();
1994
1/1
✓ Branch 3 → 4 taken 745 times.
745 m_headerProjectionTimer.stop();
1995 745 m_headerProjectionFolder.clear();
1996 745 m_threadProjectionFailed = false;
1997 745 m_waitingCachedPage = true;
1998
6/6
✓ Branch 5 → 6 taken 745 times.
✓ Branch 9 → 10 taken 745 times.
✓ Branch 10 → 11 taken 745 times.
✓ Branch 11 → 12 taken 745 times.
✓ Branch 15 → 7 taken 745 times.
✓ Branch 15 → 16 taken 745 times.
1490 qCDebug(lcQuickBackend) << "waiting for cached page of" << path;
1999
1/1
✓ Branch 16 → 17 taken 745 times.
745 cancelThreadProjection();
2000 745 m_threadProjectionPending = m_threaded;
2001
2/2
✓ Branch 17 → 18 taken 14 times.
✓ Branch 17 → 21 taken 731 times.
745 if (m_threadProjectionPending) {
2002 // Keep the previous folder's rows until this folder has content (no
2003 // empty list frame); a slow folder drops them for its loading state.
2004
1/1
✓ Branch 18 → 19 taken 14 times.
14 m_threadList.holdRowsForFolderSwitch();
2005 14 const auto hold = ++m_folderHoldGeneration;
2006
1/1
✓ Branch 19 → 20 taken 14 times.
14 QTimer::singleShot(m_threadProjectionSlowThresholdMs, this, [this, hold] {
2007
2/2
✓ Branch 2 → 3 taken 4 times.
✓ Branch 2 → 4 taken 2 times.
6 if (hold == m_folderHoldGeneration) m_threadList.releaseHeldRows();
2008 6 });
2009 }
2010
1/1
✓ Branch 21 → 22 taken 745 times.
745 const qint64 targetFolder = folderIdForPath(path);
2011
1/2
✓ Branch 23 → 24 taken 14 times.
✗ Branch 23 → 27 not taken.
14 const auto state = m_threadProjectionPending && targetFolder >= 0
2012
3/3
✓ Branch 22 → 23 taken 14 times.
✓ Branch 22 → 27 taken 731 times.
✓ Branch 25 → 26 taken 14 times.
759 ? m_session.cache()->folderPresentationState(targetFolder)
2013 745 : MailCache::FolderPresentationState{};
2014
3/4
✓ Branch 28 → 29 taken 14 times.
✓ Branch 28 → 32 taken 731 times.
✓ Branch 29 → 30 taken 14 times.
✗ Branch 29 → 32 not taken.
759 const bool indexedFolder = m_threadProjectionPending && targetFolder >= 0 &&
2015
2/2
✓ Branch 30 → 31 taken 8 times.
✓ Branch 30 → 32 taken 6 times.
14 state.headerCount > 200;
2016
2/2
✓ Branch 33 → 34 taken 8 times.
✓ Branch 33 → 35 taken 737 times.
745 const qint64 targetRevision = indexedFolder ? state.threadRevision : -1;
2017
2/2
✓ Branch 36 → 37 taken 8 times.
✓ Branch 36 → 38 taken 737 times.
745 const quint32 targetEpoch = indexedFolder ? state.uidValidity : 0;
2018 745 m_session.controller()->setStreamedHeadersVisible(!indexedFolder);
2019
2/2
✓ Branch 41 → 42 taken 8 times.
✓ Branch 41 → 67 taken 737 times.
745 if (indexedFolder) {
2020 8 const auto selected = m_workspace.mailId();
2021
2/3
✗ Branch 50 → 51 not taken.
✓ Branch 50 → 53 taken 8 times.
✓ Branch 56 → 57 taken 8 times.
8 m_threadList.configureIndexedFolder(m_session.cache()->databasePath(), targetFolder,
2022 targetEpoch, targetRevision,
2023
1/6
✗ Branch 51 → 52 not taken.
✗ Branch 51 → 53 not taken.
✓ Branch 58 → 59 taken 8 times.
✗ Branch 58 → 60 not taken.
✗ Branch 126 → 127 not taken.
✗ Branch 126 → 128 not taken.
16 selected.accountId == accountName() && selected.folderId == targetFolder ? selected.uid : 0,
2024
1/1
✓ Branch 47 → 48 taken 8 times.
16 m_session.cache()->journal().pendingMoveUids(accountName(), targetFolder));
2025 8 m_threadProjectionPending = m_threadList.projectionPending();
2026
1/1
✓ Branch 64 → 65 taken 8 times.
8 m_session.threads()->clear();
2027 8 } else {
2028
1/1
✓ Branch 67 → 68 taken 737 times.
737 m_threadList.leaveIndexedFolder();
2029
8/8
✓ Branch 68 → 69 taken 6 times.
✓ Branch 68 → 71 taken 731 times.
✓ Branch 69 → 70 taken 6 times.
✓ Branch 70 → 71 taken 4 times.
✓ Branch 70 → 72 taken 2 times.
✓ Branch 73 → 74 taken 735 times.
✓ Branch 73 → 76 taken 2 times.
✓ Branch 75 → 76 taken 735 times.
737 if (!m_threadProjectionPending || !restoreThreadSnapshot(targetFolder)) m_session.threads()->clear();
2030 }
2031
1/1
✓ Branch 76 → 77 taken 745 times.
745 emit threadedChanged();
2032 // A revision-current published indexed page already owns the visible
2033 // headers. The controller needs only identity and folder totals to start
2034 // IMAP synchronization; its hidden flat page can load on demand later.
2035
2/4
✓ Branch 79 → 80 taken 8 times.
✗ Branch 79 → 90 not taken.
✓ Branch 81 → 82 taken 8 times.
✗ Branch 81 → 90 not taken.
16 const bool indexedPageReady = indexedFolder && m_threadList.indexed() &&
2036 8 m_threadList.indexedFolder() == targetFolder &&
2037
7/9
✓ Branch 77 → 78 taken 8 times.
✓ Branch 77 → 90 taken 737 times.
✓ Branch 83 → 84 taken 3 times.
✓ Branch 83 → 90 taken 5 times.
✓ Branch 85 → 86 taken 3 times.
✓ Branch 86 → 87 taken 3 times.
✗ Branch 86 → 90 not taken.
✓ Branch 88 → 89 taken 3 times.
✗ Branch 88 → 90 not taken.
756 !m_threadList.projectionPending() && m_threadList.rowCount() > 0 &&
2038 3 m_threadList.indexRevision() == targetRevision;
2039 745 std::optional<MailController::PublishedFolderSummary> published;
2040 748 if (indexedPageReady &&
2041
3/5
✓ Branch 94 → 95 taken 3 times.
✓ Branch 95 → 96 taken 3 times.
✗ Branch 95 → 102 not taken.
✓ Branch 97 → 98 taken 3 times.
✗ Branch 97 → 102 not taken.
6 m_threadList.indexedTotalCount() >= m_threadList.rowCount() &&
2042
5/6
✓ Branch 91 → 92 taken 3 times.
✓ Branch 91 → 102 taken 742 times.
✓ Branch 100 → 101 taken 3 times.
✗ Branch 100 → 102 not taken.
✓ Branch 103 → 104 taken 3 times.
✓ Branch 103 → 109 taken 742 times.
751 m_threadList.indexedUnreadCount() >= 0 &&
2043 3 m_threadList.indexedUnreadCount() <= m_threadList.indexedTotalCount()) {
2044 3 published = MailController::PublishedFolderSummary{
2045 targetFolder, targetEpoch,
2046 3 m_threadList.indexRevision(), m_threadList.indexedTotalCount(),
2047 3 m_threadList.indexedUnreadCount()};
2048 }
2049
1/1
✓ Branch 110 → 111 taken 745 times.
745 m_session.controller()->onFolderSelected(path, indexedPageReady, published, targetFolder);
2050
1/2
✗ Branch 113 → 114 not taken.
✓ Branch 113 → 116 taken 745 times.
745 if (m_session.controller()->currentFolderId() < 0) {
2051 ✗ m_waitingCachedPage = false;
2052 ✗ m_threadProjectionPending = false;
2053 ✗ clearThreadProjectionSlowFlag();
2054 ✗ emit threadedChanged();
2055 }
2056 745 }
2057
2058 4 QStringList QuickMailBackend::matchingFolders(const QString &prefix) const {
2059 4 return m_folders.matchingPaths(prefix);
2060 }
2061
2062 57 bool QuickMailBackend::moveSelectedMail(const QString &path) {
2063
5/6
✓ Branch 2 → 3 taken 55 times.
✓ Branch 2 → 6 taken 2 times.
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 55 times.
✓ Branch 8 → 9 taken 2 times.
✓ Branch 8 → 10 taken 55 times.
57 if (m_actionPending || m_session.undo()->busy()) return false;
2064
4/4
✓ Branch 10 → 11 taken 55 times.
✓ Branch 11 → 12 taken 55 times.
✓ Branch 12 → 13 taken 2 times.
✓ Branch 12 → 100 taken 53 times.
55 if (const auto groups = searchBulkGroups(); !groups.isEmpty()) {
2065 // S101.6.1: the marked results leave per source folder (one Undo each).
2066 // S106-293: every group is admitted before anything happens; when none
2067 // can move, the marks stay and nothing is announced.
2068 2 QList<std::pair<qint64, QString>> admitted;
2069
4/4
✓ Branch 13 → 14 taken 2 times.
✓ Branch 34 → 35 taken 4 times.
✓ Branch 36 → 15 taken 2 times.
✓ Branch 36 → 37 taken 2 times.
4 for (auto it = groups.cbegin(); it != groups.cend(); ++it) {
2070
1/1
✓ Branch 17 → 18 taken 2 times.
2 const QString source = m_session.cache()->folderPath(it.key());
2071
8/10
✓ Branch 19 → 20 taken 2 times.
✗ Branch 19 → 25 not taken.
✓ Branch 21 → 22 taken 2 times.
✗ Branch 21 → 25 not taken.
✓ Branch 22 → 23 taken 2 times.
✓ Branch 23 → 24 taken 1 time.
✓ Branch 23 → 25 taken 1 time.
✓ Branch 26 → 27 taken 1 time.
✓ Branch 26 → 32 taken 1 time.
✓ Branch 29 → 30 taken 1 time.
2 if (!source.isEmpty() && source != path && m_folders.isSelectable(path)) admitted.append({it.key(), source});
2072 2 }
2073
2/2
✓ Branch 38 → 39 taken 1 time.
✓ Branch 38 → 53 taken 1 time.
2 if (admitted.isEmpty()) {
2074
5/5
✓ Branch 39 → 40 taken 1 time.
✓ Branch 43 → 44 taken 1 time.
✓ Branch 44 → 45 taken 1 time.
✓ Branch 48 → 41 taken 1 time.
✓ Branch 48 → 49 taken 1 time.
2 qCInfo(lcQuickBackend) << "Search bulk move refused: no source folder can move to the target";
2075
2/2
✓ Branch 49 → 50 taken 1 time.
✓ Branch 50 → 51 taken 1 time.
1 setStatus(tr("The marked messages cannot be moved there."));
2076 1 return false;
2077 }
2078 1 int moved = 0;
2079
2/2
✓ Branch 73 → 56 taken 1 time.
✓ Branch 73 → 74 taken 1 time.
2 for (const auto &[folderId, source] : std::as_const(admitted)) {
2080
2/2
✓ Branch 62 → 63 taken 1 time.
✓ Branch 63 → 64 taken 1 time.
1 m_session.controller()->moves()->moveMailsToFolderFrom(groups.value(folderId), folderId, source, path);
2081
1/1
✓ Branch 67 → 68 taken 1 time.
1 moved += int(groups.value(folderId).size());
2082 }
2083
3/4
✓ Branch 75 → 76 taken 1 time.
✓ Branch 76 → 77 taken 1 time.
✗ Branch 76 → 78 not taken.
✓ Branch 77 → 78 taken 1 time.
1 if (MailSearchSession *search = m_searchTabs->active()) search->clearMarks();
2084
1/1
✓ Branch 78 → 79 taken 1 time.
1 emit selectionChanged();
2085
10/10
✓ Branch 79 → 80 taken 1 time.
✓ Branch 83 → 84 taken 1 time.
✓ Branch 84 → 85 taken 1 time.
✓ Branch 85 → 86 taken 1 time.
✓ Branch 86 → 87 taken 1 time.
✓ Branch 87 → 88 taken 1 time.
✓ Branch 88 → 89 taken 1 time.
✓ Branch 89 → 90 taken 1 time.
✓ Branch 93 → 81 taken 1 time.
✓ Branch 93 → 94 taken 1 time.
2 qCInfo(lcQuickBackend) << "Search bulk move:" << moved << "messages from" << groups.size() << "folders";
2086
2/2
✓ Branch 94 → 95 taken 1 time.
✓ Branch 95 → 96 taken 1 time.
1 setStatus(tr("Moving %n message(s)…", nullptr, moved));
2087 1 return moved > 0;
2088 57 }
2089
4/6
✓ Branch 105 → 107 taken 51 times.
✓ Branch 105 → 110 taken 2 times.
✗ Branch 108 → 109 not taken.
✓ Branch 108 → 110 taken 51 times.
✗ Branch 111 → 112 not taken.
✓ Branch 111 → 116 taken 53 times.
53 if (m_workspace.activeTab() == 0 && m_selection.pending()) {
2090 ✗ setStatus(tr("Wait for the selection to finish."));
2091 ✗ return false;
2092 }
2093
8/8
✓ Branch 117 → 118 taken 51 times.
✓ Branch 117 → 122 taken 2 times.
✓ Branch 120 → 121 taken 16 times.
✓ Branch 120 → 122 taken 35 times.
✓ Branch 123 → 124 taken 51 times.
✓ Branch 123 → 125 taken 2 times.
✓ Branch 125 → 126 taken 16 times.
✓ Branch 125 → 305 taken 37 times.
53 if (m_workspace.activeTab() == 0 && m_selection.snapshot()) {
2094 16 const auto selection = m_selection.snapshot();
2095
7/7
✓ Branch 127 → 128 taken 16 times.
✓ Branch 131 → 132 taken 16 times.
✓ Branch 132 → 133 taken 16 times.
✓ Branch 134 → 135 taken 16 times.
✓ Branch 135 → 136 taken 16 times.
✓ Branch 143 → 129 taken 16 times.
✓ Branch 143 → 144 taken 16 times.
32 qCDebug(lcQuickBackend) << "move via selection snapshot: count" << selection->count << "folder"
2096
2/2
✓ Branch 137 → 138 taken 16 times.
✓ Branch 139 → 140 taken 16 times.
16 << selection->folderId << selection->folderPath;
2097
4/9
✓ Branch 148 → 149 taken 16 times.
✗ Branch 148 → 163 not taken.
✓ Branch 149 → 150 taken 16 times.
✓ Branch 150 → 151 taken 16 times.
✗ Branch 150 → 163 not taken.
✗ Branch 169 → 170 not taken.
✓ Branch 169 → 171 taken 16 times.
✗ Branch 420 → 421 not taken.
✗ Branch 420 → 422 not taken.
32 if (m_workspace.area() != "mail" || !configured() || !m_folders.isSelectable(path) ||
2098
4/10
✓ Branch 154 → 155 taken 16 times.
✗ Branch 154 → 163 not taken.
✓ Branch 157 → 158 taken 16 times.
✗ Branch 157 → 163 not taken.
✓ Branch 160 → 161 taken 16 times.
✗ Branch 160 → 163 not taken.
✓ Branch 167 → 168 taken 16 times.
✗ Branch 167 → 169 not taken.
✗ Branch 416 → 417 not taken.
✗ Branch 416 → 418 not taken.
48 selection->accountId != accountName() || selection->folderPath != m_folderPath ||
2099
3/6
✓ Branch 146 → 147 taken 16 times.
✗ Branch 146 → 163 not taken.
✗ Branch 162 → 163 not taken.
✓ Branch 162 → 164 taken 16 times.
✓ Branch 165 → 166 taken 16 times.
✗ Branch 165 → 167 not taken.
48 path == selection->folderPath || selection->count <= 0) return false;
2100 16 const auto generation = m_session.accounts()->generation();
2101 16 const auto database = m_session.cache()->databasePath();
2102
1/1
✓ Branch 175 → 176 taken 16 times.
16 const QPointer<QuickMailBackend> owner(this);
2103 16 m_actionPending = true;
2104
1/1
✓ Branch 176 → 177 taken 16 times.
16 emit actionChanged();
2105
1/2
✗ Branch 178 → 179 not taken.
✓ Branch 178 → 180 taken 16 times.
16 if (!owner) return false;
2106 // P90.16: capture the visible selected range BEFORE queueing — the
2107 // successor is the first surviving row after the last selected visible
2108 // row (identity wins over row numbers later). The uids drive the
2109 // optimistic removal once the journal accepted the move.
2110 16 QList<qint64> visibleSelectedUids;
2111 16 int lastSelectedRow = -1;
2112
1/1
✓ Branch 180 → 181 taken 16 times.
16 const int rows = activeRowCount();
2113
2/2
✓ Branch 194 → 182 taken 2312 times.
✓ Branch 194 → 195 taken 16 times.
2328 for (int row = 0; row < rows; ++row) {
2114
1/1
✓ Branch 182 → 183 taken 2312 times.
2312 const auto key = activeKeyAt(row);
2115
5/5
✓ Branch 183 → 184 taken 2312 times.
✓ Branch 184 → 185 taken 237 times.
✓ Branch 184 → 188 taken 2075 times.
✓ Branch 189 → 190 taken 237 times.
✓ Branch 189 → 192 taken 2075 times.
2549 if (m_selection.contains(key.uid) &&
2116
1/2
✓ Branch 186 → 187 taken 237 times.
✗ Branch 186 → 188 not taken.
237 key.folderId == selection->folderId) {
2117
1/1
✓ Branch 190 → 191 taken 237 times.
237 visibleSelectedUids.append(key.uid);
2118 237 lastSelectedRow = row;
2119 }
2120 2312 }
2121 // No selected row is known in the viewport (the list has not followed
2122 // the selection yet, or it was scrolled away): the current mail — the
2123 // row the range was extended to — stands for the end of the range.
2124
2/2
✓ Branch 195 → 196 taken 3 times.
✓ Branch 195 → 211 taken 13 times.
16 if (lastSelectedRow < 0) {
2125 3 const MailId current = m_workspace.mailId();
2126
3/6
✓ Branch 199 → 200 taken 3 times.
✗ Branch 199 → 203 not taken.
✓ Branch 201 → 202 taken 3 times.
✗ Branch 201 → 203 not taken.
✓ Branch 204 → 205 taken 3 times.
✗ Branch 204 → 209 not taken.
3 if (current.isValid() && current.folderId == selection->folderId)
2127
2/2
✓ Branch 205 → 206 taken 3 times.
✓ Branch 206 → 207 taken 3 times.
3 lastSelectedRow = activeRowForIdentity(WorkspaceState::encode(current));
2128 3 }
2129 {
2130 16 MailId anchor = m_workspace.mailId();
2131 16 MailId successor;
2132 16 int visualRow = lastSelectedRow;
2133
4/4
✓ Branch 213 → 214 taken 14 times.
✓ Branch 213 → 229 taken 2 times.
✓ Branch 214 → 215 taken 8 times.
✓ Branch 214 → 229 taken 6 times.
16 if (lastSelectedRow >= 0 && lastSelectedRow + 1 < rows)
2134
1/1
✓ Branch 220 → 221 taken 8 times.
16 successor = WorkspaceState::decode(
2135
4/4
✓ Branch 215 → 216 taken 8 times.
✓ Branch 217 → 218 taken 8 times.
✓ Branch 218 → 219 taken 8 times.
✓ Branch 219 → 220 taken 8 times.
24 activeMailAt(lastSelectedRow + 1).value("identity").toMap());
2136
2/2
✓ Branch 229 → 230 taken 6 times.
✓ Branch 229 → 244 taken 2 times.
8 else if (lastSelectedRow > 0)
2137
1/1
✓ Branch 235 → 236 taken 6 times.
12 successor = WorkspaceState::decode(
2138
4/4
✓ Branch 230 → 231 taken 6 times.
✓ Branch 232 → 233 taken 6 times.
✓ Branch 233 → 234 taken 6 times.
✓ Branch 234 → 235 taken 6 times.
18 activeMailAt(lastSelectedRow - 1).value("identity").toMap());
2139
2/6
✗ Branch 245 → 246 not taken.
✓ Branch 245 → 249 taken 16 times.
✗ Branch 247 → 248 not taken.
✗ Branch 247 → 249 not taken.
✗ Branch 250 → 251 not taken.
✓ Branch 250 → 257 taken 16 times.
16 if (!anchor.isValid() && !visibleSelectedUids.isEmpty()) {
2140 ✗ const auto key = activeKeyAt(lastSelectedRow);
2141 ✗ anchor = MailId{accountName(), key.folderId, key.uid};
2142 ✗ }
2143
5/6
✓ Branch 258 → 259 taken 16 times.
✗ Branch 258 → 261 not taken.
✓ Branch 259 → 260 taken 14 times.
✓ Branch 259 → 261 taken 2 times.
✓ Branch 262 → 263 taken 14 times.
✓ Branch 262 → 266 taken 2 times.
16 if (anchor.isValid() && visualRow >= 0) {
2144 14 m_actionAnchor = anchor;
2145 14 m_actionSuccessor = successor;
2146 14 m_actionVisualRow = visualRow;
2147 }
2148 16 }
2149 16 const qint64 folderId = selection->folderId;
2150 {
2151 16 QList<MailId> moved;
2152
5/7
✓ Branch 269 → 270 taken 16 times.
✓ Branch 270 → 271 taken 16 times.
✓ Branch 274 → 275 taken 237 times.
✓ Branch 278 → 272 taken 237 times.
✓ Branch 278 → 279 taken 16 times.
✗ Branch 474 → 475 not taken.
✗ Branch 474 → 476 not taken.
253 for (const qint64 uid : visibleSelectedUids) moved.append(MailId{accountName(), folderId, uid});
2153
1/1
✓ Branch 279 → 280 taken 16 times.
16 reportManualMove(moved, path);
2154 16 }
2155
2/2
✓ Branch 282 → 283 taken 16 times.
✓ Branch 283 → 284 taken 16 times.
16 setStatus(tr("Queuing move of %n message(s)…", nullptr, selection->count));
2156
1/2
✗ Branch 286 → 287 not taken.
✓ Branch 286 → 288 taken 16 times.
16 if (!owner) return false;
2157
1/1
✓ Branch 295 → 296 taken 16 times.
32 m_session.controller()->moves()->moveSelection(selection, path,
2158
1/7
✓ Branch 293 → 294 taken 16 times.
✗ Branch 492 → 493 not taken.
✗ Branch 492 → 494 not taken.
✗ Branch 495 → 496 not taken.
✗ Branch 495 → 497 not taken.
✗ Branch 498 → 499 not taken.
✗ Branch 498 → 500 not taken.
32 [owner, database, generation, visibleSelectedUids, folderId](MailSelectionMoveResult result) {
2159
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 13 times.
14 if (!owner) return;
2160 // Accepted writes remain durable even after navigation/account changes;
2161 // only the matching account may adopt their undo and display feedback.
2162
3/4
✓ Branch 8 → 9 taken 12 times.
✓ Branch 8 → 15 taken 1 time.
✓ Branch 13 → 14 taken 12 times.
✗ Branch 13 → 15 not taken.
25 if (owner->m_session.accounts()->generation() == generation &&
2163
4/4
✓ Branch 16 → 17 taken 12 times.
✓ Branch 16 → 18 taken 1 time.
✓ Branch 18 → 19 taken 12 times.
✓ Branch 18 → 58 taken 1 time.
25 owner->m_session.cache()->databasePath() == database) {
2164
2/2
✓ Branch 19 → 20 taken 3 times.
✓ Branch 19 → 29 taken 9 times.
12 if (result.outcome != MailSelectionMoveResult::Outcome::Journaled) {
2165
1/1
✓ Branch 26 → 27 taken 3 times.
6 owner->m_statusState.error(result.outcome == MailSelectionMoveResult::Outcome::Conflict
2166
4/5
✓ Branch 21 → 22 taken 2 times.
✓ Branch 21 → 23 taken 1 time.
✓ Branch 22 → 26 taken 2 times.
✗ Branch 24 → 26 not taken.
✓ Branch 25 → 26 taken 1 time.
7 ? tr("A selected message already has a pending move. Nothing was queued.")
2167 1 : result.outcome == MailSelectionMoveResult::Outcome::Limited
2168
1/2
✗ Branch 23 → 24 not taken.
✓ Branch 23 → 25 taken 1 time.
1 ? tr("The complete selection exceeds action capacity. Nothing was queued.")
2169 : tr("The selection move could not be queued. Nothing was queued; retry."));
2170 } else {
2171 // P90.16: the journal owns the intent — remove the visible rows
2172 // optimistically (differential, no reset) and exclude the pending
2173 // moves from projection pages; the reservation token converges
2174 // the selection in the next reconcile.
2175 // Rows outside the viewport leave through the exclusion refresh,
2176 // which runs even when no selected row was visible.
2177
1/2
✓ Branch 29 → 30 taken 9 times.
✗ Branch 29 → 58 not taken.
9 if (folderId >= 0) {
2178
2/2
✓ Branch 31 → 32 taken 8 times.
✓ Branch 31 → 35 taken 1 time.
9 if (!visibleSelectedUids.isEmpty())
2179 8 owner->m_session.headers()->removeByUids(visibleSelectedUids, folderId);
2180 // S106-292: UIDs are per folder — the exclusions of the source
2181 // folder apply only while that folder is the indexed one.
2182
6/6
✓ Branch 37 → 38 taken 3 times.
✓ Branch 37 → 42 taken 6 times.
✓ Branch 40 → 41 taken 2 times.
✓ Branch 40 → 42 taken 1 time.
✓ Branch 43 → 44 taken 2 times.
✓ Branch 43 → 55 taken 7 times.
9 if (owner->m_threadList.indexed() && owner->m_threadList.indexedFolder() == folderId)
2183
1/1
✓ Branch 51 → 52 taken 2 times.
4 owner->m_threadList.refreshIndexedExclusions(
2184
1/1
✓ Branch 50 → 51 taken 2 times.
4 owner->m_session.cache()->journal().pendingMoveUids(owner->accountName(), folderId));
2185
1/1
✓ Branch 56 → 57 taken 9 times.
9 QTimer::singleShot(0, owner, &QuickMailBackend::reconcileSelection);
2186 }
2187 }
2188 }
2189
1/2
✓ Branch 59 → 60 taken 13 times.
✗ Branch 59 → 63 not taken.
13 if (owner) {
2190 13 owner->m_actionPending = false;
2191 13 emit owner->actionChanged();
2192 }
2193 });
2194 16 return true;
2195 16 }
2196
7/7
✓ Branch 306 → 307 taken 35 times.
✓ Branch 306 → 310 taken 2 times.
✓ Branch 307 → 308 taken 35 times.
✓ Branch 308 → 309 taken 2 times.
✓ Branch 308 → 310 taken 33 times.
✓ Branch 311 → 312 taken 2 times.
✓ Branch 311 → 313 taken 35 times.
37 if (m_workspace.activeTab() == 0 && m_selection.count() == 0) return false;
2197 35 const auto selected = m_workspace.mailId();
2198
7/8
✓ Branch 315 → 316 taken 35 times.
✓ Branch 316 → 317 taken 33 times.
✓ Branch 316 → 319 taken 2 times.
✓ Branch 317 → 318 taken 33 times.
✗ Branch 318 → 319 not taken.
✓ Branch 318 → 320 taken 33 times.
✓ Branch 321 → 322 taken 2 times.
✓ Branch 321 → 323 taken 33 times.
35 if (!acceptsIdentity(selected) || !m_folders.isSelectable(path))
2199 2 return false;
2200
2/3
✓ Branch 323 → 324 taken 33 times.
✗ Branch 324 → 325 not taken.
✓ Branch 324 → 329 taken 33 times.
33 if (hasPendingMove(selected)) {
2201 ✗ m_statusState.error(tr("A selected message already has a pending move. Nothing was queued."));
2202 ✗ return false;
2203 }
2204
3/3
✓ Branch 329 → 330 taken 33 times.
✓ Branch 330 → 331 taken 33 times.
✓ Branch 331 → 332 taken 33 times.
33 reserveActionContinuity(activeRowForIdentity(WorkspaceState::encode(selected)));
2205
8/8
✓ Branch 333 → 334 taken 33 times.
✓ Branch 337 → 338 taken 33 times.
✓ Branch 338 → 339 taken 33 times.
✓ Branch 339 → 340 taken 33 times.
✓ Branch 340 → 341 taken 33 times.
✓ Branch 341 → 342 taken 33 times.
✓ Branch 347 → 335 taken 33 times.
✓ Branch 347 → 348 taken 33 times.
66 qCDebug(lcQuickBackend) << "move single mail" << selected.uid << "token row" << m_actionVisualRow
2206
2/2
✓ Branch 342 → 343 taken 33 times.
✓ Branch 343 → 344 taken 33 times.
33 << "successor" << m_actionSuccessor.uid;
2207
2/2
✓ Branch 352 → 353 taken 33 times.
✓ Branch 353 → 354 taken 33 times.
66 m_session.controller()->moves()->moveMailsToFolderFrom({selected.uid}, selected.folderId,
2208
1/1
✓ Branch 351 → 352 taken 33 times.
66 m_session.cache()->folderPath(selected.folderId), path);
2209
3/5
✓ Branch 358 → 359 taken 33 times.
✓ Branch 361 → 362 taken 33 times.
✓ Branch 361 → 363 taken 33 times.
✗ Branch 533 → 534 not taken.
✗ Branch 533 → 535 not taken.
66 reportManualMove({selected}, path);
2210 33 return true;
2211 68 }
2212
2213 // S95.4.5: moves the user makes feed the rule suggestions, one count per
2214 // message; moves of "apply to existing" go through Deps::moveMails instead.
2215 3418 QVariantMap QuickMailBackend::readerMarks() const {
2216 3418 const MailId id = m_workspace.mailId();
2217
8/8
✓ Branch 5 → 6 taken 2099 times.
✓ Branch 5 → 9 taken 1319 times.
✓ Branch 8 → 9 taken 4 times.
✓ Branch 8 → 10 taken 2095 times.
✓ Branch 11 → 12 taken 2099 times.
✓ Branch 11 → 13 taken 1319 times.
✓ Branch 13 → 14 taken 1323 times.
✓ Branch 13 → 15 taken 2095 times.
3418 if (!id.isValid() || id.accountId != accountName()) return {};
2218
2/2
✓ Branch 16 → 17 taken 1147 times.
✓ Branch 16 → 21 taken 948 times.
2095 if (m_readerMarks.id != id) m_readerMarks = ReaderMarks{id};
2219
2/2
✓ Branch 21 → 22 taken 1546 times.
✓ Branch 21 → 46 taken 549 times.
2095 if (!m_readerMarks.loaded) {
2220 // One row by key; a mail tab's mail is often outside the loaded list.
2221
3/5
✓ Branch 25 → 26 taken 1546 times.
✓ Branch 28 → 29 taken 1546 times.
✓ Branch 28 → 30 taken 1546 times.
✗ Branch 82 → 83 not taken.
✗ Branch 82 → 84 not taken.
4638 const auto headers = m_session.cache()->headersByIds({id});
2222
4/4
✓ Branch 31 → 32 taken 1545 times.
✓ Branch 31 → 36 taken 1 time.
✓ Branch 34 → 35 taken 266 times.
✓ Branch 34 → 36 taken 1279 times.
1546 m_readerMarks.important = !headers.isEmpty() && headers.first().isFlagged();
2223
2/2
✓ Branch 38 → 39 taken 1 time.
✓ Branch 38 → 40 taken 1545 times.
1546 m_readerMarks.labels = headers.isEmpty() ? QStringList{} : headers.first().labels;
2224 1546 m_readerMarks.loaded = true;
2225 1546 }
2226 2095 QStringList labels = m_readerMarks.labels;
2227
2/2
✓ Branch 65 → 48 taken 64 times.
✓ Branch 65 → 66 taken 2095 times.
2159 for (auto it = m_readerMarks.labelOverrides.cbegin(); it != m_readerMarks.labelOverrides.cend(); ++it) {
2228
7/7
✓ Branch 49 → 50 taken 37 times.
✓ Branch 49 → 54 taken 27 times.
✓ Branch 52 → 53 taken 22 times.
✓ Branch 52 → 54 taken 15 times.
✓ Branch 55 → 56 taken 22 times.
✓ Branch 55 → 58 taken 42 times.
✓ Branch 57 → 58 taken 22 times.
64 if (it.value() && !labels.contains(it.key())) labels.append(it.key());
2229
3/3
✓ Branch 59 → 60 taken 27 times.
✓ Branch 59 → 62 taken 37 times.
✓ Branch 61 → 62 taken 27 times.
64 if (!it.value()) labels.removeAll(it.key());
2230 }
2231 ✗ return {{"important", m_readerMarks.importantOverride.value_or(m_readerMarks.important)},
2232
2/4
✓ Branch 72 → 73 taken 4190 times.
✓ Branch 72 → 74 taken 2095 times.
✗ Branch 95 → 96 not taken.
✗ Branch 95 → 97 not taken.
6285 {"labels", MailPresentationModel::labelChipsFor(labels)}};
2233 7059 }
2234 449 void QuickMailBackend::readerMarkChanged(qint64 folderId, qint64 uid, const std::function<void(ReaderMarks &)> &apply) {
2235 449 const MailId id = m_workspace.mailId();
2236
9/10
✓ Branch 4 → 5 taken 447 times.
✓ Branch 4 → 9 taken 2 times.
✓ Branch 5 → 6 taken 180 times.
✓ Branch 5 → 9 taken 267 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 180 times.
✓ Branch 11 → 12 taken 180 times.
✓ Branch 11 → 13 taken 269 times.
✓ Branch 13 → 14 taken 269 times.
✓ Branch 13 → 15 taken 180 times.
449 if (id.folderId != folderId || id.uid != uid || id.accountId != accountName()) return;
2237
1/2
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 21 taken 180 times.
180 if (m_readerMarks.id != id) m_readerMarks = ReaderMarks{id};
2238
1/1
✓ Branch 21 → 22 taken 180 times.
180 apply(m_readerMarks);
2239
1/1
✓ Branch 22 → 23 taken 180 times.
180 emit readerMarksChanged();
2240 449 }
2241 49 void QuickMailBackend::reportManualMove(const QList<MailId> &mails, const QString &targetPath) {
2242 49 const bool filing = !FolderPredictionSession::excludedFolder(targetPath);
2243
8/8
✓ Branch 4 → 5 taken 46 times.
✓ Branch 4 → 8 taken 3 times.
✓ Branch 6 → 7 taken 43 times.
✓ Branch 6 → 9 taken 3 times.
✓ Branch 7 → 8 taken 13 times.
✓ Branch 7 → 9 taken 30 times.
✓ Branch 10 → 11 taken 16 times.
✓ Branch 10 → 12 taken 33 times.
49 if (mails.isEmpty() || (!m_rules.available() && !filing)) return;
2244
5/5
✓ Branch 13 → 14 taken 33 times.
✓ Branch 14 → 15 taken 33 times.
✓ Branch 15 → 16 taken 33 times.
✓ Branch 39 → 17 taken 39 times.
✓ Branch 39 → 40 taken 33 times.
72 for (const auto &header : m_session.cache()->headersByIds(mails)) {
2245 // S105.3.3: the user's choice counts for the very next suggestion.
2246
1/2
✓ Branch 18 → 19 taken 39 times.
✗ Branch 18 → 27 not taken.
39 if (filing)
2247
2/2
✓ Branch 19 → 20 taken 39 times.
✓ Branch 23 → 24 taken 39 times.
39 FolderPredictionService::shared()->recordFiling(m_session.cache()->databasePath(), accountName(), header, targetPath);
2248
2/2
✓ Branch 28 → 29 taken 36 times.
✓ Branch 28 → 30 taken 3 times.
39 if (!m_rules.available()) continue;
2249
1/1
✓ Branch 30 → 31 taken 3 times.
3 const QString address = RuleDraftFactory::bareAddress(header.from);
2250
2/3
✓ Branch 32 → 33 taken 3 times.
✗ Branch 32 → 34 not taken.
✓ Branch 33 → 34 taken 3 times.
3 if (!address.isEmpty()) m_rules.recordManualMove(address, header.listId, targetPath);
2251 36 }
2252 }
2253
2254 // The opened mail can outlive its own (bulk) move while the journal is
2255 // pending; a second move of it must report the conflict instead of
2256 // announcing a move that the journal will never admit.
2257 33 bool QuickMailBackend::hasPendingMove(const MailId &mail) const {
2258
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 33 times.
33 if (m_visualMoveUids.value(mail.folderId).contains(mail.uid)) return true;
2259
1/2
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 33 times.
33 if (m_session.controller()->hasSessionPendingMove(mail)) return true;
2260 // P90.17: a loaded row is never a journaled move (page reads and the
2261 // indexed exclusion set drop them), and this session's moves are known
2262 // in memory above. Only a mail outside the loaded rows needs the journal.
2263
1/2
✓ Branch 12 → 13 taken 33 times.
✗ Branch 12 → 14 not taken.
33 if (loadedRowForIdentity(mail) >= 0) return false;
2264 ✗ return m_session.cache()->journal().pendingMoveUids(accountName(), mail.folderId).contains(mail.uid);
2265 }
2266
2267 3 void QuickMailBackend::beginFolderSubscriptions() {
2268 3 const auto generation = m_session.accounts()->generation();
2269 3 const QStringList subscribed = m_session.controller()->subscribedFolders();
2270
1/1
✓ Branch 8 → 9 taken 3 times.
3 const QSet<QString> already(subscribed.cbegin(), subscribed.cend());
2271
3/3
✓ Branch 10 → 11 taken 3 times.
✓ Branch 11 → 12 taken 3 times.
✓ Branch 13 → 14 taken 3 times.
3 m_folderSubscriptions.begin(accountName(), m_availableFolders.values(), subscribed,
2272 6 [this, generation, already](const QString &account, const QStringList &paths) {
2273
7/8
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 8 taken 1 time.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 1 time.
✓ Branch 10 → 11 taken 1 time.
✓ Branch 10 → 12 taken 1 time.
✓ Branch 12 → 13 taken 1 time.
✓ Branch 12 → 14 taken 1 time.
2 if (generation != m_session.accounts()->generation() || account != accountName()) return false;
2274 // A path stays admissible when it was subscribed already (S106-239).
2275
1/2
✗ Branch 27 → 16 not taken.
✓ Branch 27 → 28 taken 1 time.
1 for (const auto &path : paths)
2276 ✗ if (!m_availableFolders.contains(path) && !already.contains(path)) return false;
2277 1 return true;
2278 });
2279 3 }
2280
2281 3 MailId QuickMailBackend::displayedMailId() const { return m_workspace.mailId(); }
2282
2283 // Sprint 98: availability of a command-bar line ("folder rename", "mark
2284 // read"); the first word is the command, the second its value. Commands the
2285 // window handles alone (areas, settings, line commands) are always available.
2286 3367 bool QuickMailBackend::canExecuteNamedCommand(const QString &line) const {
2287
2/2
✓ Branch 5 → 6 taken 3367 times.
✓ Branch 6 → 7 taken 3367 times.
3367 const QString name = line.section(QLatin1Char(' '), 0, 0, QString::SectionSkipEmpty).toLower();
2288
2/2
✓ Branch 11 → 12 taken 3367 times.
✓ Branch 12 → 13 taken 3367 times.
3367 const QString value = line.section(QLatin1Char(' '), 1, 1, QString::SectionSkipEmpty).toLower();
2289 static const QSet<QString> anywhere = {"settings", "quit", "help", "calendar", "tasks", "contacts", "mail",
2290
5/10
✓ Branch 14 → 15 taken 10 times.
✓ Branch 14 → 47 taken 3357 times.
✓ Branch 16 → 17 taken 10 times.
✗ Branch 16 → 47 not taken.
✓ Branch 44 → 45 taken 230 times.
✓ Branch 44 → 46 taken 10 times.
✗ Branch 255 → 256 not taken.
✗ Branch 255 → 257 not taken.
✗ Branch 264 → 265 not taken.
✗ Branch 264 → 266 not taken.
3607 "agenda", "import", "event", "contact", "met", "link", "relate", "map", "graph", "how", "me", "family", "theme", "notifications", "sync", "push"};
2291
2/2
✓ Branch 48 → 49 taken 769 times.
✓ Branch 48 → 50 taken 2598 times.
3367 if (anywhere.contains(name)) return true;
2292 // Sprint 100 D-29: "account new" works without an account; the others
2293 // need one (the active one when no address follows).
2294
4/6
✓ Branch 51 → 52 taken 38 times.
✓ Branch 51 → 60 taken 2560 times.
✓ Branch 53 → 54 taken 38 times.
✗ Branch 53 → 56 not taken.
✓ Branch 55 → 56 taken 38 times.
✗ Branch 55 → 57 not taken.
2598 if (name == "account") return value == "new" || configured();
2295
5/8
✓ Branch 61 → 62 taken 13 times.
✓ Branch 61 → 73 taken 2547 times.
✓ Branch 63 → 64 taken 13 times.
✗ Branch 63 → 68 not taken.
✓ Branch 66 → 67 taken 13 times.
✗ Branch 66 → 68 not taken.
✓ Branch 70 → 71 taken 13 times.
✗ Branch 70 → 72 not taken.
2560 if (name == "suggestions") return configured() && m_workspace.area() == "mail";
2296
11/12
✓ Branch 74 → 75 taken 2519 times.
✓ Branch 74 → 79 taken 28 times.
✓ Branch 76 → 77 taken 2484 times.
✓ Branch 76 → 79 taken 35 times.
✓ Branch 78 → 79 taken 35 times.
✓ Branch 78 → 80 taken 2449 times.
✓ Branch 81 → 82 taken 98 times.
✓ Branch 81 → 89 taken 2449 times.
✓ Branch 83 → 84 taken 98 times.
✗ Branch 83 → 87 not taken.
✓ Branch 85 → 86 taken 24 times.
✓ Branch 85 → 87 taken 74 times.
2547 if (name == "rules" || name == "vacation" || name == "rule") return configured() && m_rules.available();
2297
2/2
✓ Branch 90 → 91 taken 2 times.
✓ Branch 90 → 92 taken 2447 times.
2449 if (!configured()) return false;
2298
8/8
✓ Branch 93 → 94 taken 2367 times.
✓ Branch 93 → 98 taken 80 times.
✓ Branch 95 → 96 taken 2346 times.
✓ Branch 95 → 98 taken 21 times.
✓ Branch 97 → 98 taken 209 times.
✓ Branch 97 → 99 taken 2137 times.
✓ Branch 100 → 101 taken 310 times.
✓ Branch 100 → 102 taken 2137 times.
2447 if (name == "compose" || name == "detach" || name == "task") return true;
2299
2/2
✓ Branch 105 → 106 taken 8 times.
✓ Branch 105 → 107 taken 2129 times.
2137 if (m_workspace.area() != "mail") return false;
2300 2129 const bool folderView = m_workspace.activeTab() == 0;
2301 // Sprint 101 CB-06: the folder view's list commands act on search results too.
2302
4/4
✓ Branch 108 → 109 taken 421 times.
✓ Branch 108 → 111 taken 1708 times.
✓ Branch 110 → 111 taken 37 times.
✓ Branch 110 → 112 taken 384 times.
2129 const bool listView = folderView || m_workspace.activeIsSearch();
2303
2/4
✓ Branch 113 → 114 taken 2129 times.
✗ Branch 113 → 117 not taken.
✓ Branch 115 → 116 taken 2129 times.
✗ Branch 115 → 117 not taken.
2129 const bool idleSelection = !m_actionPending && !selectionPending();
2304
8/8
✓ Branch 119 → 120 taken 1861 times.
✓ Branch 119 → 124 taken 268 times.
✓ Branch 121 → 122 taken 1843 times.
✓ Branch 121 → 124 taken 18 times.
✓ Branch 123 → 124 taken 63 times.
✓ Branch 123 → 125 taken 1780 times.
✓ Branch 126 → 127 taken 349 times.
✓ Branch 126 → 128 taken 1780 times.
2129 if (name == "filter" || name == "go" || name == "search") return listView;
2305
2/2
✓ Branch 129 → 130 taken 29 times.
✓ Branch 129 → 131 taken 1751 times.
1780 if (name == "threads") return folderView;
2306
2/2
✓ Branch 132 → 133 taken 3 times.
✓ Branch 132 → 134 taken 1748 times.
1751 if (name == "timeline") return m_workspace.activeIsSearch();
2307
2/2
✓ Branch 135 → 136 taken 33 times.
✓ Branch 135 → 162 taken 1715 times.
1748 if (name == "folder") {
2308
5/7
✓ Branch 137 → 138 taken 2 times.
✓ Branch 137 → 144 taken 31 times.
✓ Branch 138 → 139 taken 2 times.
✗ Branch 138 → 142 not taken.
✓ Branch 139 → 140 taken 2 times.
✓ Branch 140 → 141 taken 2 times.
✗ Branch 140 → 142 not taken.
33 if (value == "properties") return folderView && m_folders.isSelectable(m_folderPath);
2309
8/8
✓ Branch 145 → 146 taken 27 times.
✓ Branch 145 → 150 taken 4 times.
✓ Branch 147 → 148 taken 24 times.
✓ Branch 147 → 150 taken 3 times.
✓ Branch 149 → 150 taken 7 times.
✓ Branch 149 → 151 taken 17 times.
✓ Branch 152 → 153 taken 14 times.
✓ Branch 152 → 161 taken 17 times.
31 if (value == "rename" || value == "delete" || value == "move")
2310
6/7
✓ Branch 153 → 154 taken 12 times.
✓ Branch 153 → 159 taken 2 times.
✓ Branch 155 → 156 taken 12 times.
✗ Branch 155 → 159 not taken.
✓ Branch 156 → 157 taken 12 times.
✓ Branch 157 → 158 taken 4 times.
✓ Branch 157 → 159 taken 8 times.
14 return folderView && !m_folderPath.isEmpty() && !m_folderActions.folderIsProtected(m_folderPath);
2311 17 return folderView;
2312 }
2313
8/8
✓ Branch 163 → 164 taken 1472 times.
✓ Branch 163 → 168 taken 243 times.
✓ Branch 165 → 166 taken 1447 times.
✓ Branch 165 → 168 taken 25 times.
✓ Branch 167 → 168 taken 31 times.
✓ Branch 167 → 169 taken 1416 times.
✓ Branch 170 → 171 taken 299 times.
✓ Branch 170 → 193 taken 1416 times.
1715 if (name == "archive" || name == "delete" || name == "junk") {
2314
4/4
✓ Branch 172 → 173 taken 243 times.
✓ Branch 172 → 174 taken 56 times.
✓ Branch 175 → 176 taken 25 times.
✓ Branch 175 → 177 taken 31 times.
299 const auto target = name == "archive" ? m_archiveFolder : name == "delete" ? m_trashFolder : m_junkFolder;
2315
9/12
✓ Branch 180 → 181 taken 299 times.
✗ Branch 180 → 189 not taken.
✓ Branch 181 → 182 taken 299 times.
✗ Branch 181 → 189 not taken.
✓ Branch 182 → 183 taken 299 times.
✓ Branch 183 → 184 taken 277 times.
✓ Branch 183 → 189 taken 22 times.
✓ Branch 185 → 186 taken 209 times.
✓ Branch 185 → 189 taken 68 times.
✓ Branch 186 → 187 taken 209 times.
✓ Branch 187 → 188 taken 209 times.
✗ Branch 187 → 189 not taken.
299 return listView && idleSelection && selectionCount() > 0 && !target.isEmpty() && m_folders.isSelectable(target);
2316 299 }
2317
7/9
✓ Branch 194 → 195 taken 202 times.
✓ Branch 194 → 202 taken 1214 times.
✓ Branch 195 → 196 taken 202 times.
✗ Branch 195 → 200 not taken.
✓ Branch 196 → 197 taken 202 times.
✗ Branch 196 → 200 not taken.
✓ Branch 197 → 198 taken 202 times.
✓ Branch 198 → 199 taken 189 times.
✓ Branch 198 → 200 taken 13 times.
1416 if (name == "move") return listView && idleSelection && selectionCount() > 0;
2318
6/9
✓ Branch 203 → 204 taken 15 times.
✓ Branch 203 → 212 taken 1199 times.
✓ Branch 204 → 205 taken 15 times.
✗ Branch 204 → 210 not taken.
✓ Branch 205 → 206 taken 15 times.
✗ Branch 205 → 210 not taken.
✓ Branch 207 → 208 taken 15 times.
✓ Branch 208 → 209 taken 15 times.
✗ Branch 208 → 210 not taken.
1214 if (name == "open") return listView && idleSelection && acceptsIdentity(m_workspace.mailId());
2319
2/2
✓ Branch 213 → 214 taken 34 times.
✓ Branch 213 → 215 taken 1165 times.
1199 if (name == "undo") return true;
2320
14/14
✓ Branch 216 → 217 taken 922 times.
✓ Branch 216 → 227 taken 243 times.
✓ Branch 218 → 219 taken 883 times.
✓ Branch 218 → 227 taken 39 times.
✓ Branch 220 → 221 taken 504 times.
✓ Branch 220 → 227 taken 379 times.
✓ Branch 222 → 223 taken 169 times.
✓ Branch 222 → 227 taken 335 times.
✓ Branch 224 → 225 taken 89 times.
✓ Branch 224 → 227 taken 80 times.
✓ Branch 226 → 227 taken 20 times.
✓ Branch 226 → 228 taken 69 times.
✓ Branch 229 → 230 taken 1096 times.
✓ Branch 229 → 237 taken 69 times.
1165 if (name == "reply" || name == "forward" || name == "mark" || name == "label" || name == "unlabel" || name == "reading")
2321
4/5
✓ Branch 230 → 231 taken 1096 times.
✗ Branch 230 → 235 not taken.
✓ Branch 232 → 233 taken 1096 times.
✓ Branch 233 → 234 taken 1008 times.
✓ Branch 233 → 235 taken 88 times.
1096 return idleSelection && acceptsIdentity(m_workspace.mailId());
2322 69 return false;
2323 3377 }
2324
2325 3040 QString QuickMailBackend::namedCommandUnavailableReason(const QString &line) const {
2326
3/3
✓ Branch 2 → 3 taken 3040 times.
✓ Branch 3 → 4 taken 2715 times.
✓ Branch 3 → 5 taken 325 times.
3040 if (canExecuteNamedCommand(line)) return {};
2327
2/2
✓ Branch 8 → 9 taken 325 times.
✓ Branch 9 → 10 taken 325 times.
325 const QString name = line.section(QLatin1Char(' '), 0, 0, QString::SectionSkipEmpty).toLower();
2328
2/2
✓ Branch 14 → 15 taken 325 times.
✓ Branch 15 → 16 taken 325 times.
325 const QString value = line.section(QLatin1Char(' '), 1, 1, QString::SectionSkipEmpty).toLower();
2329
1/3
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 21 taken 325 times.
✗ Branch 19 → 20 not taken.
325 if (!configured()) return tr("Configure a mail account first.");
2330
9/10
✓ Branch 22 → 23 taken 307 times.
✓ Branch 22 → 27 taken 18 times.
✓ Branch 24 → 25 taken 277 times.
✓ Branch 24 → 27 taken 30 times.
✓ Branch 26 → 27 taken 17 times.
✓ Branch 26 → 30 taken 260 times.
✓ Branch 28 → 29 taken 65 times.
✗ Branch 28 → 30 not taken.
✓ Branch 31 → 32 taken 65 times.
✓ Branch 31 → 34 taken 260 times.
325 if ((name == "rules" || name == "vacation" || name == "rule") && !m_rules.available())
2331
1/1
✓ Branch 32 → 33 taken 65 times.
65 return tr("Server rules are not available for this account.");
2332
3/3
✓ Branch 37 → 38 taken 7 times.
✓ Branch 37 → 40 taken 253 times.
✓ Branch 38 → 39 taken 7 times.
260 if (m_workspace.area() != "mail") return tr("Open a mail folder to use this command.");
2333
3/7
✓ Branch 40 → 41 taken 253 times.
✗ Branch 40 → 43 not taken.
✗ Branch 42 → 43 not taken.
✓ Branch 42 → 44 taken 253 times.
✗ Branch 45 → 46 not taken.
✓ Branch 45 → 48 taken 253 times.
✗ Branch 46 → 47 not taken.
253 if (m_actionPending || selectionPending()) return tr("Wait for the current selection or action to finish.");
2334
8/8
✓ Branch 49 → 50 taken 216 times.
✓ Branch 49 → 54 taken 37 times.
✓ Branch 51 → 52 taken 197 times.
✓ Branch 51 → 54 taken 19 times.
✓ Branch 53 → 54 taken 28 times.
✓ Branch 53 → 55 taken 169 times.
✓ Branch 56 → 57 taken 84 times.
✓ Branch 56 → 59 taken 169 times.
253 if (name == "archive" || name == "delete" || name == "junk")
2335
1/1
✓ Branch 57 → 58 taken 84 times.
84 return tr("Select messages and configure the destination folder.");
2336
9/10
✓ Branch 60 → 61 taken 8 times.
✓ Branch 60 → 70 taken 161 times.
✓ Branch 62 → 63 taken 6 times.
✓ Branch 62 → 67 taken 2 times.
✓ Branch 64 → 65 taken 3 times.
✓ Branch 64 → 67 taken 3 times.
✓ Branch 66 → 67 taken 3 times.
✗ Branch 66 → 70 not taken.
✓ Branch 71 → 72 taken 8 times.
✓ Branch 71 → 74 taken 161 times.
177 if (name == "folder" && (value == "rename" || value == "delete" || value == "move") &&
2337
2/3
✓ Branch 67 → 68 taken 8 times.
✓ Branch 68 → 69 taken 8 times.
✗ Branch 68 → 70 not taken.
8 m_folderActions.folderIsProtected(m_folderPath))
2338
1/1
✓ Branch 72 → 73 taken 8 times.
8 return tr("This folder is protected.");
2339
6/7
✓ Branch 75 → 76 taken 148 times.
✓ Branch 75 → 78 taken 13 times.
✗ Branch 77 → 78 not taken.
✓ Branch 77 → 79 taken 148 times.
✓ Branch 80 → 81 taken 13 times.
✓ Branch 80 → 83 taken 148 times.
✓ Branch 81 → 82 taken 13 times.
161 if (name == "move" || name == "open") return tr("Select a message first.");
2340
1/3
✗ Branch 84 → 85 not taken.
✓ Branch 84 → 87 taken 148 times.
✗ Branch 85 → 86 not taken.
148 if (name == "timeline") return tr("Open a search first.");
2341
1/1
✓ Branch 87 → 88 taken 148 times.
148 return tr("Select a message or open the main folder view.");
2342 325 }
2343
2344 // The commands whose effect lives in the backend (selection flags, labels,
2345 // threads, search paging, reading mode). Area, folder and line commands are
2346 // dispatched by the window (Main.qml executeNamed).
2347 79 bool QuickMailBackend::executeNamedCommand(const QString &name, const QString &argument) {
2348
5/5
✓ Branch 3 → 4 taken 3 times.
✓ Branch 3 → 5 taken 76 times.
✓ Branch 5 → 6 taken 76 times.
✓ Branch 6 → 7 taken 79 times.
✓ Branch 7 → 8 taken 79 times.
79 const auto parsed = NamedCommandCatalog::parse(name + (argument.isEmpty() ? QString{} : " " + argument));
2349
11/14
✓ Branch 11 → 12 taken 71 times.
✓ Branch 11 → 17 taken 8 times.
✓ Branch 13 → 14 taken 71 times.
✗ Branch 13 → 17 not taken.
✓ Branch 14 → 15 taken 71 times.
✓ Branch 15 → 16 taken 71 times.
✓ Branch 16 → 17 taken 8 times.
✓ Branch 16 → 18 taken 63 times.
✓ Branch 19 → 20 taken 71 times.
✓ Branch 19 → 21 taken 8 times.
✓ Branch 21 → 22 taken 16 times.
✓ Branch 21 → 23 taken 63 times.
✗ Branch 253 → 254 not taken.
✗ Branch 253 → 255 not taken.
79 if (!parsed.complete() || parsed.spec->name != name || !canExecuteNamedCommand(parsed.text())) return false;
2350
5/5
✓ Branch 23 → 24 taken 63 times.
✓ Branch 24 → 25 taken 45 times.
✓ Branch 24 → 28 taken 18 times.
✓ Branch 25 → 26 taken 45 times.
✓ Branch 27 → 29 taken 45 times.
63 const QString value = parsed.value() ? QString::fromLatin1(parsed.value()->word) : QString{};
2351 // F-09 (SPRINT-104, D-4): ":delete forever" deletes the OPEN folder's
2352 // selection for good (the command bar asked once).
2353
2/6
✗ Branch 30 → 31 not taken.
✓ Branch 30 → 34 taken 63 times.
✗ Branch 32 → 33 not taken.
✗ Branch 32 → 34 not taken.
✗ Branch 35 → 36 not taken.
✓ Branch 35 → 68 taken 63 times.
63 if (name == "delete" && value == "forever") {
2354 ✗ if (m_actionPending || m_selection.pending()) return false;
2355 ✗ const auto current = m_workspace.mailId();
2356 ✗ if (!acceptsIdentity(current) || current.folderId <= 0 ||
2357 ✗ m_session.cache()->folderPath(current.folderId) != m_folderPath) {
2358 ✗ setStatus(tr("Delete for good works in the open folder."));
2359 ✗ return true;
2360 }
2361 ✗ ensureDeleteForever();
2362 ✗ m_deleteForever->deleteNow(current, deleteForeverUids(current));
2363 ✗ return true;
2364 ✗ }
2365
2/2
✓ Branch 69 → 70 taken 3 times.
✓ Branch 69 → 80 taken 60 times.
63 if (name == "threads") {
2366
3/5
✗ Branch 71 → 72 not taken.
✓ Branch 71 → 73 taken 3 times.
✗ Branch 74 → 75 not taken.
✓ Branch 74 → 76 taken 3 times.
✓ Branch 78 → 79 taken 3 times.
3 setThreaded(value == "on" ? true : value == "off" ? false : !m_threaded);
2367 3 return true;
2368 }
2369 // D-15a: "mark read important" and "label Work Later" apply each part.
2370
1/1
✓ Branch 80 → 81 taken 60 times.
60 QStringList parts = parsed.words();
2371
2/2
✓ Branch 82 → 83 taken 44 times.
✓ Branch 82 → 98 taken 16 times.
60 if (name == "mark") {
2372
1/1
✓ Branch 83 → 84 taken 44 times.
44 parts.clear();
2373
7/7
✓ Branch 84 → 85 taken 44 times.
✓ Branch 85 → 86 taken 44 times.
✓ Branch 86 → 87 taken 44 times.
✓ Branch 90 → 91 taken 46 times.
✓ Branch 91 → 92 taken 46 times.
✓ Branch 95 → 88 taken 46 times.
✓ Branch 95 → 96 taken 44 times.
90 for (const auto *chosen : parsed.values()) parts << QString::fromLatin1(chosen->word);
2374 }
2375
6/6
✓ Branch 99 → 100 taken 16 times.
✓ Branch 99 → 104 taken 44 times.
✓ Branch 101 → 102 taken 6 times.
✓ Branch 101 → 104 taken 10 times.
✓ Branch 103 → 104 taken 5 times.
✓ Branch 103 → 105 taken 1 time.
60 const bool selectionCommand = name == "mark" || name == "label" || name == "unlabel";
2376
6/7
✓ Branch 106 → 107 taken 60 times.
✓ Branch 107 → 108 taken 11 times.
✓ Branch 107 → 110 taken 49 times.
✓ Branch 108 → 109 taken 11 times.
✗ Branch 108 → 110 not taken.
✓ Branch 111 → 112 taken 11 times.
✓ Branch 111 → 154 taken 49 times.
60 if (bulkSelection() && selectionCommand) {
2377 11 bool accepted = true;
2378
4/4
✓ Branch 112 → 113 taken 11 times.
✓ Branch 113 → 114 taken 11 times.
✓ Branch 152 → 115 taken 12 times.
✓ Branch 152 → 153 taken 11 times.
23 for (const QString &part : parts) {
2379
3/4
✓ Branch 117 → 118 taken 11 times.
✓ Branch 117 → 120 taken 1 time.
✗ Branch 119 → 120 not taken.
✓ Branch 119 → 121 taken 11 times.
12 const bool seen = part == "read" || part == "unread";
2380
1/5
✓ Branch 135 → 136 taken 11 times.
✗ Branch 285 → 286 not taken.
✗ Branch 285 → 287 not taken.
✗ Branch 291 → 292 not taken.
✗ Branch 291 → 293 not taken.
56 accepted = (name == "mark" ? actOnSelection(seen ? MutationKind::Seen : MutationKind::Flagged, {},
2381
6/6
✓ Branch 126 → 127 taken 10 times.
✓ Branch 126 → 129 taken 1 time.
✓ Branch 128 → 129 taken 9 times.
✓ Branch 128 → 130 taken 1 time.
✓ Branch 148 → 149 taken 11 times.
✓ Branch 148 → 150 taken 1 time.
23 part == "read" || part == "important")
2382
12/17
✓ Branch 123 → 124 taken 11 times.
✓ Branch 123 → 137 taken 1 time.
✓ Branch 132 → 133 taken 1 time.
✓ Branch 132 → 134 taken 10 times.
✓ Branch 136 → 141 taken 11 times.
✗ Branch 136 → 143 not taken.
✓ Branch 139 → 140 taken 1 time.
✓ Branch 140 → 141 taken 1 time.
✗ Branch 140 → 143 not taken.
✓ Branch 141 → 142 taken 12 times.
✗ Branch 141 → 143 not taken.
✓ Branch 144 → 145 taken 1 time.
✓ Branch 144 → 146 taken 11 times.
✓ Branch 146 → 147 taken 11 times.
✓ Branch 146 → 148 taken 1 time.
✗ Branch 281 → 282 not taken.
✗ Branch 281 → 283 not taken.
45 : actOnSelection(MutationKind::Keyword, part, name == "label")) && accepted;
2383 }
2384 11 return accepted;
2385 }
2386 49 const auto selected = m_workspace.mailId();
2387
2/3
✓ Branch 156 → 157 taken 49 times.
✗ Branch 157 → 158 not taken.
✓ Branch 157 → 159 taken 49 times.
49 if (!acceptsIdentity(selected)) return false;
2388
3/3
✓ Branch 160 → 161 taken 1 time.
✓ Branch 160 → 163 taken 48 times.
✓ Branch 161 → 162 taken 1 time.
49 if (name == "reading") return m_workspace.setReadingMode(value);
2389
1/2
✗ Branch 163 → 164 not taken.
✓ Branch 163 → 165 taken 48 times.
48 if (!selectionCommand) return false;
2390
4/4
✓ Branch 165 → 166 taken 48 times.
✓ Branch 166 → 167 taken 48 times.
✓ Branch 239 → 168 taken 51 times.
✓ Branch 239 → 240 taken 48 times.
99 for (const QString &part : parts) {
2391
3/3
✓ Branch 170 → 171 taken 10 times.
✓ Branch 170 → 176 taken 41 times.
✓ Branch 173 → 174 taken 10 times.
61 if (name == "label") m_session.controller()->addLabelInFolder(selected.uid, selected.folderId, part);
2392
3/3
✓ Branch 177 → 178 taken 6 times.
✓ Branch 177 → 183 taken 35 times.
✓ Branch 180 → 181 taken 6 times.
47 else if (name == "unlabel") m_session.controller()->removeLabelInFolder(selected.uid, selected.folderId, part);
2393
6/6
✓ Branch 184 → 185 taken 24 times.
✓ Branch 184 → 187 taken 11 times.
✓ Branch 186 → 187 taken 4 times.
✓ Branch 186 → 188 taken 20 times.
✓ Branch 189 → 190 taken 15 times.
✓ Branch 189 → 212 taken 20 times.
35 else if (part == "important" || part == "unimportant") {
2394 15 std::optional<bool> visibleStarred;
2395
2/6
✗ Branch 191 → 192 not taken.
✓ Branch 191 → 195 taken 15 times.
✗ Branch 193 → 194 not taken.
✗ Branch 193 → 195 not taken.
✗ Branch 196 → 197 not taken.
✓ Branch 196 → 208 taken 15 times.
15 if (threadedPresentation() && m_threadList.indexed()) {
2396 ✗ const int row = m_threadList.rowForIdentity(WorkspaceState::encode(selected));
2397 ✗ if (row >= 0)
2398 ✗ visibleStarred = m_threadList.data(m_threadList.index(row, 0), QuickThreadListModel::Starred).toBool();
2399 }
2400
1/1
✓ Branch 210 → 211 taken 15 times.
15 m_session.controller()->setStarredInFolder(selected.uid, selected.folderId, part == "important", visibleStarred);
2401 } else {
2402 20 std::optional<bool> visibleSeen;
2403
2/6
✗ Branch 213 → 214 not taken.
✓ Branch 213 → 217 taken 20 times.
✗ Branch 215 → 216 not taken.
✗ Branch 215 → 217 not taken.
✗ Branch 218 → 219 not taken.
✓ Branch 218 → 230 taken 20 times.
20 if (threadedPresentation() && m_threadList.indexed()) {
2404 ✗ const int row = m_threadList.rowForIdentity(WorkspaceState::encode(selected));
2405 ✗ if (row >= 0)
2406 ✗ visibleSeen = !m_threadList.data(m_threadList.index(row, 0), QuickThreadListModel::Unread).toBool();
2407 }
2408
3/3
✓ Branch 231 → 232 taken 4 times.
✓ Branch 231 → 234 taken 16 times.
✓ Branch 233 → 236 taken 4 times.
20 if (part == "read") m_session.controller()->markMailAsSeenInFolder(selected.uid, selected.folderId, visibleSeen);
2409
1/1
✓ Branch 235 → 236 taken 16 times.
16 else m_session.controller()->markMailAsUnseenInFolder(selected.uid, selected.folderId, visibleSeen);
2410 }
2411 }
2412 48 return true;
2413 79 }
2414
2415 // executeCommand handler: Suggestion column commands and the quick move (F4.2).
2416 1184 std::optional<bool> QuickMailBackend::handleSuggestionCommand(const QString &id) {
2417
8/10
✓ Branch 3 → 4 taken 15 times.
✓ Branch 3 → 10 taken 1169 times.
✓ Branch 5 → 6 taken 15 times.
✗ Branch 5 → 10 not taken.
✓ Branch 8 → 9 taken 15 times.
✗ Branch 8 → 10 not taken.
✓ Branch 11 → 12 taken 15 times.
✓ Branch 11 → 13 taken 1169 times.
✓ Branch 13 → 14 taken 15 times.
✓ Branch 13 → 18 taken 1169 times.
1184 if (id == "suggestion.toggle_all" && m_workspace.activeTab() == 0 && m_workspace.area() == "mail") {
2418 15 setSuggestionsVisible(!m_suggestionsVisible);
2419 15 return true;
2420 }
2421
8/10
✓ Branch 19 → 20 taken 8 times.
✓ Branch 19 → 26 taken 1161 times.
✓ Branch 21 → 22 taken 8 times.
✗ Branch 21 → 26 not taken.
✓ Branch 24 → 25 taken 8 times.
✗ Branch 24 → 26 not taken.
✓ Branch 27 → 28 taken 8 times.
✓ Branch 27 → 29 taken 1161 times.
✓ Branch 29 → 30 taken 8 times.
✓ Branch 29 → 45 taken 1161 times.
1169 if (id == "suggestion.alternate" && m_workspace.activeTab() == 0 && m_workspace.area() == "mail") {
2422 8 const auto selected = m_workspace.mailId();
2423
1/3
✗ Branch 33 → 34 not taken.
✓ Branch 33 → 35 taken 8 times.
✗ Branch 34 → 39 not taken.
8 if (m_alternatePredictions.contains(selected)) m_alternatePredictions.remove(selected);
2424
2/3
✓ Branch 36 → 37 taken 8 times.
✗ Branch 36 → 39 not taken.
✓ Branch 37 → 38 taken 8 times.
8 else if (m_predictions.contains(selected)) m_alternatePredictions.insert(selected);
2425
1/1
✓ Branch 39 → 40 taken 8 times.
8 m_mail.suggestionsChanged();
2426
1/1
✓ Branch 40 → 41 taken 8 times.
8 emit suggestionsChanged();
2427 8 return true;
2428 8 }
2429
8/10
✓ Branch 46 → 47 taken 33 times.
✓ Branch 46 → 53 taken 1128 times.
✓ Branch 48 → 49 taken 33 times.
✗ Branch 48 → 53 not taken.
✓ Branch 51 → 52 taken 33 times.
✗ Branch 51 → 53 not taken.
✓ Branch 54 → 55 taken 33 times.
✓ Branch 54 → 56 taken 1128 times.
✓ Branch 56 → 57 taken 33 times.
✓ Branch 56 → 89 taken 1128 times.
1161 if (id == "mail.quick_move" && m_workspace.activeTab() == 0 && m_workspace.area() == "mail") {
2430 33 const auto selected = m_workspace.mailId();
2431
1/1
✓ Branch 59 → 60 taken 33 times.
33 const auto target = visiblePrediction(selected).first;
2432
5/6
✓ Branch 63 → 64 taken 23 times.
✓ Branch 63 → 70 taken 10 times.
✓ Branch 65 → 66 taken 23 times.
✗ Branch 65 → 70 not taken.
✓ Branch 72 → 73 taken 10 times.
✓ Branch 72 → 82 taken 23 times.
56 if (target.isEmpty() || !m_predictionEpochs.contains(selected.folderId) ||
2433
2/3
✓ Branch 68 → 69 taken 23 times.
✗ Branch 69 → 70 not taken.
✓ Branch 69 → 71 taken 23 times.
23 m_predictionEpochs.value(selected.folderId) != m_session.cache()->uidValidity(selected.folderId)) {
2434 // F4.2(b): after a quick move the follow-up selection's prediction is
2435 // often still behind the 20 ms debounce or its worker batch. A rapid
2436 // second s must retry that prediction instead of opening the manual
2437 // move console; only a genuinely prediction-less state falls back.
2438
3/3
✓ Branch 73 → 74 taken 10 times.
✓ Branch 74 → 75 taken 1 time.
✓ Branch 74 → 79 taken 9 times.
10 if (canArmQuickMoveRetry(selected)) {
2439
1/1
✓ Branch 75 → 76 taken 1 time.
1 armQuickMoveRetry(selected);
2440 1 return true;
2441 }
2442 9 return false;
2443 }
2444 // A derived result is never authority: reuse live target/selection admission.
2445
1/1
✓ Branch 82 → 83 taken 23 times.
23 moveSelectedMail(target);
2446 23 return true;
2447 33 }
2448 1128 return std::nullopt;
2449 }
2450
2451 796 void QuickMailBackend::setSuggestionsVisible(bool on) {
2452
2/2
✓ Branch 2 → 3 taken 773 times.
✓ Branch 2 → 4 taken 23 times.
796 if (m_suggestionsVisible == on) return;
2453 23 m_suggestionsVisible = on;
2454 23 invalidatePredictions();
2455 23 emit suggestionsVisibleChanged();
2456 }
2457
2458 // executeCommand handler: Escape leaves an active search. The suggestion
2459 // column is a persistent view setting (F95-15) and only Ctrl+S toggles it.
2460 1128 std::optional<bool> QuickMailBackend::handleSurfaceClose(const QString &id) {
2461 // F100-77: Esc on a multiple selection keeps only the current mail.
2462
6/8
✓ Branch 5 → 6 taken 14 times.
✓ Branch 5 → 16 taken 8 times.
✓ Branch 8 → 9 taken 7 times.
✓ Branch 8 → 16 taken 7 times.
✓ Branch 19 → 20 taken 1 time.
✓ Branch 19 → 29 taken 1127 times.
✗ Branch 52 → 53 not taken.
✗ Branch 52 → 54 not taken.
1150 if (id == "surface.close" && m_workspace.activeTab() == 0 && m_workspace.area() == "mail" &&
2463
9/11
✓ Branch 3 → 4 taken 22 times.
✓ Branch 3 → 16 taken 1106 times.
✓ Branch 10 → 11 taken 7 times.
✗ Branch 10 → 16 not taken.
✓ Branch 12 → 13 taken 7 times.
✗ Branch 12 → 16 not taken.
✓ Branch 13 → 14 taken 7 times.
✓ Branch 14 → 15 taken 1 time.
✓ Branch 14 → 16 taken 6 times.
✓ Branch 17 → 18 taken 14 times.
✓ Branch 17 → 19 taken 1114 times.
1150 !m_workspace.readerFocused() && !m_selection.pending() && m_selection.count() > 1) {
2464
2/2
✓ Branch 21 → 22 taken 1 time.
✓ Branch 22 → 23 taken 1 time.
1 const int row = activeRowForIdentity(WorkspaceState::encode(m_workspace.mailId()));
2465
1/2
✓ Branch 24 → 25 taken 1 time.
✗ Branch 24 → 29 not taken.
1 if (row >= 0) {
2466 1 selectMailRow(row);
2467 1 return true;
2468 }
2469 }
2470 // Sprint 101 D-18, amended by F100-49 (user, 2026-10-05): Esc with nothing
2471 // else to cancel closes the search tab and returns to the tab used before it.
2472
9/10
✓ Branch 30 → 31 taken 21 times.
✓ Branch 30 → 39 taken 1106 times.
✓ Branch 32 → 33 taken 8 times.
✓ Branch 32 → 39 taken 13 times.
✓ Branch 35 → 36 taken 8 times.
✗ Branch 35 → 39 not taken.
✓ Branch 40 → 41 taken 8 times.
✓ Branch 40 → 42 taken 1119 times.
✓ Branch 42 → 43 taken 8 times.
✓ Branch 42 → 48 taken 1119 times.
1135 if (id == "surface.close" && m_workspace.activeIsSearch() && m_workspace.area() == "mail" &&
2473
1/2
✓ Branch 37 → 38 taken 8 times.
✗ Branch 37 → 39 not taken.
8 !m_workspace.readerFocused()) {
2474 8 m_workspace.closeTab(m_workspace.activeTab());
2475 8 return true;
2476 }
2477 1119 return std::nullopt;
2478 }
2479
2480 // executeCommand handler: Keyboard navigation in the indexed (large-folder) thread list.
2481 1119 std::optional<bool> QuickMailBackend::handleIndexedNavigation(const QString &id) {
2482
6/6
✓ Branch 6 → 7 taken 697 times.
✓ Branch 6 → 24 taken 312 times.
✓ Branch 8 → 9 taken 4 times.
✓ Branch 8 → 24 taken 693 times.
✓ Branch 27 → 28 taken 3 times.
✓ Branch 27 → 138 taken 1116 times.
2825 if (m_workspace.activeTab() == 0 && m_workspace.area() == "mail" &&
2483
5/6
✓ Branch 3 → 4 taken 1009 times.
✓ Branch 3 → 24 taken 110 times.
✓ Branch 10 → 11 taken 3 times.
✓ Branch 10 → 24 taken 1 time.
✓ Branch 12 → 13 taken 3 times.
✗ Branch 12 → 23 not taken.
2831 threadedPresentation() && m_threadList.indexed() &&
2484
4/6
✓ Branch 14 → 15 taken 2 times.
✓ Branch 14 → 23 taken 1 time.
✓ Branch 16 → 17 taken 2 times.
✗ Branch 16 → 23 not taken.
✓ Branch 18 → 19 taken 2 times.
✗ Branch 18 → 23 not taken.
8 (id == "mail.next" || id == "mail.previous" || id == "mail.page_down" ||
2485
5/6
✓ Branch 20 → 21 taken 1 time.
✓ Branch 20 → 23 taken 1 time.
✓ Branch 22 → 23 taken 1 time.
✗ Branch 22 → 24 not taken.
✓ Branch 25 → 26 taken 1009 times.
✓ Branch 25 → 27 taken 110 times.
1123 id == "mail.page_up" || id == "mail.first" || id == "mail.last")) {
2486 // Nothing usable is shown yet (first page, user-announced reload, or the
2487 // previous folder's inert rows): the key has no row to act on.
2488
3/6
✓ Branch 29 → 30 taken 3 times.
✗ Branch 29 → 32 not taken.
✗ Branch 31 → 32 not taken.
✓ Branch 31 → 33 taken 3 times.
✗ Branch 34 → 35 not taken.
✓ Branch 34 → 38 taken 3 times.
3 if (m_threadList.projectionBlocking() || m_threadList.holdingPreviousFolder()) return true;
2489 3 const auto selected = m_workspace.mailId();
2490
1/1
✓ Branch 40 → 41 taken 3 times.
3 const auto generation = m_workspace.generation();
2491
4/4
✓ Branch 42 → 43 taken 2 times.
✓ Branch 42 → 45 taken 1 time.
✓ Branch 44 → 45 taken 1 time.
✓ Branch 44 → 46 taken 1 time.
3 const bool boundary = id == "mail.first" || id == "mail.last";
2492
5/6
✓ Branch 48 → 49 taken 2 times.
✓ Branch 48 → 53 taken 1 time.
✓ Branch 50 → 51 taken 2 times.
✗ Branch 50 → 53 not taken.
✓ Branch 52 → 53 taken 1 time.
✓ Branch 52 → 54 taken 1 time.
3 const bool before = id == "mail.previous" || id == "mail.page_up" || id == "mail.first";
2493
4/6
✓ Branch 55 → 56 taken 2 times.
✓ Branch 55 → 57 taken 1 time.
✓ Branch 59 → 60 taken 3 times.
✗ Branch 59 → 62 not taken.
✗ Branch 61 → 62 not taken.
✓ Branch 61 → 64 taken 3 times.
3 const int delta = (before ? -1 : 1) * ((id == "mail.page_up" || id == "mail.page_down") ? qMax(1, m_pageRows) : 1);
2494
2/2
✓ Branch 65 → 66 taken 3 times.
✓ Branch 66 → 67 taken 3 times.
3 const int row = activeRowForIdentity(WorkspaceState::encode(selected));
2495
5/5
✓ Branch 68 → 69 taken 2 times.
✓ Branch 68 → 74 taken 1 time.
✓ Branch 69 → 70 taken 1 time.
✓ Branch 69 → 71 taken 1 time.
✓ Branch 71 → 72 taken 1 time.
3 const int target = boundary ? (before ? 0 : activeRowCount()-1) : row+delta;
2496
5/7
✓ Branch 75 → 76 taken 1 time.
✓ Branch 75 → 79 taken 2 times.
✓ Branch 76 → 77 taken 1 time.
✗ Branch 76 → 79 not taken.
✓ Branch 77 → 78 taken 1 time.
✗ Branch 78 → 79 not taken.
✓ Branch 78 → 80 taken 1 time.
3 const bool needsPage = boundary || target<0 || target>=activeRowCount();
2497
5/8
✓ Branch 82 → 83 taken 1 time.
✓ Branch 82 → 86 taken 2 times.
✓ Branch 83 → 84 taken 1 time.
✗ Branch 83 → 85 not taken.
✗ Branch 84 → 85 not taken.
✓ Branch 84 → 86 taken 1 time.
✗ Branch 87 → 88 not taken.
✓ Branch 87 → 109 taken 3 times.
3 if (m_threadList.projectionPending() && (row < 0 || needsPage)) {
2498 // A background refresh (e.g. the Seen commit of the opened mail) owns
2499 // the paging state. Within the published rows the key acts at once
2500 // (below); a key that needs another page runs once that refresh landed
2501 // instead of being dropped.
2502 ✗ qCDebug(lcQuickBackend) << "navigation" << id << "waits for the pending thread page";
2503 ✗ connect(&m_threadList, &QuickThreadListModel::projectionReady, this,
2504 ✗ [this, id, selected, generation] {
2505 ✗ if (m_workspace.generation() != generation || m_workspace.mailId() != selected) return;
2506 ✗ executeCommand(id);
2507 }, Qt::SingleShotConnection);
2508 ✗ return true;
2509 }
2510
6/8
✓ Branch 109 → 110 taken 3 times.
✗ Branch 109 → 113 not taken.
✓ Branch 110 → 111 taken 2 times.
✓ Branch 110 → 114 taken 1 time.
✓ Branch 112 → 113 taken 2 times.
✗ Branch 112 → 114 not taken.
✓ Branch 115 → 116 taken 2 times.
✓ Branch 115 → 126 taken 1 time.
3 if (row<0 || (needsPage && m_threadList.hasMoreIndexed(before))) {
2511 2 connect(&m_threadList, &QuickThreadListModel::projectionReady, this,
2512
1/5
✓ Branch 118 → 119 taken 2 times.
✗ Branch 162 → 163 not taken.
✗ Branch 162 → 164 not taken.
✗ Branch 165 → 166 not taken.
✗ Branch 165 → 167 not taken.
4 [this, selected, generation, boundary, before, delta] {
2513
6/13
✓ Branch 2 → 3 taken 2 times.
✓ Branch 4 → 5 taken 2 times.
✗ Branch 4 → 10 not taken.
✓ Branch 7 → 8 taken 2 times.
✗ Branch 7 → 10 not taken.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 2 times.
✓ Branch 12 → 13 taken 2 times.
✗ Branch 12 → 14 not taken.
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 2 times.
✗ Branch 35 → 36 not taken.
✗ Branch 35 → 37 not taken.
2 if (m_workspace.generation()!=generation || m_workspace.mailId()!=selected || !threadedPresentation()) return;
2514
2/2
✓ Branch 16 → 17 taken 2 times.
✓ Branch 17 → 18 taken 2 times.
2 const int current = activeRowForIdentity(WorkspaceState::encode(selected));
2515
4/5
✓ Branch 19 → 20 taken 2 times.
✗ Branch 19 → 25 not taken.
✓ Branch 20 → 21 taken 1 time.
✓ Branch 20 → 22 taken 1 time.
✓ Branch 22 → 23 taken 1 time.
2 const int target = boundary ? (before ? 0 : activeRowCount()-1) : current+delta;
2516
5/6
✓ Branch 26 → 27 taken 2 times.
✓ Branch 27 → 28 taken 2 times.
✗ Branch 27 → 32 not taken.
✓ Branch 28 → 29 taken 2 times.
✓ Branch 29 → 30 taken 2 times.
✓ Branch 30 → 31 taken 2 times.
2 if (activeRowCount()>0) selectMailRow(qBound(0, target, activeRowCount()-1));
2517 }, Qt::SingleShotConnection);
2518
2/3
✓ Branch 121 → 122 taken 2 times.
✗ Branch 121 → 123 not taken.
✓ Branch 122 → 132 taken 2 times.
2 if (boundary) m_threadList.jumpIndexed(!before);
2519 ✗ else if (row<0) m_threadList.revealIndexed(selected.uid,before);
2520 ✗ else m_threadList.fetchIndexed(before);
2521
5/6
✓ Branch 126 → 127 taken 1 time.
✓ Branch 127 → 128 taken 1 time.
✗ Branch 127 → 132 not taken.
✓ Branch 128 → 129 taken 1 time.
✓ Branch 129 → 130 taken 1 time.
✓ Branch 130 → 131 taken 1 time.
1 } else if (activeRowCount()>0) selectMailRow(qBound(0, target, activeRowCount()-1));
2522 3 return true;
2523 3 }
2524 1116 return std::nullopt;
2525 }
2526
2527 // executeCommand handler (Sprint 101): j/k, pages, first/last and next
2528 // unread move through the results of the active search tab.
2529 1116 std::optional<bool> QuickMailBackend::handleSearchTabNavigation(const QString &id) {
2530 static const QSet<QString> navigation = {"mail.next", "mail.previous", "mail.page_down", "mail.page_up",
2531
5/10
✓ Branch 2 → 3 taken 16 times.
✓ Branch 2 → 19 taken 1100 times.
✓ Branch 4 → 5 taken 16 times.
✗ Branch 4 → 19 not taken.
✓ Branch 16 → 17 taken 112 times.
✓ Branch 16 → 18 taken 16 times.
✗ Branch 106 → 107 not taken.
✗ Branch 106 → 108 not taken.
✗ Branch 115 → 116 not taken.
✗ Branch 115 → 117 not taken.
1244 "mail.first", "mail.last", "mail.next_unread"};
2532
9/10
✓ Branch 20 → 21 taken 16 times.
✓ Branch 20 → 26 taken 1100 times.
✓ Branch 23 → 24 taken 16 times.
✗ Branch 23 → 26 not taken.
✓ Branch 25 → 26 taken 14 times.
✓ Branch 25 → 27 taken 2 times.
✓ Branch 28 → 29 taken 16 times.
✓ Branch 28 → 30 taken 1100 times.
✓ Branch 30 → 31 taken 1114 times.
✓ Branch 30 → 34 taken 2 times.
1116 if (!m_workspace.activeIsSearch() || m_workspace.area() != "mail" || !navigation.contains(id)) return std::nullopt;
2533
1/1
✓ Branch 35 → 36 taken 2 times.
2 MailSearchSession *search = m_searchTabs->active();
2534
4/7
✓ Branch 36 → 37 taken 2 times.
✗ Branch 36 → 39 not taken.
✓ Branch 37 → 38 taken 2 times.
✗ Branch 38 → 39 not taken.
✓ Branch 38 → 40 taken 2 times.
✗ Branch 41 → 42 not taken.
✓ Branch 41 → 45 taken 2 times.
2 if (!search || search->loadedCount() == 0) return true;
2535
1/1
✓ Branch 45 → 46 taken 2 times.
2 const int count = search->loadedCount();
2536
1/1
✓ Branch 47 → 48 taken 2 times.
2 const int current = search->rowOf(m_workspace.mailId());
2537 2 const int page = qMax(1, m_pageRows);
2538 2 int target = current;
2539
1/2
✓ Branch 50 → 51 taken 2 times.
✗ Branch 50 → 52 not taken.
2 if (id == "mail.next") target = current + 1;
2540 ✗ else if (id == "mail.previous") target = current - 1;
2541 ✗ else if (id == "mail.page_down") target = current + page;
2542 ✗ else if (id == "mail.page_up") target = current - page;
2543 ✗ else if (id == "mail.first") target = 0;
2544 ✗ else if (id == "mail.last") target = count - 1;
2545 else {
2546 ✗ for (int step = 1; step <= count; ++step) {
2547 ✗ const int row = (qMax(current, 0) + step) % count;
2548 ✗ if (search->results()->data(search->results()->index(row, 0), MailPresentationModel::Unread).toBool()) {
2549 ✗ target = row;
2550 ✗ break;
2551 }
2552 }
2553 ✗ if (target == current) { setStatus(tr("No unread messages")); return true; }
2554 }
2555
1/1
✓ Branch 87 → 88 taken 2 times.
2 target = qBound(0, target, count - 1);
2556
1/3
✗ Branch 88 → 89 not taken.
✓ Branch 88 → 90 taken 2 times.
✗ Branch 89 → 90 not taken.
2 if (target >= count - 20) search->fetchMore(); // infinite paging ahead of the cursor
2557
1/1
✓ Branch 90 → 91 taken 2 times.
2 const MailId mail = search->mailIdAt(target);
2558
5/8
✓ Branch 91 → 92 taken 2 times.
✓ Branch 92 → 93 taken 2 times.
✗ Branch 92 → 97 not taken.
✓ Branch 95 → 96 taken 2 times.
✗ Branch 95 → 97 not taken.
✓ Branch 98 → 99 taken 2 times.
✗ Branch 98 → 100 not taken.
✓ Branch 99 → 100 taken 2 times.
2 if (acceptsIdentity(mail) && mail != m_workspace.mailId()) m_workspace.select(mail);
2559 2 return true;
2560 18 }
2561
2562 // executeCommand handler: Next/previous inside a mail tab and the previous-folder jump.
2563 1114 std::optional<bool> QuickMailBackend::handleTabNavigation(const QString &id) {
2564
8/8
✓ Branch 3 → 4 taken 1094 times.
✓ Branch 3 → 6 taken 20 times.
✓ Branch 5 → 6 taken 16 times.
✓ Branch 5 → 9 taken 1078 times.
✓ Branch 7 → 8 taken 13 times.
✓ Branch 7 → 9 taken 23 times.
✓ Branch 10 → 11 taken 13 times.
✓ Branch 10 → 50 taken 1101 times.
1114 if ((id == "mail.next" || id == "mail.previous") && m_workspace.activeTab() != 0) {
2565 // This command is handled even at a boundary/rejection: never fall through
2566 // to the QML main-list navigation. Queue only small input intents, not mails.
2567
2/4
✓ Branch 15 → 16 taken 13 times.
✗ Branch 15 → 23 not taken.
✗ Branch 29 → 30 not taken.
✓ Branch 29 → 33 taken 13 times.
26 if (m_workspace.area() != "mail" || !configured() ||
2568
5/10
✓ Branch 13 → 14 taken 13 times.
✗ Branch 13 → 23 not taken.
✓ Branch 19 → 20 taken 13 times.
✗ Branch 19 → 23 not taken.
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 24 taken 13 times.
✓ Branch 25 → 26 taken 13 times.
✗ Branch 25 → 27 not taken.
✓ Branch 27 → 28 taken 13 times.
✗ Branch 27 → 29 not taken.
26 m_workspace.mailId().accountId != accountName() || !m_workspace.mailId().isValid()) return true;
2569
1/4
✗ Branch 34 → 35 not taken.
✓ Branch 34 → 41 taken 13 times.
✗ Branch 35 → 36 not taken.
✗ Branch 36 → 37 not taken.
13 if (m_navigation.size() >= 64) { setStatus(tr("Navigation is busy. Wait and retry.")); return true; }
2570
3/3
✓ Branch 42 → 43 taken 6 times.
✓ Branch 42 → 44 taken 7 times.
✓ Branch 45 → 46 taken 13 times.
13 m_navigation.append(id == "mail.next" ? 1 : -1);
2571 13 startNavigation();
2572 13 return true;
2573 }
2574
3/3
✓ Branch 51 → 52 taken 3 times.
✓ Branch 51 → 56 taken 1098 times.
✓ Branch 52 → 53 taken 3 times.
1101 if (id == "folder.previous") return selectFolder(m_previousFolder);
2575 1098 return std::nullopt;
2576 }
2577
2578 // executeCommand handler: First/last/page navigation in the main list (B4).
2579 1098 std::optional<bool> QuickMailBackend::handleMainListNavigation(const QString &id) {
2580
6/6
✓ Branch 5 → 6 taken 1092 times.
✓ Branch 5 → 10 taken 3 times.
✓ Branch 7 → 8 taken 1091 times.
✓ Branch 7 → 10 taken 1 time.
✓ Branch 9 → 10 taken 2 times.
✓ Branch 9 → 13 taken 1089 times.
3284 if ((id == "mail.first" || id == "mail.last" || id == "mail.page_down" ||
2581
6/6
✓ Branch 3 → 4 taken 1095 times.
✓ Branch 3 → 10 taken 3 times.
✓ Branch 11 → 12 taken 8 times.
✓ Branch 11 → 13 taken 1 time.
✓ Branch 14 → 15 taken 8 times.
✓ Branch 14 → 84 taken 1090 times.
3287 id == "mail.page_up") &&
2582 9 m_workspace.activeTab() == 0) {
2583 // Main-list global navigation (B4): first/last/page operate on the
2584 // connected mail projection; the router owns the G-G prefix timing.
2585
6/13
✓ Branch 17 → 18 taken 8 times.
✗ Branch 17 → 22 not taken.
✓ Branch 19 → 20 taken 8 times.
✗ Branch 19 → 22 not taken.
✓ Branch 20 → 21 taken 8 times.
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 23 taken 8 times.
✓ Branch 24 → 25 taken 8 times.
✗ Branch 24 → 26 not taken.
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 30 taken 8 times.
✗ Branch 88 → 89 not taken.
✗ Branch 88 → 90 not taken.
8 if (m_workspace.area() != "mail" || !configured() || activeRowCount() == 0) return true;
2586
1/1
✓ Branch 30 → 31 taken 8 times.
8 const int last = activeRowCount() - 1;
2587 8 int target = -1;
2588
2/2
✓ Branch 32 → 33 taken 3 times.
✓ Branch 32 → 34 taken 5 times.
8 if (id == "mail.first") target = 0;
2589
2/2
✓ Branch 35 → 36 taken 2 times.
✓ Branch 35 → 37 taken 3 times.
5 else if (id == "mail.last") target = last;
2590 else {
2591
2/2
✓ Branch 38 → 39 taken 3 times.
✓ Branch 39 → 40 taken 3 times.
3 const int current = activeRowForIdentity(WorkspaceState::encode(m_workspace.mailId()));
2592
1/2
✗ Branch 41 → 42 not taken.
✓ Branch 41 → 45 taken 3 times.
3 if (current < 0) return true;
2593 3 const int rows = qMax(1, m_pageRows);
2594
3/3
✓ Branch 47 → 48 taken 1 time.
✓ Branch 47 → 49 taken 2 times.
✓ Branch 50 → 51 taken 3 times.
3 target = qBound(0, current + (id == "mail.page_down" ? rows : -rows), last);
2595 }
2596
2/4
✓ Branch 52 → 53 taken 8 times.
✗ Branch 52 → 54 not taken.
✗ Branch 53 → 54 not taken.
✓ Branch 53 → 57 taken 8 times.
8 if (target < 0 || target > last) return true;
2597 const auto identity =
2598
5/5
✓ Branch 57 → 58 taken 8 times.
✓ Branch 59 → 60 taken 8 times.
✓ Branch 60 → 61 taken 8 times.
✓ Branch 61 → 62 taken 8 times.
✓ Branch 62 → 63 taken 8 times.
8 WorkspaceState::decode(activeMailAt(target).value("identity").toMap());
2599
5/6
✓ Branch 69 → 70 taken 8 times.
✗ Branch 69 → 74 not taken.
✓ Branch 72 → 73 taken 4 times.
✓ Branch 72 → 74 taken 4 times.
✓ Branch 75 → 76 taken 4 times.
✓ Branch 75 → 80 taken 4 times.
8 if (identity.isValid() && identity != m_workspace.mailId())
2600
2/2
✓ Branch 76 → 77 taken 4 times.
✓ Branch 77 → 78 taken 4 times.
4 m_workspace.selectIdentity(WorkspaceState::encode(identity));
2601 8 return true;
2602 8 }
2603 1090 return std::nullopt;
2604 }
2605
2606 // executeCommand handler: Thread view toggle and next-unread navigation.
2607 1090 std::optional<bool> QuickMailBackend::handleViewCommand(const QString &id) {
2608
8/10
✓ Branch 3 → 4 taken 11 times.
✓ Branch 3 → 10 taken 1079 times.
✓ Branch 5 → 6 taken 11 times.
✗ Branch 5 → 10 not taken.
✓ Branch 8 → 9 taken 11 times.
✗ Branch 8 → 10 not taken.
✓ Branch 11 → 12 taken 11 times.
✓ Branch 11 → 13 taken 1079 times.
✓ Branch 13 → 14 taken 11 times.
✓ Branch 13 → 18 taken 1079 times.
1090 if (id == "view.thread" && m_workspace.activeTab() == 0 && m_workspace.area() == "mail") {
2609 11 setThreaded(!m_threaded);
2610 11 return true;
2611 }
2612
8/10
✓ Branch 19 → 20 taken 6 times.
✓ Branch 19 → 26 taken 1073 times.
✓ Branch 21 → 22 taken 6 times.
✗ Branch 21 → 26 not taken.
✓ Branch 24 → 25 taken 6 times.
✗ Branch 24 → 26 not taken.
✓ Branch 27 → 28 taken 6 times.
✓ Branch 27 → 29 taken 1073 times.
✓ Branch 29 → 30 taken 6 times.
✓ Branch 29 → 72 taken 1073 times.
1079 if (id == "mail.next_unread" && m_workspace.activeTab() == 0 && m_workspace.area() == "mail") {
2613 // Widgets parity (Space): advance to the next unread mail in the visible
2614 // order, wrapping once. The cross-folder jump stays open parity work.
2615
6/7
✓ Branch 31 → 32 taken 5 times.
✓ Branch 31 → 34 taken 1 time.
✓ Branch 32 → 33 taken 5 times.
✗ Branch 33 → 34 not taken.
✓ Branch 33 → 35 taken 5 times.
✓ Branch 36 → 37 taken 1 time.
✓ Branch 36 → 40 taken 5 times.
6 if (!configured() || activeRowCount() == 0) return true;
2616 // P90.16: unread state comes from the loaded projection role, not from
2617 // one synchronous SQLite header query per candidate.
2618 5 QAbstractItemModel *visible = threadedPresentation()
2619
1/2
✗ Branch 41 → 42 not taken.
✓ Branch 41 → 43 taken 5 times.
5 ? static_cast<QAbstractItemModel *>(&m_threadList)
2620 5 : static_cast<QAbstractItemModel *>(&m_mail);
2621 5 const int unreadRole = threadedPresentation() ? int(QuickThreadListModel::Unread)
2622 5 : int(MailPresentationModel::Unread);
2623
2/2
✓ Branch 46 → 47 taken 5 times.
✓ Branch 47 → 48 taken 5 times.
5 const int current = activeRowForIdentity(WorkspaceState::encode(m_workspace.mailId()));
2624
3/3
✓ Branch 63 → 64 taken 49 times.
✓ Branch 64 → 50 taken 48 times.
✓ Branch 64 → 65 taken 1 time.
49 for (int step = 1; step <= activeRowCount(); ++step) {
2625
1/1
✓ Branch 51 → 52 taken 48 times.
48 const int row = (qMax(current, 0) + step) % activeRowCount();
2626
5/5
✓ Branch 53 → 54 taken 48 times.
✓ Branch 54 → 55 taken 48 times.
✓ Branch 55 → 56 taken 48 times.
✓ Branch 57 → 58 taken 4 times.
✓ Branch 57 → 62 taken 44 times.
48 if (visible->data(visible->index(row, 0), unreadRole).toBool()) {
2627
1/1
✓ Branch 58 → 59 taken 4 times.
4 selectMailRow(row);
2628 4 return true;
2629 }
2630 }
2631
2/2
✓ Branch 65 → 66 taken 1 time.
✓ Branch 66 → 67 taken 1 time.
1 setStatus(tr("No unread messages"));
2632 1 return true;
2633 }
2634 1073 return std::nullopt;
2635 }
2636
2637 // executeCommand handler: Undo of the last mail action.
2638 1073 std::optional<bool> QuickMailBackend::handleUndoCommand(const QString &id) {
2639
6/6
✓ Branch 3 → 4 taken 1071 times.
✓ Branch 3 → 6 taken 2 times.
✓ Branch 5 → 6 taken 5 times.
✓ Branch 5 → 7 taken 1066 times.
✓ Branch 8 → 9 taken 7 times.
✓ Branch 8 → 27 taken 1066 times.
1073 if (id == "undo.normal" || id == "undo.standard") {
2640
2/2
✓ Branch 9 → 10 taken 2 times.
✓ Branch 9 → 13 taken 5 times.
7 if (m_actionPending) return false;
2641 // Sprint 98 principle 10: the key and the `undo` command answer either way.
2642
4/4
✓ Branch 15 → 16 taken 2 times.
✓ Branch 15 → 22 taken 3 times.
✓ Branch 16 → 17 taken 2 times.
✓ Branch 17 → 18 taken 2 times.
5 if (!m_session.undo()->canUndo()) { setStatus(tr("Nothing to undo")); return false; }
2643 3 m_session.undo()->undo();
2644 3 return true;
2645 }
2646 1066 return std::nullopt;
2647 }
2648
2649 // MAIL-SELECTION-ACTIONS: with more than one message selected in the main
2650 // folder view, the mail actions act on the whole selection.
2651 125 std::shared_ptr<const MailSelectionSnapshot> QuickMailBackend::actionSelection() const {
2652
7/8
✓ Branch 3 → 4 taken 90 times.
✓ Branch 3 → 8 taken 35 times.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 90 times.
✓ Branch 10 → 11 taken 90 times.
✓ Branch 10 → 12 taken 35 times.
✓ Branch 12 → 13 taken 35 times.
✓ Branch 12 → 14 taken 90 times.
125 if (m_workspace.activeTab() != 0 || m_workspace.area() != QLatin1String("mail")) return nullptr;
2653 90 const auto selection = m_selection.snapshot();
2654
4/6
✓ Branch 16 → 17 taken 25 times.
✓ Branch 16 → 23 taken 65 times.
✓ Branch 18 → 19 taken 25 times.
✗ Branch 18 → 23 not taken.
✓ Branch 21 → 22 taken 25 times.
✗ Branch 21 → 23 not taken.
90 return selection && selection->count > 1 && selection->folderPath == m_folderPath ? selection : nullptr;
2655 90 }
2656
2657 // S101.6.1 (D-13): the marked results of a search tab, grouped by folder.
2658 168 QMap<qint64, QList<qint64>> QuickMailBackend::searchBulkGroups() const {
2659 168 QMap<qint64, QList<qint64>> groups;
2660
3/3
✓ Branch 3 → 4 taken 6 times.
✓ Branch 3 → 7 taken 162 times.
✓ Branch 5 → 6 taken 6 times.
168 const MailSearchSession *search = m_workspace.activeIsSearch() ? m_searchTabs->active() : nullptr;
2661
6/7
✓ Branch 8 → 9 taken 6 times.
✓ Branch 8 → 11 taken 162 times.
✓ Branch 9 → 10 taken 6 times.
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 6 times.
✓ Branch 13 → 14 taken 162 times.
✓ Branch 13 → 16 taken 6 times.
168 if (!search || search->markedCount() < 2) return groups;
2662 // S106-294: a search tab keeps its marks across an account switch; folder
2663 // ids and UIDs only mean something in their own account's cache, so marks
2664 // of another account make no bulk group (the move and state commands then
2665 // have nothing to act on).
2666
5/5
✓ Branch 16 → 17 taken 6 times.
✓ Branch 17 → 18 taken 6 times.
✓ Branch 18 → 19 taken 6 times.
✓ Branch 31 → 20 taken 975 times.
✓ Branch 31 → 32 taken 5 times.
980 for (const MailId &id : search->markedIds()) {
2667
2/2
✓ Branch 24 → 25 taken 1 time.
✓ Branch 24 → 27 taken 974 times.
975 if (id.accountId != accountName()) return {};
2668
2/2
✓ Branch 27 → 28 taken 974 times.
✓ Branch 28 → 29 taken 974 times.
974 groups[id.folderId].append(id.uid);
2669 6 }
2670 5 return groups;
2671 168 }
2672
2673
9/14
✓ Branch 4 → 5 taken 96 times.
✓ Branch 4 → 8 taken 13 times.
✓ Branch 5 → 6 taken 96 times.
✓ Branch 6 → 7 taken 96 times.
✓ Branch 7 → 8 taken 2 times.
✓ Branch 7 → 9 taken 94 times.
✓ Branch 11 → 12 taken 96 times.
✓ Branch 11 → 13 taken 13 times.
✓ Branch 13 → 14 taken 109 times.
✗ Branch 13 → 15 not taken.
✗ Branch 17 → 18 not taken.
✗ Branch 17 → 19 not taken.
✗ Branch 21 → 22 not taken.
✗ Branch 21 → 23 not taken.
109 bool QuickMailBackend::bulkSelection() const { return actionSelection() || !searchBulkGroups().isEmpty(); }
2674
2675 17 bool QuickMailBackend::actOnSelection(MutationKind kind, const QString &label, bool desired,
2676 std::function<void(bool)> done) {
2677
4/4
✓ Branch 2 → 3 taken 17 times.
✓ Branch 3 → 4 taken 17 times.
✓ Branch 4 → 5 taken 1 time.
✓ Branch 4 → 33 taken 16 times.
17 if (const auto groups = searchBulkGroups(); !groups.isEmpty()) {
2678 // One state change per source folder; `done` runs once, after all.
2679
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 6 → 7 taken 1 time.
1 auto remaining = std::make_shared<int>(int(groups.size()));
2680
1/1
✓ Branch 7 → 8 taken 1 time.
1 auto journaled = std::make_shared<bool>(true);
2681
1/1
✓ Branch 9 → 10 taken 1 time.
1 auto finish = std::make_shared<std::function<void(bool)>>(std::move(done));
2682
4/4
✓ Branch 10 → 11 taken 1 time.
✓ Branch 26 → 27 taken 3 times.
✓ Branch 28 → 12 taken 2 times.
✓ Branch 28 → 29 taken 1 time.
3 for (auto it = groups.cbegin(); it != groups.cend(); ++it)
2683
1/1
✓ Branch 21 → 22 taken 2 times.
4 m_session.controller()->selectionStates()->setState(nullptr, it.key(), it.value(), kind, label, desired, true,
2684
1/5
✓ Branch 17 → 18 taken 2 times.
✗ Branch 71 → 72 not taken.
✗ Branch 71 → 73 not taken.
✗ Branch 74 → 75 not taken.
✗ Branch 74 → 76 not taken.
4 [remaining, journaled, finish](bool ok) {
2685
2/4
✓ Branch 3 → 4 taken 2 times.
✗ Branch 3 → 6 not taken.
✓ Branch 4 → 5 taken 2 times.
✗ Branch 4 → 6 not taken.
2 *journaled = *journaled && ok;
2686
4/6
✓ Branch 9 → 10 taken 1 time.
✓ Branch 9 → 14 taken 1 time.
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 1 time.
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 19 taken 2 times.
2 if (--*remaining == 0 && *finish) (*finish)(*journaled);
2687 2 });
2688 1 return true;
2689 18 }
2690 16 const auto selection = actionSelection();
2691
2/2
✓ Branch 39 → 41 taken 4 times.
✓ Branch 39 → 42 taken 12 times.
16 if (!selection) return false;
2692
1/2
✗ Branch 43 → 44 not taken.
✓ Branch 43 → 48 taken 12 times.
12 if (m_selection.pending()) {
2693 ✗ setStatus(tr("Wait for the selection to finish."));
2694 ✗ return true;
2695 }
2696
1/1
✓ Branch 54 → 55 taken 12 times.
12 m_session.controller()->selectionStates()->setState(selection, -1, {}, kind, label, desired, true, std::move(done));
2697 12 return true;
2698 16 }
2699
2700 // executeCommand handler: Junk, archive and delete of the selected mail.
2701 864 std::optional<bool> QuickMailBackend::handleMailMoveCommand(const QString &id, const MailId &selected) {
2702
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 76 taken 863 times.
864 if (id == "mail.junk") {
2703 // Widgets parity (T-407): $Junk label plus a journaled move into the
2704 // Junk folder. The removal follow-up selects the neighbouring mail.
2705
1/4
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 12 taken 1 time.
✗ Branch 6 → 7 not taken.
✗ Branch 7 → 8 not taken.
1 if (m_junkFolder.isEmpty()) { setStatus(tr("No Junk folder found")); return true; }
2706
3/6
✓ Branch 12 → 13 taken 1 time.
✗ Branch 12 → 15 not taken.
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 1 time.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 21 taken 1 time.
1 if (m_actionPending || m_selection.pending()) return true;
2707
2/3
✓ Branch 21 → 22 taken 1 time.
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 41 taken 1 time.
1 if (bulkSelection()) {
2708 // The label is journaled before the move (different writers).
2709 ✗ const QPointer<QuickMailBackend> owner(this);
2710 ✗ const QString junk = m_junkFolder;
2711 ✗ actOnSelection(MutationKind::Keyword, QStringLiteral("$Junk"), true, [owner, junk](bool journaled) {
2712 ✗ if (owner && journaled) owner->moveSelectedMail(junk);
2713 ✗ });
2714 ✗ return true;
2715 ✗ }
2716
3/5
✓ Branch 44 → 45 taken 1 time.
✓ Branch 47 → 48 taken 1 time.
✓ Branch 47 → 49 taken 1 time.
✗ Branch 181 → 182 not taken.
✗ Branch 181 → 183 not taken.
3 const auto junkHeaders = m_session.cache()->headersByIds({selected});
2717
1/1
✓ Branch 54 → 55 taken 1 time.
2 m_session.controller()->addLabelInFolder(selected.uid, selected.folderId, QStringLiteral("$Junk"));
2718
1/1
✓ Branch 58 → 59 taken 1 time.
1 moveCurrentMailToFolder(m_junkFolder);
2719
3/3
✓ Branch 59 → 60 taken 1 time.
✓ Branch 61 → 62 taken 1 time.
✓ Branch 62 → 63 taken 1 time.
2 setStatus(tr("Marked as junk → %1").arg(m_junkFolder));
2720 // S95.5.2: offer a rule for the sender (only with a rule server).
2721
3/4
✓ Branch 66 → 67 taken 1 time.
✗ Branch 66 → 72 not taken.
✓ Branch 68 → 69 taken 1 time.
✓ Branch 69 → 70 taken 1 time.
1 if (!junkHeaders.isEmpty()) m_rules.offerJunkRule(RuleDraftFactory::bareAddress(junkHeaders.first().from));
2722 1 return true;
2723 1 }
2724
2/2
✓ Branch 77 → 78 taken 10 times.
✓ Branch 77 → 104 taken 853 times.
863 if (id == "mail.archive") {
2725
1/4
✗ Branch 79 → 80 not taken.
✓ Branch 79 → 86 taken 10 times.
✗ Branch 80 → 81 not taken.
✗ Branch 81 → 82 not taken.
10 if (m_archiveFolder.isEmpty()) { setStatus(tr("No Archive folder found")); return true; }
2726
2/2
✓ Branch 87 → 88 taken 2 times.
✓ Branch 87 → 92 taken 8 times.
10 if (bulkSelection()) { moveSelectedMail(m_archiveFolder); return true; }
2727
1/2
✓ Branch 93 → 94 taken 8 times.
✗ Branch 93 → 101 not taken.
8 if (moveCurrentMailToFolder(m_archiveFolder))
2728
3/3
✓ Branch 94 → 95 taken 8 times.
✓ Branch 96 → 97 taken 8 times.
✓ Branch 97 → 98 taken 8 times.
16 setStatus(tr("Archived → %1").arg(m_archiveFolder));
2729 8 return true;
2730 }
2731
2/2
✓ Branch 105 → 106 taken 4 times.
✓ Branch 105 → 151 taken 849 times.
853 if (id == "mail.delete") {
2732
1/4
✗ Branch 107 → 108 not taken.
✓ Branch 107 → 114 taken 4 times.
✗ Branch 108 → 109 not taken.
✗ Branch 109 → 110 not taken.
4 if (m_trashFolder.isEmpty()) { setStatus(tr("No Trash folder found")); return true; }
2733 // F-09 (SPRINT-104, D-4): `d` IN the Trash deletes for good — with
2734 // an explicit second press as the confirmation and no Undo (IMAP
2735 // cannot undo an EXPUNGE). The first press only arms.
2736
1/2
✗ Branch 115 → 116 not taken.
✓ Branch 115 → 133 taken 4 times.
4 if (m_folderPath == m_trashFolder) {
2737 ✗ ensureDeleteForever();
2738 ✗ if (!m_deleteForever->armOrDelete(selected, deleteForeverUids(selected)))
2739 ✗ setStatus(tr("Delete for good? Press %1 again — this cannot be undone.").arg(tr("d")));
2740 ✗ return true;
2741 }
2742
2/2
✓ Branch 134 → 135 taken 1 time.
✓ Branch 134 → 139 taken 3 times.
4 if (bulkSelection()) { moveSelectedMail(m_trashFolder); return true; }
2743
1/2
✓ Branch 140 → 141 taken 3 times.
✗ Branch 140 → 148 not taken.
3 if (moveCurrentMailToFolder(m_trashFolder))
2744
3/3
✓ Branch 141 → 142 taken 3 times.
✓ Branch 143 → 144 taken 3 times.
✓ Branch 144 → 145 taken 3 times.
6 setStatus(tr("Deleted → %1").arg(m_trashFolder));
2745 3 return true;
2746 }
2747 849 return std::nullopt;
2748 1 }
2749
2750 // executeCommand handler: Open the selected mail and cycle its reading mode.
2751 849 std::optional<bool> QuickMailBackend::handleMailViewCommand(const QString &id, const MailId &selected) {
2752
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 8 taken 849 times.
849 if (id == "mail.open") {
2753 // Explicit open/read trigger (o): identical to the selection path.
2754 ✗ requestSelectedMail(selected);
2755 ✗ return true;
2756 }
2757
5/6
✓ Branch 9 → 10 taken 846 times.
✓ Branch 9 → 12 taken 3 times.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 846 times.
✓ Branch 14 → 15 taken 3 times.
✓ Branch 14 → 50 taken 846 times.
849 if (id == "mail.reading_mode" || id == "mail.reading_mode_back") {
2758 // Cycle the effective reading mode: text → light → html → text
2759 // (Sprint 105: Shift+H the other way round).
2760
5/10
✓ Branch 15 → 16 taken 1 time.
✓ Branch 15 → 37 taken 2 times.
✓ Branch 17 → 18 taken 1 time.
✗ Branch 17 → 37 not taken.
✓ Branch 31 → 32 taken 3 times.
✓ Branch 31 → 33 taken 1 time.
✗ Branch 55 → 56 not taken.
✗ Branch 55 → 57 not taken.
✗ Branch 73 → 74 not taken.
✗ Branch 73 → 75 not taken.
7 static const QStringList modes = {QStringLiteral("text"), QStringLiteral("light"), QStringLiteral("html")};
2761 3 const int current = std::max<int>(0, int(modes.indexOf(m_workspace.readingMode())));
2762
1/2
✓ Branch 42 → 43 taken 3 times.
✗ Branch 42 → 44 not taken.
3 const int step = id == "mail.reading_mode" ? 1 : 2;
2763 3 m_workspace.setReadingMode(modes.at((current + step) % 3));
2764 3 return true;
2765 }
2766 846 return std::nullopt;
2767 4 }
2768
2769 // executeCommand handler: Toggle a standard label or clear all labels of the selected mail.
2770 846 std::optional<bool> QuickMailBackend::handleLabelCommand(const QString &id, const MailId &selected) {
2771
7/10
✓ Branch 6 → 7 taken 5 times.
✓ Branch 6 → 15 taken 3 times.
✓ Branch 8 → 9 taken 4 times.
✓ Branch 8 → 15 taken 1 time.
✓ Branch 10 → 11 taken 4 times.
✗ Branch 10 → 15 not taken.
✓ Branch 18 → 19 taken 4 times.
✓ Branch 18 → 54 taken 842 times.
✗ Branch 127 → 128 not taken.
✗ Branch 127 → 129 not taken.
858 if (id.startsWith("label.") && id != "label.clear" && id != "label.menu" &&
2772
6/8
✓ Branch 2 → 3 taken 846 times.
✓ Branch 3 → 4 taken 846 times.
✓ Branch 4 → 5 taken 8 times.
✓ Branch 4 → 15 taken 838 times.
✓ Branch 13 → 14 taken 4 times.
✗ Branch 13 → 15 not taken.
✓ Branch 16 → 17 taken 846 times.
✗ Branch 16 → 18 not taken.
858 id.size() == 7 && id.at(6).isDigit()) {
2773 // Toggle one standard IMAP label ($label1..5).
2774
1/1
✓ Branch 23 → 24 taken 4 times.
8 const QString keyword = QStringLiteral("$label") + id.at(6);
2775
1/1
✓ Branch 26 → 27 taken 4 times.
4 const QStringList labels = selectedLabels();
2776 // A selection follows the current message: it has the label → remove it from all.
2777
2/3
✓ Branch 29 → 30 taken 4 times.
✗ Branch 31 → 32 not taken.
✓ Branch 31 → 35 taken 4 times.
4 if (actOnSelection(MutationKind::Keyword, keyword, !labels.contains(keyword, Qt::CaseInsensitive))) return true;
2778
2/2
✓ Branch 36 → 37 taken 1 time.
✓ Branch 36 → 42 taken 3 times.
4 if (labels.contains(keyword, Qt::CaseInsensitive))
2779
1/1
✓ Branch 39 → 40 taken 1 time.
1 m_session.controller()->removeLabelInFolder(selected.uid, selected.folderId, keyword, labels);
2780 else
2781
1/1
✓ Branch 44 → 45 taken 3 times.
3 m_session.controller()->addLabelInFolder(selected.uid, selected.folderId, keyword, labels);
2782
1/1
✓ Branch 47 → 48 taken 4 times.
4 emit selectedLabelsChanged();
2783 4 return true;
2784 4 }
2785
2/2
✓ Branch 55 → 56 taken 3 times.
✓ Branch 55 → 120 taken 839 times.
842 if (id == "label.clear") {
2786
1/1
✓ Branch 56 → 57 taken 3 times.
3 const QStringList labels = selectedLabels();
2787
2/3
✓ Branch 57 → 58 taken 3 times.
✗ Branch 58 → 59 not taken.
✓ Branch 58 → 99 taken 3 times.
3 if (bulkSelection()) {
2788 // The standard labels and every label of the current message, from all.
2789 ✗ QStringList keywords;
2790 ✗ for (int i = 1; i <= 5; ++i) keywords << QStringLiteral("$label%1").arg(i);
2791 ✗ for (const auto &label : labels)
2792 ✗ if (!ImapResponseParser::isInternalKeyword(label) && !keywords.contains(label, Qt::CaseInsensitive)) keywords << label;
2793 ✗ for (const auto &keyword : keywords) actOnSelection(MutationKind::Keyword, keyword, false);
2794 ✗ return true;
2795 ✗ }
2796 // Each removal commits visually at once; the next call sees the rest.
2797 3 QStringList remaining = labels;
2798
2/2
✓ Branch 113 → 102 taken 2 times.
✓ Branch 113 → 114 taken 3 times.
5 for (const auto &label : labels) {
2799
2/3
✓ Branch 103 → 104 taken 2 times.
✗ Branch 104 → 105 not taken.
✓ Branch 104 → 106 taken 2 times.
2 if (ImapResponseParser::isInternalKeyword(label)) continue;
2800
1/1
✓ Branch 108 → 109 taken 2 times.
2 m_session.controller()->removeLabelInFolder(selected.uid, selected.folderId, label, remaining);
2801
1/1
✓ Branch 110 → 111 taken 2 times.
2 remaining.removeAll(label);
2802 }
2803
1/1
✓ Branch 114 → 115 taken 3 times.
3 emit selectedLabelsChanged();
2804 3 return true;
2805 3 }
2806 839 return std::nullopt;
2807 }
2808
2809 // executeCommand handler: Seen/starred toggles of the selected mail (last handler).
2810 839 std::optional<bool> QuickMailBackend::handleFlagCommand(const QString &id, const MailId &selected) {
2811
9/9
✓ Branch 3 → 4 taken 837 times.
✓ Branch 3 → 6 taken 2 times.
✓ Branch 5 → 6 taken 29 times.
✓ Branch 5 → 9 taken 808 times.
✓ Branch 6 → 7 taken 31 times.
✓ Branch 7 → 8 taken 1 time.
✓ Branch 7 → 9 taken 30 times.
✓ Branch 10 → 11 taken 1 time.
✓ Branch 10 → 37 taken 838 times.
839 if ((id == "mail.toggle_read" || id == "mail.toggle_star") && bulkSelection()) {
2812 // A selection follows the current message: read → all unread, and so on.
2813
1/1
✓ Branch 12 → 13 taken 1 time.
1 const auto header = m_session.cache()->header(selected.folderId, selected.uid);
2814 1 const bool read = id == "mail.toggle_read";
2815
3/8
✓ Branch 15 → 16 taken 1 time.
✗ Branch 15 → 24 not taken.
✓ Branch 16 → 17 taken 1 time.
✗ Branch 16 → 20 not taken.
✓ Branch 19 → 23 taken 1 time.
✗ Branch 19 → 24 not taken.
✗ Branch 22 → 23 not taken.
✗ Branch 22 → 24 not taken.
1 const bool current = header && (read ? header->isSeen() : header->isFlagged());
2816
2/3
✓ Branch 27 → 28 taken 1 time.
✗ Branch 27 → 29 not taken.
✓ Branch 30 → 31 taken 1 time.
1 return actOnSelection(read ? MutationKind::Seen : MutationKind::Flagged, {}, !current);
2817 1 }
2818
2/2
✓ Branch 38 → 39 taken 1 time.
✓ Branch 38 → 60 taken 837 times.
838 if (id == "mail.toggle_read") {
2819 1 std::optional<bool> visibleSeen;
2820
2/6
✗ Branch 40 → 41 not taken.
✓ Branch 40 → 44 taken 1 time.
✗ Branch 42 → 43 not taken.
✗ Branch 42 → 44 not taken.
✗ Branch 45 → 46 not taken.
✓ Branch 45 → 57 taken 1 time.
1 if (threadedPresentation() && m_threadList.indexed()) {
2821 ✗ const int row = m_threadList.rowForIdentity(WorkspaceState::encode(selected));
2822 ✗ if (row >= 0)
2823 ✗ visibleSeen = !m_threadList.data(m_threadList.index(row, 0),
2824 ✗ QuickThreadListModel::Unread).toBool();
2825 }
2826
1/1
✓ Branch 58 → 59 taken 1 time.
1 m_session.controller()->toggleReadStatusInFolder(selected.uid, selected.folderId, visibleSeen);
2827 }
2828
2/2
✓ Branch 61 → 62 taken 29 times.
✓ Branch 61 → 83 taken 808 times.
837 else if (id == "mail.toggle_star") {
2829 29 std::optional<bool> visibleStarred;
2830
2/6
✗ Branch 63 → 64 not taken.
✓ Branch 63 → 67 taken 29 times.
✗ Branch 65 → 66 not taken.
✗ Branch 65 → 67 not taken.
✗ Branch 68 → 69 not taken.
✓ Branch 68 → 80 taken 29 times.
29 if (threadedPresentation() && m_threadList.indexed()) {
2831 ✗ const int row = m_threadList.rowForIdentity(WorkspaceState::encode(selected));
2832 ✗ if (row >= 0)
2833 ✗ visibleStarred = m_threadList.data(m_threadList.index(row, 0),
2834 ✗ QuickThreadListModel::Starred).toBool();
2835 }
2836
1/1
✓ Branch 81 → 82 taken 29 times.
29 m_session.controller()->toggleStarredInFolder(selected.uid, selected.folderId,
2837 visibleStarred);
2838 }
2839 808 else return false;
2840 30 return true;
2841 }
2842
2843 // Ordered command handlers: the first handler that recognizes the command
2844 // decides (the order is part of the behaviour — e.g. Escape first closes the
2845 // suggestions, then a search). Mail handlers run only for an admitted
2846 // selected mail. S91.2.7: formerly one 235-line if-chain.
2847 1184 bool QuickMailBackend::executeCommand(const QString &id) {
2848 using Handler = std::optional<bool> (QuickMailBackend::*)(const QString &);
2849 static constexpr Handler beforeSelection[] = {
2850 &QuickMailBackend::handleSuggestionCommand,
2851 &QuickMailBackend::handleSurfaceClose,
2852 &QuickMailBackend::handleIndexedNavigation,
2853 &QuickMailBackend::handleSearchTabNavigation,
2854 &QuickMailBackend::handleTabNavigation,
2855 &QuickMailBackend::handleMainListNavigation,
2856 &QuickMailBackend::handleViewCommand,
2857 &QuickMailBackend::handleUndoCommand,
2858 };
2859
2/2
✓ Branch 13 → 3 taken 8922 times.
✓ Branch 13 → 14 taken 1066 times.
9988 for (const Handler handler : beforeSelection)
2860
4/5
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 8922 times.
✓ Branch 6 → 7 taken 8922 times.
✓ Branch 8 → 9 taken 118 times.
✓ Branch 8 → 11 taken 8804 times.
8922 if (const auto handled = (this->*handler)(id)) return *handled;
2861 1066 const auto selected = m_workspace.mailId();
2862
3/3
✓ Branch 16 → 17 taken 1066 times.
✓ Branch 17 → 18 taken 202 times.
✓ Branch 17 → 19 taken 864 times.
1066 if (!acceptsIdentity(selected)) return false;
2863 using MailHandler = std::optional<bool> (QuickMailBackend::*)(const QString &, const MailId &);
2864 static constexpr MailHandler forSelectedMail[] = {
2865 &QuickMailBackend::handleMailMoveCommand,
2866 &QuickMailBackend::handleMailViewCommand,
2867 &QuickMailBackend::handleLabelCommand,
2868 &QuickMailBackend::handleFlagCommand,
2869 };
2870
1/2
✓ Branch 30 → 20 taken 3398 times.
✗ Branch 30 → 31 not taken.
3398 for (const MailHandler handler : forSelectedMail)
2871
4/5
✗ Branch 20 → 21 not taken.
✓ Branch 20 → 22 taken 3398 times.
✓ Branch 23 → 24 taken 3398 times.
✓ Branch 25 → 26 taken 864 times.
✓ Branch 25 → 28 taken 2534 times.
3398 if (const auto handled = (this->*handler)(id, selected)) return *handled;
2872 ✗ return false;
2873 1066 }
2874
2875 12 bool QuickMailBackend::moveCurrentMailToFolder(const QString &targetFolder) {
2876
4/8
✓ Branch 3 → 4 taken 12 times.
✗ Branch 3 → 7 not taken.
✓ Branch 4 → 5 taken 12 times.
✗ Branch 4 → 7 not taken.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 12 times.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 12 times.
12 if (targetFolder.isEmpty() || m_actionPending || m_selection.pending()) return false;
2877 12 const auto selected = m_workspace.mailId();
2878
5/8
✓ Branch 13 → 14 taken 12 times.
✓ Branch 14 → 15 taken 12 times.
✗ Branch 14 → 17 not taken.
✓ Branch 15 → 16 taken 12 times.
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 18 taken 12 times.
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 21 taken 12 times.
12 if (!acceptsIdentity(selected) || !m_folders.isSelectable(targetFolder)) return false;
2879 // P90.16: reserve the surviving neighbour before the removal lands.
2880
3/3
✓ Branch 21 → 22 taken 12 times.
✓ Branch 22 → 23 taken 12 times.
✓ Branch 23 → 24 taken 12 times.
12 reserveActionContinuity(activeRowForIdentity(WorkspaceState::encode(selected)));
2881
2/2
✓ Branch 29 → 30 taken 12 times.
✓ Branch 30 → 31 taken 12 times.
24 m_session.controller()->moves()->moveMailsToFolderFrom({selected.uid}, selected.folderId,
2882
1/1
✓ Branch 28 → 29 taken 12 times.
24 m_session.cache()->folderPath(selected.folderId), targetFolder);
2883 12 return true;
2884 12 }
2885
2886 // ─── Q06: independent compose sessions ───
2887
2888
2889
2890
2891 // ─── Q06: per-session attachments ───
2892
2893 // ── Folder properties (appearance, subscription, cache) ──
2894
2895 // P90 "first usable page" (cold/repair p95 <= 200 ms): a first indexed page
2896 // that is not ready after ProvisionalPageDelayMs is a thread-index build.
2897 // Show the folder's cached first page (date order) meanwhile; the indexed
2898 // page replaces it.
2899 5 void QuickMailBackend::requestProvisionalPage() {
2900 5 const qint64 folderId = m_threadList.indexedFolder();
2901
4/7
✓ Branch 4 → 5 taken 5 times.
✗ Branch 4 → 7 not taken.
✓ Branch 5 → 6 taken 5 times.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 5 times.
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 5 times.
5 if (!threadedPresentation() || !m_threadList.provisionalPageWanted(folderId)) return;
2902
1/1
✓ Branch 12 → 13 taken 5 times.
5 const auto state = m_session.cache()->folderPresentationState(folderId);
2903
1/1
✓ Branch 14 → 15 taken 5 times.
5 const QString path = m_session.cache()->folderPath(folderId);
2904
3/6
✓ Branch 16 → 17 taken 5 times.
✗ Branch 16 → 18 not taken.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 5 times.
✗ Branch 20 → 21 not taken.
✓ Branch 20 → 22 taken 5 times.
5 if (path.isEmpty() || state.uidValidity == 0) return;
2905 10 MailHeaderPageRequest request{accountName(), path, folderId, state.uidValidity, 0, {}, false,
2906 5 MailHeaderPageSnapshot::MaximumRows};
2907
1/1
✓ Branch 26 → 27 taken 5 times.
5 const QPointer<QuickMailBackend> owner(this);
2908 5 const auto epoch = state.uidValidity;
2909 5 const auto generation = m_session.accounts()->generation();
2910
2/2
✓ Branch 29 → 30 taken 5 times.
✓ Branch 35 → 36 taken 5 times.
20 MailDisplayReadService::shared()->readHeaderPage(&m_provisionalPageOwner,
2911 10 m_session.cache()->databasePath(), request,
2912
1/3
✓ Branch 31 → 32 taken 5 times.
✗ Branch 59 → 60 not taken.
✗ Branch 59 → 61 not taken.
10 [owner, folderId, epoch, generation](MailHeaderPageSnapshot snapshot) {
2913
3/6
✓ Branch 3 → 4 taken 5 times.
✗ Branch 3 → 9 not taken.
✓ Branch 7 → 8 taken 5 times.
✗ Branch 7 → 9 not taken.
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 13 taken 5 times.
10 if (!owner || generation != owner->m_session.accounts()->generation() ||
2914
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 5 times.
5 snapshot.outcome != MailHeaderPageSnapshot::Outcome::Ready) return;
2915
4/4
✓ Branch 13 → 14 taken 5 times.
✓ Branch 14 → 15 taken 5 times.
✓ Branch 19 → 16 taken 1000 times.
✓ Branch 19 → 20 taken 5 times.
1005 for (auto &header : snapshot.headers) header.folderId = folderId;
2916
2/2
✓ Branch 22 → 23 taken 3 times.
✓ Branch 22 → 25 taken 2 times.
5 if (owner->m_threadList.showProvisionalPage(folderId, epoch, snapshot.headers))
2917 3 owner->clearThreadProjectionSlowFlag();
2918 });
2919 5 }
2920
2921 981 void QuickMailBackend::refreshTaskLinks() {
2922
1/1
✓ Branch 2 → 3 taken 981 times.
981 auto ids = m_pim.openTaskMessageIds();
2923
2/2
✓ Branch 4 → 5 taken 952 times.
✓ Branch 4 → 6 taken 29 times.
981 if (ids == m_openTaskMessageIds) return;
2924 29 m_openTaskMessageIds = std::move(ids);
2925
1/1
✓ Branch 8 → 9 taken 29 times.
29 m_mail.taskLinksChanged();
2926
1/1
✓ Branch 9 → 10 taken 29 times.
29 m_threadList.taskLinksChanged();
2927 981 }
2928