presentation/QuickThreadListModel.h
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #pragma once | ||
| 2 | |||
| 3 | #include "MailPresentationModel.h" | ||
| 4 | #include "ui/MailThreadModel.h" | ||
| 5 | #include "data/MailRequest.h" | ||
| 6 | #include "data/MailSelectionSnapshot.h" | ||
| 7 | #include "data/ThreadProjectionIndex.h" | ||
| 8 | #include <QCache> | ||
| 9 | #include <QThreadPool> | ||
| 10 | #include <QAbstractListModel> | ||
| 11 | #include <QHash> | ||
| 12 | #include <QSet> | ||
| 13 | #include <QVector> | ||
| 14 | #include <functional> | ||
| 15 | |||
| 16 | // QML thread projection: persistent worker-owned pages for large folders; | ||
| 17 | // the shared MailThreadModel supplies bounded small-folder/design fixtures. | ||
| 18 | // Expansion, filters and viewport anchors belong to this view owner. Role numbers and names deliberately match | ||
| 19 | // MailPresentationModel so one QML delegate serves both presentations; the | ||
| 20 | // thread-only roles extend the same block. | ||
| 21 | class QuickThreadListModel final : public QAbstractListModel { | ||
| 22 | ✗ | Q_OBJECT | |
| 23 | public: | ||
| 24 | enum Role { | ||
| 25 | Identity = Qt::UserRole + 100, Subject, Sender, Date, Unread, Starred, | ||
| 26 | Labels, Attachment, Selected, Folder, Suggestion, | ||
| 27 | Depth, ThreadCount, HasChildren, Expanded, LabelChips, OpenTask, RuleMatch, | ||
| 28 | Recipient, SizeText /* F100-30: list columns To and Size */, | ||
| 29 | Period /* search rows only; no role name here — keeps the numbers equal (S106-306) */, | ||
| 30 | MatchedInText /* S101.1.10: search rows only; always false here */, | ||
| 31 | Answered /* status column: the IMAP Answered flag */ | ||
| 32 | }; | ||
| 33 | explicit QuickThreadListModel(MailThreadModel *source, QString accountId, QObject *parent = nullptr); | ||
| 34 | ~QuickThreadListModel() override; | ||
| 35 | // Large folders use persistent pages. The legacy source remains the | ||
| 36 | // bounded small-folder/design-fixture implementation of the same roles. | ||
| 37 | void configureIndexedFolder(const QString &database, qint64 folder, quint32 epoch, | ||
| 38 | qint64 revision, qint64 anchorUid = 0, | ||
| 39 | const QSet<qint64> &excludedUids = {}); | ||
| 40 | void leaveIndexedFolder(); | ||
| 41 | // Cold thread-index build (P90 "first usable page"): while the first | ||
| 42 | // indexed page of `folder` is still pending and nothing is shown, plain | ||
| 43 | // cached rows (date order, no threading) are shown provisionally. The | ||
| 44 | // first indexed page replaces them. False when the state no longer fits. | ||
| 45 | bool showProvisionalPage(qint64 folder, quint32 epoch, const QList<MailHeader> &headers); | ||
| 46 | 44 | bool provisionalPageWanted(qint64 folder) const { | |
| 47 |
6/8✓ Branch 2 → 3 taken 44 times.
✗ Branch 2 → 10 not taken.
✓ Branch 3 → 4 taken 42 times.
✓ Branch 3 → 10 taken 2 times.
✓ Branch 5 → 6 taken 32 times.
✓ Branch 5 → 10 taken 10 times.
✓ Branch 7 → 8 taken 32 times.
✗ Branch 7 → 10 not taken.
|
76 | return m_indexed && m_indexPending && m_indexRows.isEmpty() && !filterActive() && |
| 48 |
2/2✓ Branch 8 → 9 taken 31 times.
✓ Branch 8 → 10 taken 1 time.
|
76 | m_indexRequest.folderId == folder; |
| 49 | } | ||
| 50 | void cancelIndexedRequest(); | ||
| 51 | // Folder switch continuity: the visible rows are copied and stay on | ||
| 52 | // screen, inert (no identity, key or row lookup), while the model | ||
| 53 | // silently clears and loads the next folder. The first non-empty | ||
| 54 | // content (indexed page, provisional page, rebuilt thread list) or | ||
| 55 | // releaseHeldRows() replaces them in one reset, so no empty frame is | ||
| 56 | // rendered between two folders. Invariant: while holding, the | ||
| 57 | // underlying rows are empty. | ||
| 58 | void holdRowsForFolderSwitch(); | ||
| 59 | void releaseHeldRows(); | ||
| 60 | 58 | bool holdingPreviousFolder() const { return m_holding; } | |
| 61 | // Estimated bytes of the published indexed rows (the bounded window). | ||
| 62 | qint64 retainedRowBytes() const; | ||
| 63 | private: | ||
| 64 | // Ends a hold once the underlying rows have content: one reset swaps | ||
| 65 | // the held rows for them. | ||
| 66 | void swapHeldRowsForContent(); | ||
| 67 | public: | ||
| 68 | // P90.16: force a Replace reload even when identity and revision are | ||
| 69 | // unchanged (journal phase transitions change the pending-move | ||
| 70 | // exclusion set, not the revision). | ||
| 71 | void refreshIndexedExclusions(const QSet<qint64> &excludedUids); | ||
| 72 | // Fence an in-flight page and hide a newly pending move immediately; | ||
| 73 | // a coalesced journal read later replaces the full exclusion set. | ||
| 74 | void markIndexedMovePending(qint64 folder, qint64 uid); | ||
| 75 | quint64 indexedExclusionGeneration() const { return m_indexExclusionGeneration; } | ||
| 76 | void abandonIndexedMoveRefresh(); | ||
| 77 | // S106-304: entries of the per-identity role caches (tests). | ||
| 78 | 2 | qsizetype identityCacheSize() const { return m_selectedCache.size() + m_suggestionCache.size(); } | |
| 79 | 5247 | bool indexed() const { return m_indexed; } | |
| 80 | 63 | qint64 indexedFolder() const { return m_indexRequest.folderId; } | |
| 81 | 547 | bool projectionPending() const { return m_indexPending; } | |
| 82 | // A pending page the user waits for: nothing is shown yet, or the user | ||
| 83 | // replaces the content (filter, jump). Background refreshes (revision | ||
| 84 | // catch-ups, move exclusions, paging, expand) keep the published rows. | ||
| 85 | 202 | bool projectionBlocking() const { | |
| 86 |
6/6✓ Branch 2 → 3 taken 94 times.
✓ Branch 2 → 7 taken 108 times.
✓ Branch 4 → 5 taken 47 times.
✓ Branch 4 → 6 taken 47 times.
✓ Branch 5 → 6 taken 16 times.
✓ Branch 5 → 7 taken 31 times.
|
202 | return m_indexPending && (m_indexRows.isEmpty() || m_indexLoadAnnounced); |
| 87 | } | ||
| 88 | Q_PROPERTY(bool projectionPending READ projectionPending NOTIFY projectionChanged) | ||
| 89 | 187 | bool hasMoreIndexed(bool before = false) const { | |
| 90 |
10/10✓ Branch 2 → 3 taken 137 times.
✓ Branch 2 → 8 taken 50 times.
✓ Branch 3 → 4 taken 81 times.
✓ Branch 3 → 8 taken 56 times.
✓ Branch 4 → 5 taken 52 times.
✓ Branch 4 → 6 taken 29 times.
✓ Branch 5 → 7 taken 18 times.
✓ Branch 5 → 8 taken 34 times.
✓ Branch 6 → 7 taken 27 times.
✓ Branch 6 → 8 taken 2 times.
|
187 | return m_indexed && !m_indexPending && (before ? m_indexHasBefore : m_indexHasAfter); |
| 91 | } | ||
| 92 | void fetchIndexed(bool before = false); | ||
| 93 | void jumpIndexed(bool last); | ||
| 94 | void revealIndexed(qint64 uid, bool before); | ||
| 95 | Q_INVOKABLE void rememberViewport(int row, qreal offset); | ||
| 96 | // P90.0 trace wiring: the revision the indexed projection currently | ||
| 97 | // exposes (page or catch-up state), -1 when none is loaded. | ||
| 98 | 19 | qint64 indexRevision() const { return m_indexRevision; } | |
| 99 | 10 | int indexedTotalCount() const { return m_indexTotalCount; } | |
| 100 | 9 | int indexedUnreadCount() const { return m_indexUnreadCount; } | |
| 101 | void updateIndexedFlags(qint64 folder, qint64 uid, quint32 flags); | ||
| 102 | void updateIndexedLabel(qint64 folder, qint64 uid, const QString &label, bool present); | ||
| 103 | // P90.15: visual seen/unseen commit for the indexed projection. Applies | ||
| 104 | // only the Seen bit of the loaded row (no SQL); the durable cache write | ||
| 105 | // later re-applies the authoritative flags through updateIndexedFlags. | ||
| 106 | void applyIndexedSeen(qint64 folder, qint64 uid, bool seen); | ||
| 107 | void applyIndexedStar(qint64 folder, qint64 uid, bool starred); | ||
| 108 | // P90.16: optimistic visual removal for journaled moves. The canonical | ||
| 109 | // header stays until the mutation is confirmed remotely; the projection | ||
| 110 | // refresh later publishes the authoritative page. The freed visual slot | ||
| 111 | // is taken over by the successor row without a model reset. | ||
| 112 | void removeIndexedUid(qint64 folder, qint64 uid); | ||
| 113 | void removeIndexedUids(qint64 folder, const QSet<qint64> &uids); | ||
| 114 | // Identity only (account, folder, epoch; no revision): decides whether a | ||
| 115 | // page of an older revision may fill an empty list during a fast sync. | ||
| 116 | 797 | void setProjectionIdentityValidator(std::function<bool(const ThreadProjectionRequest &)> validator) { | |
| 117 | 797 | m_acceptIndexedIdentity = std::move(validator); | |
| 118 | 797 | } | |
| 119 | 798 | void setProjectionValidator(std::function<bool(const ThreadProjectionRequest &, qint64)> validator) { | |
| 120 | 798 | m_acceptIndexed = std::move(validator); | |
| 121 | 798 | } | |
| 122 | 340 | QString filterText() const { return m_filterText; } | |
| 123 | 340 | bool showUnreadOnly() const { return m_showUnread; } | |
| 124 | 340 | bool showStarredOnly() const { return m_showStarred; } | |
| 125 | 340 | bool showWithAttachments() const { return m_showAttachments; } | |
| 126 | QHash<int, QByteArray> roleNames() const override; | ||
| 127 | int rowCount(const QModelIndex &parent = {}) const override; | ||
| 128 | QVariant data(const QModelIndex &index, int role) const override; | ||
| 129 | void setAccountId(const QString &accountId); | ||
| 130 | // Filter semantics mirror MailFilterProxyModel (text against subject and | ||
| 131 | // sender, unread-only, starred-only). A thread stays visible when only a | ||
| 132 | // descendant matches; ancestors of a kept row are always kept, and a | ||
| 133 | // collapsed ancestor still hides its whole subtree. | ||
| 134 | Q_INVOKABLE void filter(const QString &text, bool unreadOnly, bool starredOnly = false, bool withAttachments = false); | ||
| 135 | Q_INVOKABLE void clearFilters(); | ||
| 136 | // F100-66: threads ordered by their newest mail (the ThreadBuilder | ||
| 137 | // order) or by their first mail's sender or subject, either direction | ||
| 138 | // (MailSort::threadOrder); replies stay in tree order. A change reloads | ||
| 139 | // the first page (indexed) or rebuilds the rows. | ||
| 140 | void setSortOrder(const MailSortOrder &order); | ||
| 141 | const MailSortOrder &sortOrder() const { return m_sort; } | ||
| 142 | // Expansion is in-memory view state keyed by (folderId, uid), like the | ||
| 143 | // Widgets tree view. New threads start expanded (Widgets expandAll). | ||
| 144 | Q_INVOKABLE void toggleExpanded(int row); | ||
| 145 | Q_INVOKABLE bool isExpanded(int row) const; | ||
| 146 | Q_INVOKABLE QVariantMap mailAt(int row) const; | ||
| 147 | Q_INVOKABLE int rowForIdentity(const QVariantMap &identity) const; | ||
| 148 | MailSelectionKey keyAt(int row) const; | ||
| 149 | // Borrow the already-published preview header for an indexed selection. | ||
| 150 | // The caller must copy it synchronously; page publication can replace it. | ||
| 151 | const MailHeader *indexedHeader(qint64 folder, qint64 uid) const; | ||
| 152 | 798 | void setSelectionProvider(std::function<bool(qint64, qint64)> provider) { m_selected = std::move(provider); } | |
| 153 | void selectionChanged(); | ||
| 154 | void singleSelectionChanged(MailSelectionKey previous, MailSelectionKey current); | ||
| 155 | 797 | void setSuggestionProvider(std::function<QString(const MailId &)> provider) { m_suggestion = std::move(provider); } | |
| 156 | void suggestionsChanged(); | ||
| 157 | // S94.5.5: whether a raw Message-ID header has an open linked task. | ||
| 158 | 795 | void setTaskLinkProvider(std::function<bool(const QString &)> provider) { m_taskLink = std::move(provider); } | |
| 159 | // F95-14: whether an enabled server rule matches the row. | ||
| 160 | 795 | void setRuleMatchProvider(std::function<bool(const MailHeader &)> provider) { m_ruleMatch = std::move(provider); } | |
| 161 | void ruleMatchesChanged(); | ||
| 162 | void taskLinksChanged(); | ||
| 163 | signals: | ||
| 164 | void projectionChanged(); | ||
| 165 | void viewportRestoreRequested(const QVariantMap &identity, qreal offset); | ||
| 166 | void projectionReady(); | ||
| 167 | void projectionFailed(const QString &error); | ||
| 168 | void projectionStale(); | ||
| 169 | void mailFilterChanged(); | ||
| 170 | private: | ||
| 171 | enum class PageMode { Replace, Append, Prepend, Last }; | ||
| 172 | // P90.1: bounded catch-up chain per pending sequence. Folder-open reads | ||
| 173 | // absorb mid-read revision changes without dropping pending state; | ||
| 174 | // beyond the bound the explicit stale contract applies again. | ||
| 175 | static constexpr int MaximumCatchUpAttempts = 8; | ||
| 176 | static constexpr int MaximumDiffMoves = 64; | ||
| 177 | // Matches the ThreadProjectionIndex request limit for retained uids. | ||
| 178 | static constexpr int MaximumRetainedUnread = 4096; | ||
| 179 | void loadIndexed(PageMode mode, qint64 anchorUid = 0, bool restoreViewport = true); | ||
| 180 | // loadIndexed() steps (S92.4.2), in their former order: request, worker | ||
| 181 | // read, GUI-side completion (catch-up decision, Replace or rolling | ||
| 182 | // window publication, viewport re-pin) and the orphaned-page snapshot. | ||
| 183 | ThreadProjectionRequest indexedPageRequest(PageMode mode, qint64 anchorUid); | ||
| 184 | static ThreadProjectionPage buildIndexedPage(const ThreadProjectionRequest &request, | ||
| 185 | PageMode mode, const QString &database, | ||
| 186 | std::atomic_bool &cancelled, int &catchUps); | ||
| 187 | void finishIndexedPage(quint64 generation, const ThreadProjectionRequest &request, | ||
| 188 | PageMode mode, qreal viewportOffset, bool restoreViewport, | ||
| 189 | ThreadProjectionPage result, qint64 elapsedMicros, int catchUps, | ||
| 190 | const QString &database); | ||
| 191 | void retainOrphanedPage(const ThreadProjectionRequest &request, PageMode mode, | ||
| 192 | const ThreadProjectionPage &result, const QString &database); | ||
| 193 | bool publishReplacePage(const ThreadProjectionRequest &request, PageMode mode, | ||
| 194 | ThreadProjectionPage &result, bool revisionChanged); | ||
| 195 | void publishWindowPage(PageMode mode, const ThreadProjectionPage &result); | ||
| 196 | void repinViewportAfterReset(qreal viewportOffset, bool restoreViewport); | ||
| 197 | // configureIndexedFolder() steps (S92.4.2): the new-folder path. | ||
| 198 | bool enterIndexedFolder(const QString &database, qint64 folder, quint32 epoch, | ||
| 199 | qint64 revision, const QSet<qint64> &excludedUids); | ||
| 200 | QList<ThreadProjectionRow> provisionalIndexRows(qint64 folder, | ||
| 201 | const QSet<qint64> &excludedUids) const; | ||
| 202 | qint64 catchUpAnchor(qint64 requestedAnchor) const; | ||
| 203 | void publishIndexedContent(QList<ThreadProjectionRow> next); | ||
| 204 | bool applyIndexedRowDiff(const QList<ThreadProjectionRow> &next); | ||
| 205 | void toggleIndexed(int row); | ||
| 206 | void retainIndexedSnapshot(); | ||
| 207 | QString indexedKey() const; | ||
| 208 | static QString snapshotKey(const QString &database, const ThreadProjectionRequest &request); | ||
| 209 | // P90.2: bounded orphan accounting for background-completing page loads. | ||
| 210 | void forgetActiveRequest(const std::shared_ptr<std::atomic_bool> &token); | ||
| 211 | struct IndexSnapshot { | ||
| 212 | QList<ThreadProjectionRow> rows; | ||
| 213 | ThreadProjectionRequest request; | ||
| 214 | qint64 revision = -1; | ||
| 215 | int totalCount = -1, unreadCount = -1; | ||
| 216 | bool before = false, after = false; | ||
| 217 | qint64 viewportUid = 0; | ||
| 218 | qreal viewportOffset = 0; | ||
| 219 | }; | ||
| 220 | bool m_indexed = false, m_indexPending = false; | ||
| 221 | bool m_indexExclusionsDirty = false; | ||
| 222 | quint64 m_indexExclusionGeneration = 0; | ||
| 223 | bool m_indexHasBefore = false, m_indexHasAfter = false; | ||
| 224 | QString m_indexDatabase; | ||
| 225 | ThreadProjectionRequest m_indexRequest; | ||
| 226 | qint64 m_indexRevision = -1; | ||
| 227 | // Newest revision configureIndexedFolder() asked for; a page that lands | ||
| 228 | // behind it is published and followed by a bounded catch-up. | ||
| 229 | qint64 m_indexWantedRevision = -1; | ||
| 230 | int m_indexTotalCount = -1, m_indexUnreadCount = -1; | ||
| 231 | qint64 m_viewportUid = 0; | ||
| 232 | qreal m_viewportOffset = 0; | ||
| 233 | quint64 m_indexGeneration = 0; | ||
| 234 | int m_indexCatchUps = 0; | ||
| 235 | QList<ThreadProjectionRow> m_indexRows; | ||
| 236 | // P90.12: collapsed subtree spans retained for differential expand. | ||
| 237 | // Revision-fenced: any structural change discards them so a re-insert | ||
| 238 | // can never publish stale rows; bounded and cleared on folder changes. | ||
| 239 | struct CollapsedSpan { | ||
| 240 | qint64 revision = -1; | ||
| 241 | QList<ThreadProjectionRow> rows; | ||
| 242 | }; | ||
| 243 | QHash<qint64, CollapsedSpan> m_collapsedSpans; | ||
| 244 | qsizetype m_collapsedSpanRows = 0; | ||
| 245 | QThreadPool m_indexPool; | ||
| 246 | std::shared_ptr<std::atomic_bool> m_indexCancelled; | ||
| 247 | QList<std::shared_ptr<std::atomic_bool>> m_activeRequests; | ||
| 248 | QCache<QString, IndexSnapshot> m_indexSnapshots{3*1024}; // KiB, in addition to the active window | ||
| 249 | std::function<bool(const ThreadProjectionRequest &, qint64)> m_acceptIndexed; | ||
| 250 | std::function<bool(const ThreadProjectionRequest &)> m_acceptIndexedIdentity; | ||
| 251 | using Row = MailThreadModel::VisibleRow; | ||
| 252 | using MailKey = MailThreadModel::MailKey; | ||
| 253 | void rebuild(); | ||
| 254 | // Compute descendant visibility once, then append in the existing DFS | ||
| 255 | // order without rescanning each subtree for every visible ancestor. | ||
| 256 | bool collectMatches(const ThreadNode *node, | ||
| 257 | QSet<const ThreadNode *> *matching) const; | ||
| 258 | void appendVisible(ThreadNode *node, int depth, QVector<Row> *rows, | ||
| 259 | const QSet<const ThreadNode *> *matching = nullptr) const; | ||
| 260 | bool matches(const MailHeader &header) const; | ||
| 261 | void forgetShownUnread(); | ||
| 262 |
7/8✓ Branch 3 → 4 taken 4262 times.
✓ Branch 3 → 7 taken 10 times.
✓ Branch 4 → 5 taken 4249 times.
✓ Branch 4 → 7 taken 13 times.
✓ Branch 5 → 6 taken 4247 times.
✓ Branch 5 → 7 taken 2 times.
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 4247 times.
|
4272 | bool filterActive() const { return !m_filterText.isEmpty() || m_showUnread || m_showStarred || m_showAttachments; } |
| 263 | const MailHeader *headerAtRow(int row) const; | ||
| 264 | MailKey keyAtRow(int row) const; | ||
| 265 | void reindexRows(int from = 0); | ||
| 266 | // Remove a published indexed range without rebuilding the lookup and | ||
| 267 | // every role cache for surviving rows. | ||
| 268 | void removeIndexedRows(int first, int last); | ||
| 269 | MailThreadModel *m_source; | ||
| 270 | QString m_accountId; | ||
| 271 | QVector<Row> m_rows; | ||
| 272 | QHash<MailKey, int> m_rowByKey; | ||
| 273 | QSet<MailKey> m_collapsed; | ||
| 274 | QString m_filterText; | ||
| 275 | MailSortOrder m_sort; // MailSort::threadOrder | ||
| 276 | // Legacy (small-folder) roots in m_sort order. | ||
| 277 | QList<ThreadNode *> orderedRoots(const std::vector<std::unique_ptr<ThreadNode>> &roots) const; | ||
| 278 | bool m_showUnread = false; | ||
| 279 | // Mails this unread-filtered view already showed stay visible after they | ||
| 280 | // are read, until the filter or the folder changes (one folder at a time). | ||
| 281 | mutable QSet<MailKey> m_shownUnread; | ||
| 282 | bool m_indexLoadAnnounced = false; | ||
| 283 | bool m_holding = false; | ||
| 284 | QList<ThreadProjectionRow> m_heldRows; | ||
| 285 | QVector<bool> m_heldExpanded; | ||
| 286 | bool m_nextLoadAnnounced = false; | ||
| 287 | mutable qint64 m_shownUnreadFolder = -1; | ||
| 288 | bool m_showStarred = false; | ||
| 289 | bool m_showAttachments = false; | ||
| 290 | std::function<bool(qint64, qint64)> m_selected; | ||
| 291 | std::function<QString(const MailId &)> m_suggestion; | ||
| 292 | std::function<bool(const QString &)> m_taskLink; | ||
| 293 | std::function<bool(const MailHeader &)> m_ruleMatch; | ||
| 294 | // P90.12: last published per-identity role values so selection/suggestion | ||
| 295 | // changes invalidate only rows whose value actually flipped. | ||
| 296 | // Last value each view read through data() (or a published diff). A row | ||
| 297 | // absent here was never read, so a change needs no notification. | ||
| 298 | mutable QHash<MailKey, bool> m_selectedCache; | ||
| 299 | mutable QHash<MailKey, QString> m_suggestionCache; | ||
| 300 | // P90.12: materialized per-identity display roles — locale date text and | ||
| 301 | // label chips are computed once per header revision instead of on every | ||
| 302 | // delegate evaluation. Lazily filled from const data(). | ||
| 303 | mutable QHash<MailKey, QString> m_dateTextCache; | ||
| 304 | mutable QHash<MailKey, QVariantList> m_chipCache; | ||
| 305 | }; | ||
| 306 |