Branch data Line data Source code
1 : : #include "FolderTree.h"
2 : : #include "ui/MdiIconProvider.h"
3 : : #include "ui/ThemeManager.h"
4 : :
5 : : #include <QHeaderView>
6 : : #include <QIcon>
7 : : #include <QColorDialog>
8 : : #include <QLoggingCategory>
9 : : #include <QMenu>
10 : : #include <QMimeData>
11 : : #include <QPainter>
12 : : #include <QPalette>
13 : : #include <QSettings>
14 : : #include <QStandardItemModel>
15 : : #include <QStyledItemDelegate>
16 : : #include <QDragEnterEvent>
17 : : #include <QDataStream>
18 : :
19 : : #include "data/Models.h"
20 : :
21 [ + + + - : 243 : Q_LOGGING_CATEGORY(lcFolderTree, "mailjd.foldertree")
+ - - - ]
22 : :
23 : : // 67.B2: Folder icons are monochrome, tinted with the theme's secondary
24 : : // text color (ThemeManager falls back to the Light palette before any
25 : : // applyTheme(), so this is always a valid color).
26 : 191 : static QColor themedIconColor() {
27 : 191 : return QColor(
28 [ + - + - ]: 382 : ThemeManager::instance().color(QStringLiteral("@text_secondary")));
29 : : }
30 : :
31 : : // 67.B4: Paints the unread count as a modern badge pill (accent
32 : : // background, white text) right-aligned in the row. The base item
33 : : // (icon, name, hover/selected states) is painted by the style/QSS.
34 : : class FolderBadgeDelegate : public QStyledItemDelegate {
35 : : public:
36 : : using QStyledItemDelegate::QStyledItemDelegate;
37 : :
38 : 2077 : void paint(QPainter *painter, const QStyleOptionViewItem &option,
39 : : const QModelIndex &index) const override {
40 [ + - ]: 2077 : QStyledItemDelegate::paint(painter, option, index);
41 : :
42 : : // T-71.2: Paint the accent bar directly instead of relying on QSS
43 : : // border-left. Qt's stylesheet engine draws duplicate border geometries
44 : : // for item-view ::item:selected states on Qt 6.4 (shorthand/longhand
45 : : // and radius differences all trigger it). Painting here is reliable —
46 : : // same approach as StarDelegate/LabelDelegate.
47 : : // Paint at the very left edge of the viewport (x=0), not at
48 : : // option.rect.left() which is offset by the tree indentation.
49 [ + + ]: 2077 : if (option.state & QStyle::State_Selected) {
50 : : const QColor accent =
51 [ + - + - ]: 570 : ThemeManager::instance().color(QStringLiteral("@accent"));
52 [ + - ]: 285 : painter->fillRect(
53 : 570 : QRect(0, option.rect.top(), 3, option.rect.height()),
54 : : accent);
55 : : }
56 : :
57 [ + - + - ]: 2077 : const int count = index.data(FolderTree::UnreadCountRole).toInt();
58 [ + + ]: 2077 : if (count <= 0)
59 : 1914 : return;
60 : :
61 [ - + - - ]: 163 : const QString text = count > 999 ? QStringLiteral("999+")
62 [ - + + - ]: 163 : : QString::number(count);
63 [ + - ]: 163 : QFont badgeFont = option.font;
64 [ + - + - ]: 163 : badgeFont.setPointSizeF(qMax(7.5, badgeFont.pointSizeF() - 2));
65 [ + - ]: 163 : badgeFont.setBold(true);
66 [ + - ]: 163 : const QFontMetrics fm(badgeFont);
67 : :
68 : 163 : const int hPad = 6;
69 [ + - ]: 163 : const int pillH = fm.height() + 2;
70 [ + - ]: 163 : const int pillW = qMax(pillH, fm.horizontalAdvance(text) + 2 * hPad);
71 : 326 : QRect pill(option.rect.right() - pillW - 6,
72 : 163 : option.rect.center().y() - pillH / 2, pillW, pillH);
73 : :
74 [ + - ]: 163 : painter->save();
75 [ + - ]: 163 : painter->setRenderHint(QPainter::Antialiasing, true);
76 [ + - ]: 163 : painter->setPen(Qt::NoPen);
77 [ + - + - ]: 163 : painter->setBrush(QColor(
78 [ + - + - ]: 489 : ThemeManager::instance().color(QStringLiteral("@accent"))));
79 [ + - ]: 163 : painter->drawRoundedRect(pill, pillH / 2.0, pillH / 2.0);
80 [ + - ]: 163 : painter->setPen(Qt::white);
81 [ + - ]: 163 : painter->setFont(badgeFont);
82 [ + - ]: 163 : painter->drawText(pill, Qt::AlignCenter, text);
83 [ + - ]: 163 : painter->restore();
84 : 163 : }
85 : : };
86 : :
87 : 91 : FolderTree::FolderTree(QWidget *parent)
88 [ + - + - : 91 : : QTreeView(parent), m_model(new QStandardItemModel(this)) {
- + + - +
- - - ]
89 [ + - ]: 91 : setupUi();
90 : : // 67.B2: live theme switch re-tints all code-colored folder icons
91 [ + - ]: 91 : connect(&ThemeManager::instance(), &ThemeManager::themeChanged, this,
92 [ + - ]: 102 : [this](ThemeManager::Theme) { refreshFolderIcons(); });
93 : 91 : }
94 : :
95 : 91 : void FolderTree::setupUi() {
96 : 91 : setModel(m_model);
97 : 91 : setHeaderHidden(true);
98 [ + - ]: 91 : setEditTriggers(QAbstractItemView::NoEditTriggers);
99 : 91 : setSelectionMode(QAbstractItemView::SingleSelection);
100 : 91 : setAnimated(true);
101 : 91 : setExpandsOnDoubleClick(true);
102 : 91 : setIndentation(14);
103 : 91 : setUniformRowHeights(true);
104 : : // 67.B4: unread badge pill
105 [ + - + - : 91 : setItemDelegate(new FolderBadgeDelegate(this));
- + - - ]
106 : :
107 : : // T-103: Enable drop target
108 : 91 : setAcceptDrops(true);
109 : 91 : setDropIndicatorShown(true);
110 : :
111 : : // T-133: Explicit border: none prevents Breeze/system theme from adding
112 : : // borders on hover/selected states, which shifts content by ~1px.
113 : : // Sprint 64: Now handled by global ThemeManager (main.qss).
114 : :
115 : : // Selection → emit folderSelected signal
116 [ + - ]: 91 : connect(selectionModel(), &QItemSelectionModel::currentChanged, this,
117 [ + - ]: 91 : [this](const QModelIndex ¤t, const QModelIndex &) {
118 [ + + ]: 55 : if (current.isValid()) {
119 [ + - + - ]: 40 : auto path = current.data(FolderPathRole).toString();
120 [ + + ]: 40 : if (path == QLatin1String(SearchNodePath)) {
121 : : // Virtual search node: restore the search results view instead
122 : : // of switching to a real folder.
123 [ + - ]: 1 : emit searchNodeSelected();
124 [ + - ]: 39 : } else if (!path.isEmpty()) {
125 [ + - ]: 39 : emit folderSelected(path);
126 : : }
127 : 40 : }
128 : 55 : });
129 : :
130 : : // T-069/T-289: Context menu for folder management
131 : 91 : setContextMenuPolicy(Qt::CustomContextMenu);
132 : 91 : connect(this, &QWidget::customContextMenuRequested, this,
133 [ + - ]: 91 : [this](const QPoint &pos) {
134 [ + - ]: 4 : auto idx = indexAt(pos);
135 : :
136 : : // T-289: Right-click on empty area → "Neuer Ordner…" only
137 [ + + ]: 4 : if (!idx.isValid()) {
138 [ + - ]: 1 : QMenu menu(this);
139 [ + - + - ]: 1 : menu.addAction(tr("New Folder…"), [this]() {
140 [ + - ]: 1 : emit createFolderRequested(QString()); // empty = top-level
141 : 1 : });
142 [ + - + - : 1 : menu.exec(viewport()->mapToGlobal(pos));
+ - ]
143 : 1 : return;
144 : 1 : }
145 : :
146 [ + - + - ]: 3 : auto path = idx.data(FolderPathRole).toString();
147 [ - + ]: 3 : if (path.isEmpty())
148 : 0 : return;
149 : :
150 : : // Virtual search node: offer to close (cancel) the search.
151 [ + + ]: 3 : if (path == QLatin1String(SearchNodePath)) {
152 [ + - ]: 1 : QMenu menu(this);
153 [ + - + - ]: 1 : menu.addAction(tr("Close Search"), [this]() {
154 : 1 : emit searchNodeCloseRequested();
155 : 1 : });
156 [ + - + - : 1 : menu.exec(viewport()->mapToGlobal(pos));
+ - ]
157 : 1 : return;
158 : 1 : }
159 : :
160 [ + - + - ]: 2 : auto flags = idx.data(FolderFlagsRole).toStringList();
161 [ + - ]: 2 : bool special = isSpecialFolder(path, flags);
162 : :
163 [ + - ]: 2 : QMenu menu(this);
164 : :
165 : : // T-134: Properties dialog
166 [ + - + - ]: 2 : menu.addAction(tr("Edit Folder…"), [this, path]() {
167 : 1 : emit folderPropertiesRequested(path);
168 : 1 : });
169 : :
170 [ + - ]: 2 : menu.addSeparator();
171 : :
172 : : // T-289: Create subfolder
173 [ + - + - ]: 2 : menu.addAction(tr("New Subfolder…"), [this, path]() {
174 : 1 : emit createFolderRequested(path);
175 : 1 : });
176 : :
177 : : // T-289: Rename (disabled for special folders)
178 [ + - ]: 2 : auto *renameAction = menu.addAction(
179 [ + - ]: 4 : tr("Rename Folder…"), [this, path]() {
180 : 1 : emit renameFolderRequested(path);
181 : 1 : });
182 [ + - ]: 2 : renameAction->setEnabled(!special);
183 : :
184 : : // T-289: Move (disabled for special folders)
185 [ + - ]: 2 : auto *moveAction = menu.addAction(
186 [ + - ]: 4 : tr("Move Folder to…"), [this, path]() {
187 : 1 : emit moveFolderRequested(path);
188 : 1 : });
189 [ + - ]: 2 : moveAction->setEnabled(!special);
190 : :
191 [ + - ]: 2 : menu.addSeparator();
192 : :
193 : : // T-069: Quick action for hiding
194 [ + - + - ]: 2 : menu.addAction(tr("Hide Folder"), [this, path]() {
195 : 1 : emit folderHideRequested(path);
196 : 1 : });
197 : :
198 : : // T-200: Mark all mails in folder as read
199 [ + - + - ]: 2 : menu.addAction(tr("Mark All as Read"),
200 : 4 : [this, path]() {
201 : 1 : emit markAllReadRequested(path);
202 : 1 : });
203 : :
204 [ + - ]: 2 : menu.addSeparator();
205 : :
206 : : // T-289: Delete (disabled for special folders, red text)
207 [ + - ]: 2 : auto *deleteAction = menu.addAction(
208 [ + - ]: 4 : tr("Delete Folder…"), [this, path]() {
209 : 1 : emit deleteFolderRequested(path);
210 : 1 : });
211 [ + - ]: 2 : deleteAction->setEnabled(!special);
212 [ + + ]: 2 : if (!special) {
213 [ + - ]: 1 : auto f = deleteAction->font();
214 : : // No red color — just disable for special folders
215 : 1 : }
216 : :
217 [ + - + - : 2 : menu.exec(viewport()->mapToGlobal(pos));
+ - ]
218 [ + + ]: 3 : });
219 : 91 : }
220 : :
221 : : // T-289: Check if a folder is a special system folder that should not be
222 : : // deleted, renamed, or moved. Checks both IMAP flags and path heuristics.
223 : 61 : bool FolderTree::isSpecialFolder(const QString &path,
224 : : const QStringList &flags) {
225 : : // INBOX is always special
226 [ + + ]: 61 : if (path.compare(QStringLiteral("INBOX"), Qt::CaseInsensitive) == 0)
227 : 13 : return true;
228 : :
229 : : // Check IMAP special-use flags (RFC 6154)
230 : : static const QStringList specialFlags = {
231 : 7 : QStringLiteral("\\Sent"), QStringLiteral("\\Drafts"),
232 : 7 : QStringLiteral("\\Trash"), QStringLiteral("\\Archive"),
233 : 7 : QStringLiteral("\\Junk"), QStringLiteral("\\All"),
234 [ + + + - : 111 : QStringLiteral("\\Flagged")};
+ + - - -
- ]
235 [ + + ]: 52 : for (const auto &flag : flags) {
236 [ + + ]: 81 : for (const auto &sf : specialFlags) {
237 [ + + ]: 77 : if (flag.compare(sf, Qt::CaseInsensitive) == 0)
238 : 20 : return true;
239 : : }
240 : : }
241 : :
242 : 28 : return false;
243 [ + - - - : 56 : }
- - ]
244 : :
245 : 47 : void FolderTree::setFolders(const QList<FolderInfo> &folders) {
246 : 47 : m_searchItem = nullptr;
247 [ + - ]: 47 : m_model->clear();
248 : 47 : m_pathCache.clear(); // T-525: Reset path cache
249 : :
250 : : // T-069/T-078: Filter out hidden folders AND their children
251 [ + - + - : 47 : QSet<QString> hiddenSet(m_hiddenFolders.begin(), m_hiddenFolders.end());
+ - ]
252 : 47 : QList<FolderInfo> visible;
253 [ + + ]: 177 : for (const auto &f : folders) {
254 : 130 : bool isHidden = hiddenSet.contains(f.path);
255 : : // T-078: Also hide children of hidden folders (prefix match)
256 [ + + ]: 130 : if (!isHidden) {
257 [ + - + - : 130 : for (const auto &h : m_hiddenFolders) {
+ + ]
258 [ - + - - ]: 4 : QString delimiter = f.delimiter.isEmpty() ? "." : f.delimiter;
259 [ + - + - : 4 : if (f.path.startsWith(h + delimiter)) {
- + ]
260 : 0 : isHidden = true;
261 : 0 : break;
262 : : }
263 [ + - ]: 4 : }
264 : : }
265 [ + + ]: 130 : if (!isHidden)
266 [ + - ]: 126 : visible.append(f);
267 : : }
268 : :
269 : : // T-078: Store hidden set for findOrCreateParent to check
270 : 47 : m_hiddenSet = hiddenSet;
271 : :
272 : : // Sort folders: special folders first, then by path depth, then alphabetical
273 : 47 : auto sorted = visible;
274 [ + - + - : 47 : std::sort(sorted.begin(), sorted.end(),
+ - ]
275 : 241 : [](const FolderInfo &a, const FolderInfo &b) {
276 : : // INBOX always first
277 [ + + ]: 241 : if (a.path == "INBOX")
278 : 1 : return true;
279 [ + + ]: 240 : if (b.path == "INBOX")
280 : 113 : return false;
281 : :
282 : : // Special folders before regular ones
283 [ + - + - : 251 : auto isSpecialA = a.isSent() || a.isDrafts() || a.isTrash() ||
+ - + - +
+ ]
284 [ + + + - : 251 : a.isArchive() || a.isJunk();
+ - + - -
+ ]
285 [ + - + - : 252 : auto isSpecialB = b.isSent() || b.isDrafts() || b.isTrash() ||
+ - + - +
+ ]
286 [ + + + - : 252 : b.isArchive() || b.isJunk();
+ - + - -
+ ]
287 [ + + + + ]: 127 : if (isSpecialA && !isSpecialB)
288 : 3 : return true;
289 [ + + + + ]: 124 : if (!isSpecialA && isSpecialB)
290 : 3 : return false;
291 : :
292 : : // T-064: Sort by path depth (shorter paths first = parents
293 : : // before children)
294 [ - + - - ]: 121 : auto delimA = a.delimiter.isEmpty() ? "." : a.delimiter;
295 [ - + - - ]: 121 : auto delimB = b.delimiter.isEmpty() ? "." : b.delimiter;
296 [ + - ]: 121 : int depthA = a.path.count(delimA);
297 [ + - ]: 121 : int depthB = b.path.count(delimB);
298 [ + + ]: 121 : if (depthA != depthB)
299 : 32 : return depthA < depthB;
300 : :
301 : 89 : return a.name.compare(b.name, Qt::CaseInsensitive) < 0;
302 : 121 : });
303 : :
304 : : // Track whether we need a separator after special folders
305 : 47 : bool lastWasSpecial = false;
306 : 47 : bool separatorInserted = false;
307 : :
308 [ + - + - : 173 : for (const auto &folder : sorted) {
+ + ]
309 [ + - + + ]: 210 : bool isSpecial = (folder.path == "INBOX") || folder.isSent() ||
310 [ + - + - : 77 : folder.isDrafts() || folder.isTrash() ||
+ - + + ]
311 [ + + + - : 210 : folder.isArchive() || folder.isJunk();
+ + + - -
+ ]
312 : :
313 : : // Insert visible separator between special and regular folders (top-level)
314 [ + + + + : 149 : if (lastWasSpecial && !isSpecial && !separatorInserted &&
+ + + + ]
315 [ + - + + ]: 23 : !folder.path.contains(folder.delimiter)) {
316 [ + - + - : 20 : auto *separator = new QStandardItem();
- + - - ]
317 [ + - ]: 20 : separator->setSelectable(false);
318 [ + - ]: 20 : separator->setEnabled(false);
319 [ + - ]: 20 : separator->setData(true, SeparatorRole);
320 [ + - ]: 20 : separator->setSizeHint(QSize(0, 8));
321 : : // Thin horizontal line
322 [ + - ]: 40 : separator->setData(QStringLiteral("────────────────────"),
323 : : Qt::DisplayRole);
324 [ + - + - : 60 : separator->setForeground(QColor(ThemeManager::instance().color(
+ - + - ]
325 : 60 : QStringLiteral("@border_medium"))));
326 [ + - ]: 20 : QFont sepFont;
327 [ + - ]: 20 : sepFont.setPointSize(4);
328 [ + - ]: 20 : separator->setFont(sepFont);
329 [ + - ]: 20 : m_model->appendRow(separator);
330 : 20 : separatorInserted = true;
331 : 20 : }
332 : :
333 [ + - ]: 126 : auto *parent = findOrCreateParent(folder.path, folder.delimiter);
334 : :
335 [ + - + - : 126 : auto *item = new QStandardItem(folder.name);
- + - - ]
336 [ + - ]: 126 : item->setData(folder.path, FolderPathRole);
337 [ + - ]: 126 : item->setData(folder.name, DisplayNameRole);
338 [ + - ]: 126 : item->setData(folder.delimiter, DelimiterRole);
339 [ + - ]: 126 : item->setData(folder.flags, FolderFlagsRole); // T-289
340 : : // T-126/67.B2: theme-tinted icon incl. custom icon/color settings
341 [ + - + - ]: 126 : item->setIcon(themedItemIcon(folder.path, folder));
342 : :
343 : : // \Noselect folders are not selectable
344 [ + - + + ]: 126 : if (folder.isNoselect()) {
345 [ + - ]: 1 : item->setSelectable(false);
346 [ + - + - ]: 1 : item->setForeground(Qt::gray);
347 : : }
348 : :
349 : : // Check if a placeholder already exists for this path (created by a child)
350 [ + - ]: 126 : auto *existing = findItemByPath(folder.path);
351 [ - + ]: 126 : if (existing) {
352 : : // Replace placeholder data with real folder data
353 [ # # ]: 0 : existing->setText(folder.name);
354 [ # # ]: 0 : existing->setData(folder.name, DisplayNameRole);
355 [ # # ]: 0 : existing->setData(folder.delimiter, DelimiterRole);
356 [ # # ]: 0 : existing->setData(folder.flags, FolderFlagsRole);
357 : : // T-126/67.B2: theme-tinted icon incl. custom icon/color settings
358 [ # # # # ]: 0 : existing->setIcon(themedItemIcon(folder.path, folder));
359 [ # # # # ]: 0 : if (!folder.isNoselect()) {
360 [ # # ]: 0 : existing->setSelectable(true);
361 [ # # # # : 0 : existing->setForeground(QPalette().color(QPalette::Text));
# # # # ]
362 : : }
363 [ + + ]: 126 : } else if (parent) {
364 [ + - ]: 21 : parent->appendRow(item);
365 [ + - ]: 21 : m_pathCache.insert(folder.path, item); // T-525
366 : : } else {
367 [ + - ]: 105 : m_model->appendRow(item);
368 [ + - ]: 105 : m_pathCache.insert(folder.path, item); // T-525
369 : : }
370 : :
371 [ + + + - : 126 : if (isSpecial && !folder.path.contains(folder.delimiter)) {
+ - + + ]
372 : 52 : lastWasSpecial = true;
373 : : }
374 : : }
375 : :
376 : : // T-546: Don't expandAll() here — the caller (restoreSessionFolder,
377 : : // refreshTreeWithBadges) is responsible for setting the expand state.
378 [ + - + - : 94 : qCInfo(lcFolderTree) << "Populated" << folders.size() << "folders";
+ - + - +
- + + ]
379 : 47 : }
380 : :
381 : 174 : void FolderTree::setUnreadCount(const QString &folderPath, int count) {
382 [ + - ]: 174 : auto *item = findItemByPath(folderPath);
383 [ + + ]: 174 : if (!item)
384 : 11 : return;
385 : :
386 : : // T-064: Use stored display name instead of delimiter heuristic
387 [ + - + - ]: 163 : QString baseName = item->data(DisplayNameRole).toString();
388 [ + + ]: 163 : if (baseName.isEmpty()) {
389 : : // Fallback: extract from path using stored delimiter
390 [ + - + - ]: 1 : QString delim = item->data(DelimiterRole).toString();
391 [ - + ]: 1 : if (delim.isEmpty())
392 [ # # ]: 0 : delim = ".";
393 [ + - ]: 1 : baseName = folderPath.section(delim, -1);
394 [ - + ]: 1 : if (baseName.isEmpty())
395 : 0 : baseName = folderPath;
396 : 1 : }
397 : :
398 : : // 67.B4: the count lives in UnreadCountRole (badge pill painted by
399 : : // FolderBadgeDelegate); the text stays the plain folder name.
400 [ + - ]: 163 : item->setText(baseName);
401 [ + + + - ]: 163 : item->setData(count > 0 ? count : QVariant(), UnreadCountRole);
402 [ + - ]: 163 : QFont f = item->font();
403 [ + - ]: 163 : f.setBold(count > 0);
404 [ + - ]: 163 : item->setFont(f);
405 : 163 : }
406 : :
407 : : // ═══════════════════════════════════════════════════════
408 : : // T-197: Virtual search node
409 : : // ═══════════════════════════════════════════════════════
410 : :
411 : 45 : void FolderTree::showSearchNode(int resultCount) {
412 [ + + ]: 45 : if (m_searchItem) {
413 [ + - ]: 19 : updateSearchCount(resultCount);
414 : 19 : return;
415 : : }
416 : :
417 [ + - + - : 26 : m_searchItem = new QStandardItem();
- + - - ]
418 [ + - + - ]: 26 : m_searchItem->setData(QLatin1String(SearchNodePath), FolderPathRole);
419 [ + - ]: 52 : m_searchItem->setData(QStringLiteral("Suche"), DisplayNameRole);
420 [ + - + - : 52 : m_searchItem->setIcon(MdiIconProvider::instance().icon(
+ - ]
421 [ + - + - : 78 : QStringLiteral("magnify"), 20, QColor(ThemeManager::instance().color("@text_secondary"))));
+ - ]
422 [ + - ]: 26 : m_searchItem->setDropEnabled(false);
423 [ + - ]: 26 : m_searchItem->setDragEnabled(false);
424 : :
425 [ + - ]: 26 : QFont f = m_searchItem->font();
426 [ + - ]: 26 : f.setBold(true);
427 [ + - ]: 26 : m_searchItem->setFont(f);
428 : :
429 [ + - ]: 26 : updateSearchCount(resultCount);
430 : :
431 : : // Insert at row 0 (above everything)
432 [ + - ]: 26 : m_model->insertRow(0, m_searchItem);
433 : :
434 : : // Select the search node. Block selection signals so this programmatic
435 : : // selection does not emit searchNodeSelected (which would re-trigger the
436 : : // search). Only an actual user click on the node should restore the search.
437 [ + - ]: 26 : auto idx = m_model->indexFromItem(m_searchItem);
438 : : {
439 [ + - ]: 26 : QSignalBlocker block(selectionModel());
440 [ + - ]: 26 : setCurrentIndex(idx);
441 : 26 : }
442 [ + - ]: 26 : scrollToTop();
443 : :
444 [ + - + - : 52 : qCInfo(lcFolderTree) << "Search node shown with" << resultCount << "results";
+ - + - +
- + + ]
445 : 26 : }
446 : :
447 : 53 : void FolderTree::updateSearchCount(int count) {
448 [ + + ]: 53 : if (!m_searchItem)
449 : 2 : return;
450 [ + + ]: 51 : if (count > 0) {
451 [ + - + - ]: 150 : m_searchItem->setText(QStringLiteral("Suche %1").arg(count));
452 : : } else {
453 [ + - ]: 2 : m_searchItem->setText(QStringLiteral("Suche"));
454 : : }
455 : : }
456 : :
457 : 24 : void FolderTree::hideSearchNode() {
458 [ + + ]: 24 : if (!m_searchItem)
459 : 3 : return;
460 : :
461 [ + - ]: 21 : auto idx = m_model->indexFromItem(m_searchItem);
462 [ + - ]: 21 : if (idx.isValid()) {
463 [ + - ]: 21 : m_model->removeRow(idx.row());
464 : : }
465 : 21 : m_searchItem = nullptr;
466 : :
467 [ + - + - : 42 : qCInfo(lcFolderTree) << "Search node hidden";
+ - + + ]
468 : : }
469 : :
470 : 8 : bool FolderTree::isSearchNodeSelected() const {
471 [ + - ]: 8 : auto idx = currentIndex();
472 [ + + + - ]: 14 : return idx.isValid() &&
473 [ + - + - : 14 : idx.data(FolderPathRole).toString() == QLatin1String(SearchNodePath);
+ + + + -
- - - ]
474 : : }
475 : :
476 : 5 : void FolderTree::clear() {
477 : 5 : m_searchItem = nullptr;
478 : 5 : m_model->clear();
479 : 5 : m_pathCache.clear(); // T-525
480 : 5 : }
481 : :
482 : 135 : void FolderTree::selectFolder(const QString &folderPath) {
483 : 135 : auto *item = findItemByPath(folderPath);
484 [ + + ]: 135 : if (item) {
485 [ + - ]: 128 : auto idx = m_model->indexFromItem(item);
486 [ + - ]: 128 : setCurrentIndex(idx);
487 [ + - ]: 128 : scrollTo(idx);
488 [ + - + - : 256 : qCInfo(lcFolderTree) << "Restored folder selection:" << folderPath;
+ - + - +
+ ]
489 : : } else {
490 [ + - + - : 14 : qCInfo(lcFolderTree) << "Could not find folder for restore:" << folderPath;
+ - + - +
+ ]
491 : : }
492 : 135 : }
493 : :
494 : 22 : QString FolderTree::selectedFolderPath() const {
495 [ + - ]: 22 : auto idx = currentIndex();
496 [ + + ]: 22 : if (idx.isValid()) {
497 [ + - + - ]: 14 : return idx.data(FolderPathRole).toString();
498 : : }
499 : 8 : return {};
500 : : }
501 : :
502 : 16 : QStringList FolderTree::expandedFolderPaths() const {
503 : 16 : QStringList paths;
504 : 16 : std::function<void(const QModelIndex &)> collectExpanded;
505 : 0 : collectExpanded = [&](const QModelIndex &parent) {
506 : 89 : int rows = m_model->rowCount(parent);
507 [ + + ]: 162 : for (int i = 0; i < rows; ++i) {
508 [ + - ]: 73 : auto idx = m_model->index(i, 0, parent);
509 [ + - + + ]: 73 : if (isExpanded(idx)) {
510 [ + - + - ]: 44 : auto path = idx.data(FolderPathRole).toString();
511 [ + + ]: 44 : if (!path.isEmpty()) {
512 [ + - ]: 42 : paths.append(path);
513 : : }
514 : 44 : }
515 [ + - ]: 73 : collectExpanded(idx);
516 : : }
517 [ + - ]: 105 : };
518 [ + - ]: 16 : collectExpanded(QModelIndex());
519 : 16 : return paths;
520 : 16 : }
521 : :
522 : 8 : void FolderTree::restoreExpandedFolders(const QStringList &paths) {
523 : : // Collapse all first, then expand only the saved ones
524 : 8 : collapseAll();
525 [ + + ]: 37 : for (const auto &path : paths) {
526 [ + - ]: 29 : auto *item = findItemByPath(path);
527 [ + - ]: 29 : if (item) {
528 [ + - + - ]: 29 : expand(m_model->indexFromItem(item));
529 : : }
530 : : }
531 [ + - + - : 16 : qCInfo(lcFolderTree) << "Restored" << paths.size() << "expanded folders";
+ - + - +
- + + ]
532 : 8 : }
533 : :
534 : 130 : QStandardItem *FolderTree::findOrCreateParent(const QString &path,
535 : : const QString &delimiter) {
536 [ + - + - : 130 : if (delimiter.isEmpty() || !path.contains(delimiter)) {
+ + + + ]
537 : 108 : return nullptr; // Top-level item
538 : : }
539 : :
540 : : // path = "Arbeit/UCC/Projekte" → parentPath = "Arbeit/UCC"
541 [ + - ]: 22 : auto parentPath = path.section(delimiter, 0, -2);
542 [ - + ]: 22 : if (parentPath.isEmpty()) {
543 : 0 : return nullptr;
544 : : }
545 : :
546 : : // Try to find existing parent
547 [ + - ]: 22 : auto *existing = findItemByPath(parentPath);
548 [ + + ]: 22 : if (existing) {
549 : 18 : return existing;
550 : : }
551 : :
552 : : // T-078: Don't create placeholders for hidden folders
553 [ - + ]: 4 : if (m_hiddenSet.contains(parentPath)) {
554 : 0 : return nullptr;
555 : : }
556 : :
557 : : // T-064: Parent doesn't exist → create it recursively
558 : : // First ensure the grandparent exists
559 [ + - ]: 4 : auto *grandParent = findOrCreateParent(parentPath, delimiter);
560 : :
561 : : // Create placeholder node for the missing parent
562 [ + - ]: 4 : auto parentName = parentPath.section(delimiter, -1);
563 [ + - + - : 4 : auto *placeholder = new QStandardItem(parentName);
- + - - ]
564 [ + - ]: 4 : placeholder->setData(parentPath, FolderPathRole);
565 [ + - ]: 4 : placeholder->setData(parentName, DisplayNameRole);
566 [ + - ]: 4 : placeholder->setData(delimiter, DelimiterRole);
567 [ + - + - : 8 : placeholder->setIcon(MdiIconProvider::instance().icon(
+ - ]
568 [ + - ]: 12 : QStringLiteral("folder-outline"), 20, themedIconColor()));
569 [ + - ]: 4 : placeholder->setSelectable(false);
570 [ + - + - ]: 4 : placeholder->setForeground(Qt::gray);
571 : :
572 [ + + ]: 4 : if (grandParent) {
573 [ + - ]: 1 : grandParent->appendRow(placeholder);
574 : : } else {
575 [ + - ]: 3 : m_model->appendRow(placeholder);
576 : : }
577 [ + - ]: 4 : m_pathCache.insert(parentPath, placeholder); // T-525
578 : :
579 [ + - + - : 8 : qCInfo(lcFolderTree) << "Created intermediate node:" << parentPath;
+ - + - +
+ ]
580 : 4 : return placeholder;
581 : 22 : }
582 : :
583 : : // 67.B2: One resolution point for an item's icon — special/heuristic MDI
584 : : // icon, optional custom icon and custom tint from QSettings — so both
585 : : // setFolders() and refreshFolderIcons() produce identical results.
586 : 184 : QIcon FolderTree::themedItemIcon(const QString &path,
587 : : const FolderInfo &folder) {
588 [ + - ]: 184 : QIcon icon = iconForFolder(folder);
589 [ + - ]: 184 : QSettings s;
590 [ + - + - : 184 : const QString customIcon = s.value("folder/icon/" + path).toString();
+ - ]
591 [ + + ]: 184 : if (!customIcon.isEmpty()) {
592 : : // T-199b: Use MdiIconProvider (font-based, 7448 icons)
593 : : QIcon mdiIcon =
594 [ + - + - : 3 : MdiIconProvider::instance().icon(customIcon, 20, themedIconColor());
+ - ]
595 [ + - ]: 3 : icon = mdiIcon.isNull()
596 [ + - + - : 9 : ? QIcon::fromTheme(customIcon,
- - ]
597 [ + - + - : 6 : QIcon::fromTheme(QStringLiteral("folder")))
+ - + - -
- - - -
- ]
598 : 3 : : mdiIcon;
599 : 3 : }
600 [ + - + - : 184 : const QString colorStr = s.value("folder/color/" + path).toString();
+ - ]
601 [ + + ]: 184 : if (!colorStr.isEmpty())
602 [ + - ]: 3 : icon = tintedIcon(icon, QColor(colorStr));
603 : 184 : return icon;
604 : 184 : }
605 : :
606 : 11 : void FolderTree::refreshFolderIcons() {
607 [ + + ]: 69 : for (auto it = m_pathCache.constBegin(); it != m_pathCache.constEnd();
608 : 58 : ++it) {
609 : 58 : QStandardItem *item = it.value();
610 [ - + ]: 58 : if (!item)
611 : 0 : continue;
612 : 58 : FolderInfo folder;
613 : 58 : folder.path = it.key();
614 [ + - + - ]: 58 : folder.name = item->data(DisplayNameRole).toString();
615 [ + - + - ]: 58 : folder.delimiter = item->data(DelimiterRole).toString();
616 [ + - + - ]: 58 : folder.flags = item->data(FolderFlagsRole).toStringList();
617 [ + - + - ]: 58 : item->setIcon(themedItemIcon(folder.path, folder));
618 : 58 : }
619 [ - + ]: 11 : if (m_searchItem) {
620 [ # # # # : 0 : m_searchItem->setIcon(MdiIconProvider::instance().icon(
# # ]
621 [ # # ]: 0 : QStringLiteral("magnify"), 20, themedIconColor()));
622 : : }
623 : 11 : }
624 : :
625 : 184 : QIcon FolderTree::iconForFolder(const FolderInfo &folder) {
626 [ + - ]: 184 : auto &mdi = MdiIconProvider::instance();
627 [ + - ]: 184 : const QColor color = themedIconColor();
628 : :
629 : : // Special folders (detected by IMAP flags)
630 [ + + ]: 184 : if (folder.path == "INBOX")
631 [ + - ]: 52 : return mdi.icon(QStringLiteral("inbox"), 20, color);
632 [ + - + + ]: 132 : if (folder.isSent())
633 [ + - ]: 7 : return mdi.icon(QStringLiteral("send"), 20, color);
634 [ + - - + ]: 125 : if (folder.isDrafts())
635 [ # # ]: 0 : return mdi.icon(QStringLiteral("file-edit-outline"), 20, color);
636 [ + - + + ]: 125 : if (folder.isTrash())
637 [ + - ]: 2 : return mdi.icon(QStringLiteral("trash-can-outline"), 20, color);
638 [ + - + + ]: 123 : if (folder.isArchive())
639 [ + - ]: 1 : return mdi.icon(QStringLiteral("archive-outline"), 20, color);
640 [ + - - + ]: 122 : if (folder.isJunk())
641 [ # # ]: 0 : return mdi.icon(QStringLiteral("alert-octagon-outline"), 20, color);
642 : :
643 : : // T-125: Name-based heuristics for common folder names
644 [ + - ]: 122 : QString name = folder.path.contains('.')
645 [ + + ]: 156 : ? folder.path.section('.', -1)
646 [ + - + - ]: 156 : : folder.path.section('/', -1);
647 : :
648 : : // MDI icon names — always available via font rendering
649 : : static const QHash<QString, QString> s_nameIcons = {
650 : : {"notifications", "bell-outline"},
651 : : {"benachrichtigungen", "bell-outline"},
652 : : {"newsletter", "newspaper-variant-outline"},
653 : : {"newsletters", "newspaper-variant-outline"},
654 : : {"work", "briefcase-outline"},
655 : : {"arbeit", "briefcase-outline"},
656 : : {"important", "star-outline"},
657 : : {"wichtig", "star-outline"},
658 : : {"lists", "format-list-bulleted"},
659 : : {"mailinglisten", "format-list-bulleted"},
660 : : {"mailing-listen", "format-list-bulleted"},
661 : : {"receipts", "receipt-text-outline"},
662 : : {"rechnungen", "receipt-text-outline"},
663 : : {"invoices", "receipt-text-outline"},
664 : : {"travel", "airplane"},
665 : : {"reisen", "airplane"},
666 : : // Additional heuristics
667 : : {"server", "server"},
668 : : {"monitoring", "monitor-eye"},
669 : : {"backup", "cloud-upload-outline"},
670 : : {"git", "source-branch"},
671 : : {"pipelines", "pipe"},
672 : : {"geld", "currency-eur"},
673 : : {"money", "currency-eur"},
674 : : {"steuern", "calculator"},
675 : : {"bitcoin", "bitcoin"},
676 : : {"versicherungen", "shield-check-outline"},
677 : : {"privat", "account-outline"},
678 : : {"personal", "account-outline"},
679 : : {"bestellungen", "cart-outline"},
680 : : {"orders", "cart-outline"},
681 : : {"uni", "school-outline"},
682 : : {"university", "school-outline"},
683 : : {"schule", "school-outline"},
684 : : {"games", "gamepad-variant-outline"},
685 : : {"tracking", "map-marker-path"},
686 : : {"startups", "rocket-launch-outline"},
687 : : {"onlinekurse", "book-open-page-variant-outline"},
688 : : {"spam", "alert-octagon-outline"},
689 : : {"spam2", "alert-octagon-outline"},
690 [ + + + - : 402 : };
+ + - - -
- ]
691 : :
692 [ + - ]: 122 : auto it = s_nameIcons.find(name.toLower());
693 [ + + ]: 122 : if (it != s_nameIcons.end()) {
694 [ + - ]: 31 : return mdi.icon(*it, 20, color);
695 : : }
696 : :
697 [ + - ]: 91 : return mdi.icon(QStringLiteral("folder-outline"), 20, color);
698 [ + - + - : 129 : }
+ - + - +
- + - + -
+ - + - +
- + - + -
+ - + - +
- + - + -
+ - + - +
- + - + -
+ - + - +
- + - + -
+ - + - +
- + - + -
+ - + - +
- + - + -
+ - + - +
- - - -
- ]
699 : :
700 : 486 : QStandardItem *FolderTree::findItemByPath(const QString &folderPath) const {
701 : : // T-525: O(1) cache lookup (replaces O(N) recursive tree search)
702 : 486 : auto it = m_pathCache.constFind(folderPath);
703 [ + + ]: 486 : if (it != m_pathCache.constEnd())
704 : 338 : return *it;
705 : : // Fallback to recursive search for items not yet in cache
706 [ + - ]: 148 : return findItemByPathRecursive(nullptr, folderPath);
707 : : }
708 : :
709 : 497 : QStandardItem *FolderTree::findItemByPathRecursive(QStandardItem *root,
710 : : const QString &path) const {
711 [ + + + - : 497 : int rows = root ? root->rowCount() : m_model->rowCount();
+ - ]
712 [ + + ]: 846 : for (int i = 0; i < rows; ++i) {
713 [ + + ]: 349 : auto *child = root ? root->child(i) : m_model->item(i);
714 [ + - + - : 349 : if (child && child->data(FolderPathRole).toString() == path) {
+ - - + +
- + - - +
- - - - ]
715 : 0 : return child;
716 : : }
717 : 349 : auto *found = findItemByPathRecursive(child, path);
718 [ - + ]: 349 : if (found)
719 : 0 : return found;
720 : : }
721 : 497 : return nullptr;
722 : : }
723 : :
724 : : // ═══════════════════════════════════════════════════════
725 : : // Drop Target (T-103) — visual-only highlight, no folder switch
726 : : // ═══════════════════════════════════════════════════════
727 : :
728 : 4 : void FolderTree::dragEnterEvent(QDragEnterEvent *event) {
729 : 4 : if (event->mimeData()->hasFormat(
730 [ + - + + : 14 : QStringLiteral("application/x-mailjd-mail-ids")) ||
+ - + + -
- - - ]
731 [ + - + + ]: 4 : event->mimeData()->hasFormat(
732 [ + + + + : 6 : QStringLiteral("application/x-mailjd-uids"))) {
+ - - - -
- ]
733 : : // Save current selection so we can restore it after drop/cancel
734 [ + - + - ]: 3 : m_preDragIndex = currentIndex();
735 : 3 : event->acceptProposedAction();
736 : : } else {
737 : 1 : event->ignore();
738 : : }
739 : 4 : }
740 : :
741 : 4 : void FolderTree::dragMoveEvent(QDragMoveEvent *event) {
742 [ + - ]: 4 : auto idx = indexAt(event->position().toPoint());
743 [ + + ]: 4 : if (!idx.isValid()) {
744 : 2 : event->ignore();
745 [ + - ]: 2 : m_dragHighlightIndex = QPersistentModelIndex();
746 [ + - + - ]: 2 : viewport()->update();
747 : 2 : return;
748 : : }
749 : :
750 [ + - + - ]: 2 : auto path = idx.data(FolderPathRole).toString();
751 [ - + ]: 2 : if (path.isEmpty()) {
752 : : // Separator or non-folder item
753 : 0 : event->ignore();
754 [ # # ]: 0 : m_dragHighlightIndex = QPersistentModelIndex();
755 [ # # # # ]: 0 : viewport()->update();
756 : 0 : return;
757 : : }
758 : :
759 : : // Accept the drop on this folder — visual highlight only, NO setCurrentIndex
760 : 2 : event->acceptProposedAction();
761 [ + - ]: 2 : if (m_dragHighlightIndex != idx) {
762 [ + - ]: 2 : m_dragHighlightIndex = QPersistentModelIndex(idx);
763 [ + - + - ]: 2 : viewport()->update();
764 : : }
765 [ + - ]: 2 : }
766 : :
767 : 2 : void FolderTree::dropEvent(QDropEvent *event) {
768 [ + - ]: 2 : auto idx = indexAt(event->position().toPoint());
769 : :
770 : : // Clear drag highlight
771 [ + - ]: 2 : m_dragHighlightIndex = QPersistentModelIndex();
772 [ + - + - ]: 2 : viewport()->update();
773 : :
774 [ - + ]: 2 : if (!idx.isValid()) {
775 : 0 : event->ignore();
776 : 1 : return;
777 : : }
778 : :
779 [ + - + - ]: 2 : auto targetFolder = idx.data(FolderPathRole).toString();
780 [ - + ]: 2 : if (targetFolder.isEmpty()) {
781 : 0 : event->ignore();
782 : 0 : return;
783 : : }
784 : :
785 [ + - ]: 4 : if (event->mimeData()->hasFormat(
786 [ + + ]: 4 : QStringLiteral("application/x-mailjd-mail-ids"))) {
787 : : auto encoded = event->mimeData()->data(
788 [ + - ]: 2 : QStringLiteral("application/x-mailjd-mail-ids"));
789 [ + - ]: 1 : QDataStream stream(&encoded, QIODevice::ReadOnly);
790 : 1 : QList<MailIdentity> mails;
791 [ + - + + ]: 3 : while (!stream.atEnd()) {
792 : 2 : MailIdentity mail;
793 [ + - + - : 2 : stream >> mail.account >> mail.folderId >> mail.uid;
+ - ]
794 [ + - ]: 2 : if (mail.isValid())
795 [ + - ]: 2 : mails.append(mail);
796 : 2 : }
797 : :
798 [ + - ]: 1 : if (!mails.isEmpty()) {
799 [ + - + - : 2 : qCInfo(lcFolderTree) << "Drop:" << mails.size() << "mail(s) onto"
+ - + - +
- + + ]
800 [ + - ]: 1 : << targetFolder;
801 [ + - ]: 1 : emit moveMailIdsRequested(mails, targetFolder);
802 : 1 : event->acceptProposedAction();
803 : : } else {
804 : 0 : event->ignore();
805 : : }
806 : 1 : return;
807 : 1 : }
808 : :
809 : : auto encoded =
810 [ + - ]: 2 : event->mimeData()->data(QStringLiteral("application/x-mailjd-uids"));
811 [ + - ]: 1 : QDataStream stream(&encoded, QIODevice::ReadOnly);
812 : 1 : QList<qint64> uids;
813 [ + - + + ]: 3 : while (!stream.atEnd()) {
814 : : qint64 uid;
815 [ + - ]: 2 : stream >> uid;
816 [ + - ]: 2 : uids.append(uid);
817 : : }
818 : :
819 [ + - ]: 1 : if (!uids.isEmpty()) {
820 [ + - + - : 2 : qCInfo(lcFolderTree) << "Drop:" << uids.size() << "mail(s) onto"
+ - + - +
- + + ]
821 [ + - ]: 1 : << targetFolder;
822 [ + - ]: 1 : emit moveRequested(uids, targetFolder);
823 : 1 : event->acceptProposedAction();
824 : : } else {
825 : 0 : event->ignore();
826 : : }
827 : :
828 : : // Restore original folder selection (not the drag target)
829 [ + - - + ]: 1 : if (m_preDragIndex.isValid()) {
830 [ # # # # ]: 0 : setCurrentIndex(m_preDragIndex);
831 [ # # ]: 0 : m_preDragIndex = QPersistentModelIndex();
832 : : }
833 [ + + ]: 2 : }
834 : :
835 : 1 : void FolderTree::dragLeaveEvent(QDragLeaveEvent *event) {
836 [ + - ]: 1 : m_dragHighlightIndex = QPersistentModelIndex();
837 : 1 : viewport()->update();
838 : 1 : QTreeView::dragLeaveEvent(event);
839 : 1 : }
840 : :
841 : 577 : void FolderTree::paintEvent(QPaintEvent *event) {
842 : : // Draw the tree normally
843 : 577 : QTreeView::paintEvent(event);
844 : :
845 : : // Overlay a drag highlight rect if active
846 [ + + ]: 577 : if (m_dragHighlightIndex.isValid()) {
847 [ + - + - ]: 1 : auto rect = visualRect(m_dragHighlightIndex);
848 [ + - ]: 1 : if (rect.isValid()) {
849 [ + - + - : 1 : QPainter painter(viewport());
+ - ]
850 [ + - ]: 1 : painter.setRenderHint(QPainter::Antialiasing, false);
851 [ + - + - ]: 1 : QColor highlight = palette().highlight().color();
852 [ + - ]: 1 : highlight.setAlpha(50);
853 [ + - ]: 1 : painter.fillRect(rect, highlight);
854 [ + - + - : 1 : painter.setPen(QPen(palette().highlight().color(), 1));
+ - + - +
- ]
855 [ + - ]: 1 : painter.drawRect(rect.adjusted(0, 0, -1, -1));
856 : 1 : }
857 : : }
858 : 577 : }
859 : :
860 : : // T-126: Tint an icon with a solid color (preserves alpha/shape)
861 : 3 : QIcon FolderTree::tintedIcon(const QIcon &icon, const QColor &color) {
862 [ + - ]: 3 : QPixmap pix = icon.pixmap(24, 24);
863 [ + - ]: 3 : QPainter p(&pix);
864 [ + - ]: 3 : p.setCompositionMode(QPainter::CompositionMode_SourceIn);
865 [ + - + - ]: 3 : p.fillRect(pix.rect(), color);
866 [ + - ]: 3 : p.end();
867 [ + - ]: 6 : return QIcon(pix);
868 : 3 : }
|