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