Branch data Line data Source code
1 : : #include "MainWindow.h"
2 : : #include "MailtoUrl.h"
3 : : #include "SearchQuery.h" // Sprint 59: parseSearchQuery/buildSearchQuery
4 : : #include "data/CredentialStore.h"
5 : :
6 : : #include <QApplication>
7 : : #include <QElapsedTimer>
8 : : #include <QCloseEvent>
9 : : #include <QMouseEvent> // T-181: suggestion label click
10 : : #include <QDir>
11 : : #include <QFileDialog>
12 : : #include <QFileInfo>
13 : : #include <QInputDialog>
14 : : #include <QLineEdit>
15 : : #include <QHeaderView>
16 : : #include <QLabel>
17 : : #include <QLoggingCategory>
18 : : #include <QMenu>
19 : : #include <QMenuBar>
20 : : #include <QMessageBox>
21 : : #include <QPainter>
22 : : #include <QPointer>
23 : : #include <QSplitter>
24 : : #include <QStandardPaths>
25 : : #include <QStatusBar>
26 : : #include <QSystemTrayIcon>
27 : : #include <QThread>
28 : : #include <QTimer>
29 : : #include <QTreeView>
30 : : #include <QUuid>
31 : : #include <QShortcut>
32 : : #include <QVBoxLayout>
33 : : #include <QStackedWidget>
34 : : #include <QThread>
35 : : #include <QScrollBar>
36 : : #include <QTabBar>
37 : : #include <QTranslator>
38 : : #include <QLocale>
39 : : #include <optional>
40 : :
41 : : // Sprint 76 (T-76.A4): robust foreground activation on Wayland/X11.
42 : : #if defined(MAILJD_HAVE_KWINDOWSYSTEM)
43 : : #include <KWindowSystem>
44 : : #endif
45 : :
46 : : #include "controller/MailController.h"
47 : : #include "controller/UndoManager.h"
48 : : #include "data/AccountConfig.h"
49 : : #include "data/MailCache.h"
50 : : #include "service/ImapService.h"
51 : : #include "service/ImapResponseParser.h"
52 : : #include "ui/AttachmentBar.h"
53 : : #include "ui/CommandBar.h"
54 : : #include "ui/FolderPropertiesDialog.h"
55 : : #include "ui/FolderSubscriptionDialog.h"
56 : : #include "ui/FolderTree.h"
57 : : #include "ui/MailFilterProxyModel.h"
58 : : #include "ui/MailListModel.h"
59 : : #include "ui/MailThreadModel.h"
60 : : #include "ui/MailView.h"
61 : : #include "ui/MailTabWidget.h"
62 : : #include "app/FolderOperationsController.h"
63 : : #include "app/SearchCoordinator.h"
64 : : #include "ui/SearchPanel.h"
65 : : #include "ui/TabManager.h"
66 : : #include "ui/ComposeWindow.h"
67 : : #include "ui/LabelDelegate.h"
68 : : #include "ui/ShortcutHelpOverlay.h"
69 : : #include "ui/SuggestionWorker.h"
70 : : #include "ui/StarDelegate.h"
71 : : #include "ui/MdiIconProvider.h"
72 : : #include "ui/ThemeManager.h"
73 : : #include "ui/SettingsDialog.h"
74 : : #include "ui/SetupWizard.h"
75 : : #include "ui/ContactManagerDialog.h"
76 : : #include "data/ContactStore.h"
77 : : #include "data/DavCredentials.h"
78 : : #include "data/FolderPredictor.h"
79 : : #include "data/SettingsCollector.h"
80 : : #include "data/SettingsSyncModels.h"
81 : : #include "service/CardDavClient.h"
82 : : #include "service/CalDavClient.h"
83 : : #include "service/ConnectionHealthMonitor.h"
84 : : #include "service/SettingsSyncService.h"
85 : : #include "data/CalendarStore.h"
86 : : #include "ui/CalendarWidget.h"
87 : : #include "ui/TaskListWidget.h"
88 : : #include "ui/EventEditDialog.h"
89 : : #include "ui/TaskEditDialog.h"
90 : : #include "service/DesktopNotifier.h"
91 : : #include "service/NotificationBatcher.h"
92 : : #include <QEvent>
93 : :
94 [ + + + - : 731 : Q_LOGGING_CATEGORY(lcMainWindow, "mailjd.mainwindow")
+ - - - ]
95 : :
96 : : // T-71: narrow icon-only delegate for the Attachment column.
97 : : // Paints a paperclip (MDI 'attachment' glyph) when HasAttachmentsRole is true.
98 : : class AttachmentIndicatorDelegate : public QStyledItemDelegate {
99 : : public:
100 : : using QStyledItemDelegate::QStyledItemDelegate;
101 : :
102 : 2377 : void paint(QPainter *painter, const QStyleOptionViewItem &option,
103 : : const QModelIndex &index) const override {
104 : : // Let the base handle selection/hover background.
105 [ + - ]: 2377 : QStyledItemDelegate::paint(painter, option, index);
106 : :
107 [ + - + - : 2377 : if (!index.data(MailListModel::HasAttachmentsRole).toBool())
+ + ]
108 : 2156 : return;
109 : :
110 : 221 : const int iconSize = 14;
111 : : const QColor color =
112 [ + - + - ]: 442 : QColor(ThemeManager::instance().color(QStringLiteral("@text_muted")));
113 [ + - ]: 221 : const QPixmap px = MdiIconProvider::instance()
114 [ + - ]: 442 : .icon(QStringLiteral("attachment"), iconSize, color)
115 [ + - ]: 221 : .pixmap(iconSize, iconSize);
116 : 221 : const int x = option.rect.center().x() - iconSize / 2;
117 : 221 : const int y = option.rect.center().y() - iconSize / 2;
118 [ + - ]: 221 : painter->drawPixmap(x, y, px);
119 : 221 : }
120 : : };
121 : :
122 : : #ifdef MAILJD_KDE_INTEGRATION
123 : : #include <KStatusNotifierItem>
124 : : #endif
125 : :
126 : :
127 : 2 : static QString uniqueAttachmentSavePath(const QDir &targetDir,
128 : : const QString &requestedName,
129 : : QSet<QString> *reservedNames) {
130 [ + - + - : 2 : QString fileName = QFileInfo(requestedName).fileName().trimmed();
+ - ]
131 [ - + ]: 2 : if (fileName.isEmpty())
132 : 0 : fileName = QStringLiteral("attachment");
133 : :
134 [ + - ]: 2 : const QFileInfo info(fileName);
135 [ + - ]: 2 : QString baseName = info.completeBaseName();
136 [ + - ]: 2 : QString suffix = info.suffix();
137 [ - + ]: 2 : if (baseName.isEmpty()) {
138 : 0 : baseName = fileName;
139 : 0 : suffix.clear();
140 : : }
141 : :
142 : 2 : QString candidate = fileName;
143 : 2 : int counter = 2;
144 [ + - + + ]: 9 : while ((reservedNames && reservedNames->contains(candidate)) ||
145 [ + - + - : 9 : QFileInfo::exists(targetDir.filePath(candidate))) {
+ + + + +
+ - - ]
146 : 3 : candidate = suffix.isEmpty()
147 [ - + - - : 12 : ? QStringLiteral("%1 (%2)").arg(baseName).arg(counter)
- - + - -
+ - + - -
- - - - ]
148 [ + - - + : 6 : : QStringLiteral("%1 (%2).%3")
- - - - ]
149 [ + - + - : 6 : .arg(baseName)
- - ]
150 [ + - + - : 6 : .arg(counter)
+ - - - ]
151 : 3 : .arg(suffix);
152 : 3 : ++counter;
153 : : }
154 [ + - ]: 2 : if (reservedNames)
155 [ + - ]: 2 : reservedNames->insert(candidate);
156 [ + - ]: 4 : return targetDir.filePath(candidate);
157 : 2 : }
158 : :
159 : 4 : QString MainWindow::attachmentSaveDialogPath(const QString &filename) {
160 [ + - + - : 4 : QString safeName = QFileInfo(filename).fileName().trimmed();
+ - ]
161 [ + - + + ]: 5 : while (safeName.startsWith(QLatin1Char('.')))
162 [ + - ]: 1 : safeName.remove(0, 1);
163 [ - + ]: 4 : if (safeName.isEmpty())
164 : 0 : safeName = QStringLiteral("attachment");
165 : :
166 : : QString downloadDir =
167 [ + - ]: 4 : QStandardPaths::writableLocation(QStandardPaths::DownloadLocation);
168 [ - + ]: 4 : if (downloadDir.isEmpty())
169 [ # # ]: 0 : downloadDir = QDir::homePath();
170 [ + - + - ]: 8 : return QDir(downloadDir).filePath(safeName);
171 : 4 : }
172 : :
173 : : static std::optional<CalendarEvent>
174 : 8 : findStoredEvent(CalendarStore *store, const CalendarEvent &event) {
175 [ - + ]: 8 : if (!store)
176 : 0 : return std::nullopt;
177 : : const auto events =
178 [ + - ]: 8 : store->eventsForCalendar(event.accountId, event.calendarPath);
179 [ + + ]: 9 : for (const auto &stored : events) {
180 [ + + ]: 3 : if (stored.uid == event.uid)
181 : 2 : return stored;
182 : : }
183 : 6 : return std::nullopt;
184 : 8 : }
185 : :
186 : : static std::optional<CalendarTask>
187 : 7 : findStoredTask(CalendarStore *store, const CalendarTask &task) {
188 [ - + ]: 7 : if (!store)
189 : 0 : return std::nullopt;
190 : : const auto tasks =
191 [ + - ]: 7 : store->tasksForCalendar(task.accountId, task.calendarPath);
192 [ + + ]: 9 : for (const auto &stored : tasks) {
193 [ + + ]: 4 : if (stored.uid == task.uid)
194 : 2 : return stored;
195 : : }
196 : 5 : return std::nullopt;
197 : 7 : }
198 : :
199 : : // Sprint 59: parseSearchQuery()/buildSearchQuery() moved to the stateless,
200 : : // unit-tested src/app/SearchQuery.{h,cpp}. See SearchQuery.h for the syntax.
201 : :
202 [ + - + - : 57 : MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) {
+ - + - +
- + - +
- ]
203 : : // Test seams: real modal dialogs by default (unit tests override before use).
204 : 58 : m_runDialog = [](QDialog *d) { return d->exec(); };
205 : 1 : m_promptText = [this](const QString &title, const QString &label,
206 : : const QString &initial, bool *ok) {
207 : 1 : return QInputDialog::getText(this, title, label, QLineEdit::Normal, initial,
208 [ + - ]: 1 : ok);
209 : 57 : };
210 : 115 : m_confirm = [this](const QString &title, const QString &text) {
211 [ + - ]: 1 : return QMessageBox::question(this, title, text) == QMessageBox::Yes;
212 : 57 : };
213 : : // T-545: Startup timing instrumentation
214 : 57 : QElapsedTimer t;
215 : 57 : t.start();
216 [ + - ]: 57 : setupUi();
217 [ + - + - : 114 : qCInfo(lcMainWindow) << "⏱ setupUi:" << t.elapsed() << "ms"; t.restart();
+ - + - +
- + + ]
218 : : // Sprint 65 (P2.1): search-mode state/logic. Created after setupUi() (needs
219 : : // cache, controller, models, tree, panel, bar) and before connectSignals().
220 [ - - ]: 0 : m_search = new SearchCoordinator(
221 : 57 : SearchCoordinator::Deps{m_cache, m_controller, m_mailListModel,
222 : 57 : m_mailListProxy, m_folderTree, m_searchPanel,
223 : 57 : m_commandBar,
224 : : // 67.A3: selection snapshot for restore
225 : : // across search-result rebuilds
226 [ - + ]: 57 : [this]() -> QPair<qint64, qint64> {
227 [ + - ]: 17 : const MailId id = currentMailId();
228 [ + - ]: 34 : return qMakePair(id.folderId, id.uid);
229 : 17 : }},
230 [ + - + - ]: 57 : this);
231 : 57 : connect(m_search, &SearchCoordinator::windowTitleChangeRequested, this,
232 [ + - ]: 57 : &MainWindow::setWindowTitle);
233 : 57 : connect(m_search, &SearchCoordinator::statusMessage, this,
234 [ + - ]: 111 : [this](const QString &msg) { setStatus(msg); });
235 : 57 : connect(m_search, &SearchCoordinator::keyedStatusMessage, this,
236 [ + - ]: 57 : [this](const QString &key, const QString &msg, int timeoutMs) {
237 : 38 : setStatus(key, msg, timeoutMs);
238 : 38 : });
239 : 57 : connect(m_search, &SearchCoordinator::statusCleared, this,
240 [ + - ]: 76 : [this](const QString &key) { clearStatus(key); });
241 : 57 : connect(m_search, &SearchCoordinator::mailListFocusRequested, this,
242 [ + - ]: 74 : [this]() { m_mailList->setFocus(); });
243 : 57 : connect(m_search, &SearchCoordinator::mailSelectionClearRequested, this,
244 [ + - ]: 57 : [this]() {
245 : 36 : m_mailList->clearSelection();
246 : 36 : m_mailList->selectionModel()->clearCurrentIndex();
247 : 36 : });
248 : : // 67.A3: restore the clicked search result after a result-list rebuild
249 : 57 : connect(m_search, &SearchCoordinator::mailRevealRequested, this,
250 [ + - ]: 57 : [this](qint64 folderId, qint64 uid) {
251 : 1 : trySelectMailInView(uid, folderId);
252 : 1 : });
253 : : // Sprint 65 (P2.2): IMAP folder management (create/rename/delete/move).
254 [ - - ]: 0 : m_folderOps = new FolderOperationsController(
255 [ - + ]: 57 : FolderOperationsController::Deps{m_imapService, m_folderTree,
256 : 57 : m_commandBar},
257 [ + - + - ]: 57 : this);
258 : 57 : connect(m_folderOps, &FolderOperationsController::keyedStatusMessage, this,
259 [ + - ]: 57 : [this](const QString &key, const QString &msg, int timeoutMs) {
260 : 4 : setStatus(key, msg, timeoutMs);
261 : 4 : });
262 [ + - ]: 57 : setupMenuBar();
263 [ + - + - : 114 : qCInfo(lcMainWindow) << "⏱ setupMenuBar:" << t.elapsed() << "ms"; t.restart();
+ - + - +
- + + ]
264 [ + - ]: 57 : setupTray(); // T-124
265 [ + - ]: 57 : setupStatusBar();
266 : : // Sprint 49: Desktop notifications
267 [ + - + - : 57 : m_desktopNotifier = new DesktopNotifier(this);
- + - - ]
268 : 57 : connect(m_desktopNotifier, &DesktopNotifier::actionInvoked, this,
269 [ + - ]: 57 : &MainWindow::onNotificationAction);
270 : : // 67.B2: live theme switch — delegates pull ThemeManager colors per
271 : : // paint, but views must be repainted explicitly.
272 [ + - ]: 57 : connect(&ThemeManager::instance(), &ThemeManager::themeChanged, this,
273 [ + - ]: 57 : [this](ThemeManager::Theme) {
274 [ + - + - : 9 : if (m_mailList && m_mailList->viewport())
+ - ]
275 : 9 : m_mailList->viewport()->update();
276 [ + - ]: 9 : if (m_mailView)
277 : 9 : m_mailView->update();
278 : 9 : });
279 : : // 67.A2: cluster new-mail bursts into summary notifications
280 [ + - + - : 57 : m_notificationBatcher = new NotificationBatcher(this);
- + - - ]
281 : 57 : connect(m_notificationBatcher, &NotificationBatcher::notifyIndividual,
282 [ + - ]: 57 : this, &MainWindow::notifyNewMail);
283 : 57 : connect(m_notificationBatcher, &NotificationBatcher::notifySummary, this,
284 [ + - ]: 57 : &MainWindow::notifySummaryPopup);
285 : 57 : connect(m_desktopNotifier, &DesktopNotifier::notificationClosed, this,
286 [ + - ]: 57 : [this](uint id) {
287 [ # # ]: 0 : if (id == m_summaryNotificationId)
288 : 0 : m_summaryNotificationId = 0; // next summary is a new popup
289 : 0 : });
290 [ + - + - : 114 : qCInfo(lcMainWindow) << "⏱ tray+status:" << t.elapsed() << "ms"; t.restart();
+ - + - +
- + + ]
291 [ + - ]: 57 : connectSignals();
292 [ + - + - : 114 : qCInfo(lcMainWindow) << "⏱ connectSignals:" << t.elapsed() << "ms"; t.restart();
+ - + - +
- + + ]
293 [ + - ]: 57 : restoreLayout();
294 [ + - + - : 114 : qCInfo(lcMainWindow) << "⏱ restoreLayout:" << t.elapsed() << "ms"; t.restart();
+ - + - +
- + + ]
295 [ + - ]: 57 : loadAccounts();
296 [ + - + - : 114 : qCInfo(lcMainWindow) << "⏱ loadAccounts:" << t.elapsed() << "ms";
+ - + - +
- + + ]
297 : 57 : }
298 : :
299 : 18 : MainWindow::~MainWindow() {
300 : : // T-720: Deactivate + detach the health monitor BEFORE Qt's parent-child
301 : : // cleanup destroys m_imapService. The monitor's stateChanged slot would
302 : : // otherwise react to ImapService::~ImapService() → disconnect() →
303 : : // setState(Disconnected) on a half-destroyed object.
304 [ + - ]: 9 : if (m_imapHealth) {
305 : 9 : m_imapHealth->setActive(false);
306 : 9 : m_imapHealth->detach();
307 : : }
308 : : // Disconnect ImapService signals before Qt's parent-child cleanup
309 : : // destroys m_imapService. ImapService::~ImapService() calls disconnect()
310 : : // → setState(Disconnected) → emits stateChanged, which would invoke
311 : : // our lambdas on already-destroyed members (m_statusMessages etc.).
312 [ + - ]: 9 : if (m_imapService)
313 : 9 : QObject::disconnect(m_imapService, nullptr, this, nullptr);
314 : 18 : }
315 : :
316 : 57 : void MainWindow::setupUi() {
317 [ + - + - ]: 57 : setWindowTitle("MailJD");
318 [ + - ]: 57 : setMinimumSize(800, 600);
319 : :
320 : : // Left panel: folder tree
321 [ + - + - : 57 : m_folderTree = new FolderTree(this);
- + - - ]
322 : :
323 : : // Top-right panel: mail list with model
324 [ + - + - : 57 : m_mailListModel = new MailListModel(this);
- + - - ]
325 [ + - + - : 57 : m_mailThreadModel = new MailThreadModel(this);
- + - - ]
326 [ + - + - : 57 : m_mailListProxy = new MailFilterProxyModel(this);
- + - - ]
327 [ + - ]: 57 : m_mailListProxy->setSourceModel(m_mailListModel);
328 [ + - ]: 57 : m_mailListProxy->setSortRole(MailListModel::SortRole);
329 : :
330 : : // Bug 1: QAbstractItemView::event() accepts QEvent::ShortcutOverride for
331 : : // printable keys (type-ahead search). This happens BEFORE Qt's shortcut
332 : : // system checks QAction shortcuts, completely preventing shortcuts like
333 : : // r=read, s=move, a=archive from firing. The fix: override event() to
334 : : // reject ShortcutOverride for single-char keys, letting QAction handle them.
335 : : class NoSearchTreeView : public QTreeView {
336 : : public:
337 : : using QTreeView::QTreeView;
338 : 0 : void keyboardSearch(const QString &) override { /* no-op */ }
339 : : protected:
340 : 3402 : bool event(QEvent *e) override {
341 [ + + ]: 3402 : if (e->type() == QEvent::ShortcutOverride) {
342 : 2 : auto *ke = static_cast<QKeyEvent *>(e);
343 : : // Let navigation keys through to QTreeView
344 [ + - ]: 2 : switch (ke->key()) {
345 : 2 : case Qt::Key_Up: case Qt::Key_Down:
346 : : case Qt::Key_Left: case Qt::Key_Right:
347 : : case Qt::Key_PageUp: case Qt::Key_PageDown:
348 : : case Qt::Key_Home: case Qt::Key_End:
349 : : case Qt::Key_Return: case Qt::Key_Enter:
350 : : case Qt::Key_Tab: case Qt::Key_Backtab:
351 : : case Qt::Key_Escape:
352 : 2 : break; // normal QTreeView handling
353 : 0 : default:
354 : : // For printable chars without Ctrl/Alt: DON'T accept the
355 : : // override, so the QAction shortcut system can fire instead
356 [ # # # # : 0 : if (!ke->text().isEmpty() &&
# # # # ]
357 [ # # # # ]: 0 : !(ke->modifiers() & (Qt::ControlModifier | Qt::AltModifier))) {
358 : 0 : e->ignore();
359 : 0 : return false;
360 : : }
361 : 0 : break;
362 : : }
363 : : }
364 : 3402 : return QTreeView::event(e);
365 : : }
366 : : };
367 [ + - + - : 57 : m_mailList = new NoSearchTreeView(this);
- + - - ]
368 [ + - ]: 114 : m_mailList->setObjectName(QStringLiteral("mailList"));
369 [ + - ]: 57 : m_mailList->setRootIsDecorated(false);
370 [ + - ]: 57 : m_mailList->setAlternatingRowColors(false);
371 : : // Sprint 70: uniform row heights — compact density (~17-18px) plus
372 : : // scroll/paint performance on large mailboxes. Compatible with the
373 : : // thread view (only setRootIsDecorated(true) + setIndentation(28)
374 : : // change in thread mode; no per-row height variation).
375 [ + - ]: 57 : m_mailList->setUniformRowHeights(true);
376 [ + - ]: 57 : m_mailList->setSelectionBehavior(QAbstractItemView::SelectRows);
377 [ + - ]: 57 : m_mailList->setSelectionMode(QAbstractItemView::SingleSelection);
378 [ + - ]: 57 : m_mailList->setSortingEnabled(true);
379 [ + - ]: 57 : m_mailList->setModel(m_mailListProxy);
380 [ + - ]: 57 : m_mailList->sortByColumn(MailListModel::Date, Qt::DescendingOrder);
381 [ + - ]: 57 : m_mailList->setContextMenuPolicy(Qt::CustomContextMenu);
382 : :
383 : : // T-102: Enable drag from mail list
384 [ + - ]: 57 : m_mailList->setDragEnabled(true);
385 [ + - ]: 57 : m_mailList->setSelectionMode(QAbstractItemView::ExtendedSelection);
386 [ + - ]: 57 : m_mailList->setDragDropMode(QAbstractItemView::DragOnly);
387 : :
388 : : // Star column: narrow, fixed width, not resizable
389 [ + - + - ]: 57 : m_mailList->header()->setStretchLastSection(true);
390 [ + - + - ]: 57 : m_mailList->header()->setMinimumSectionSize(20); // allow narrow icon columns
391 [ + - + - ]: 57 : m_mailList->header()->resizeSection(MailListModel::Star, 24);
392 [ + - + - ]: 57 : m_mailList->header()->setSectionResizeMode(MailListModel::Star,
393 : : QHeaderView::Fixed);
394 : :
395 : : // T-71: Attachment indicator column — narrow, icon-only.
396 : : // Interactive so users can shrink it if they find it too wide.
397 [ + - + - ]: 57 : m_mailList->header()->resizeSection(MailListModel::Attachment, 22);
398 [ + - + - ]: 57 : m_mailList->header()->setSectionResizeMode(MailListModel::Attachment,
399 : : QHeaderView::Interactive);
400 : :
401 : : // LabelDelegate on Subject column (T-087)
402 [ + - - + : 57 : m_mailList->setItemDelegateForColumn(MailListModel::Subject,
- - ]
403 [ + - + - ]: 57 : new LabelDelegate(this));
404 [ + - - + : 57 : m_mailList->setItemDelegateForColumn(MailListModel::Star,
- - ]
405 [ + - + - ]: 57 : new StarDelegate(this));
406 [ + - - + : 57 : m_mailList->setItemDelegateForColumn(MailListModel::Attachment,
- - ]
407 [ + - + - ]: 57 : new AttachmentIndicatorDelegate(this));
408 : :
409 : :
410 : : // T-232: Suggestion overlay (badge layer above mail list viewport)
411 : : // T-232: Hide suggestion column by default
412 [ + - ]: 57 : m_mailList->setColumnHidden(MailListModel::Suggestion, true);
413 : :
414 : : // T-232: Re-hide suggestion column after model resets (setHeaders etc.)
415 [ + - ]: 57 : connect(m_mailListProxy, &QAbstractItemModel::modelReset, this, [this]() {
416 [ + - ]: 169 : if (!m_suggestionColumnVisible) {
417 : 169 : m_mailList->setColumnHidden(MailListModel::Suggestion, true);
418 : : } else {
419 : 0 : m_mailList->setColumnWidth(MailListModel::Suggestion, 140);
420 : : // Model reset clears suggestion cache — recompute for visible rows
421 : 0 : m_suggestedUids.clear();
422 : 0 : m_scrollDebounce->start();
423 : : }
424 : 169 : });
425 : :
426 : : // T-232: Scroll debounce timer — triggers suggestion recompute 150ms after scroll stops
427 [ + - + - : 57 : m_scrollDebounce = new QTimer(this);
- + - - ]
428 [ + - ]: 57 : m_scrollDebounce->setSingleShot(true);
429 [ + - ]: 57 : m_scrollDebounce->setInterval(150);
430 [ + - ]: 57 : connect(m_scrollDebounce, &QTimer::timeout, this, [this]() {
431 [ # # ]: 0 : if (m_suggestionColumnVisible)
432 : 0 : computeVisibleSuggestions();
433 : 0 : });
434 : :
435 : : // T-232: Connect scrollbar to debounced recompute
436 [ + - ]: 57 : connect(m_mailList->verticalScrollBar(), &QScrollBar::valueChanged,
437 [ + - ]: 57 : this, [this]() {
438 [ - + ]: 11 : if (m_suggestionColumnVisible)
439 : 0 : m_scrollDebounce->start();
440 : 11 : });
441 : :
442 : : // T-232: Also recompute when rows are removed (e.g. mail moved with 's')
443 : : // New rows become visible without a scroll event
444 : 57 : connect(m_mailListProxy, &QAbstractItemModel::rowsRemoved,
445 [ + - ]: 57 : this, [this]() {
446 [ - + ]: 11 : if (m_suggestionColumnVisible)
447 : 0 : m_scrollDebounce->start();
448 : 11 : });
449 : :
450 : : // Bottom-right panel: mail view
451 [ + - + - : 57 : m_mailView = new MailView(this);
- + - - ]
452 : :
453 : : // Container for mail list. Sprint 59 (U2): the SearchPanel sits above the
454 : : // list and is shown only in search mode.
455 [ + - + - : 57 : auto *mailListContainer = new QWidget(this);
- + - - ]
456 [ + - + - : 57 : auto *mailListLayout = new QVBoxLayout(mailListContainer);
- + - - ]
457 [ + - ]: 57 : mailListLayout->setContentsMargins(0, 0, 0, 0);
458 [ + - ]: 57 : mailListLayout->setSpacing(0);
459 [ + - + - : 57 : m_searchPanel = new SearchPanel(mailListContainer);
- + - - ]
460 [ + - ]: 57 : m_searchPanel->hide(); // only visible while a search is active
461 [ + - ]: 57 : mailListLayout->addWidget(m_searchPanel);
462 [ + - ]: 57 : mailListLayout->addWidget(m_mailList);
463 : :
464 : : // Right side: vertical splitter (mail list on top, mail view on bottom)
465 [ + - + - : 57 : m_verticalSplitter = new QSplitter(Qt::Vertical, this);
- + - - ]
466 [ + - ]: 57 : m_verticalSplitter->addWidget(mailListContainer);
467 [ + - ]: 57 : m_verticalSplitter->addWidget(m_mailView);
468 [ + - ]: 57 : m_verticalSplitter->setStretchFactor(0, 2); // 40%
469 [ + - ]: 57 : m_verticalSplitter->setStretchFactor(1, 3); // 60%
470 : :
471 : : // Main: horizontal splitter (folder tree on left, right splitter on right)
472 [ + - + - : 57 : m_horizontalSplitter = new QSplitter(Qt::Horizontal, this);
- + - - ]
473 [ + - ]: 57 : m_horizontalSplitter->addWidget(m_folderTree);
474 [ + - ]: 57 : m_horizontalSplitter->addWidget(m_verticalSplitter);
475 [ + - ]: 57 : m_horizontalSplitter->setStretchFactor(0, 0);
476 [ + - ]: 57 : m_horizontalSplitter->setStretchFactor(1, 1);
477 : :
478 : : // Set initial folder tree width to ~250px
479 [ + - + - ]: 57 : m_horizontalSplitter->setSizes({250, 750});
480 : :
481 : : // Minimum width for folder tree so it doesn't collapse completely
482 [ + - ]: 57 : m_folderTree->setMinimumWidth(150);
483 : :
484 : : // T-141: CommandBar at the bottom of the window
485 [ + - + - : 57 : m_commandBar = new CommandBar(this);
- + - - ]
486 : :
487 : : // T-148: Shortcut help overlay — created after m_tabStack below (T-233 fix)
488 : :
489 : : // T-213: Tab system — wrap main pane in QStackedWidget
490 : : // Main pane: 3-pane layout + command bar
491 [ + - + - : 57 : auto *mainPane = new QWidget(this);
- + - - ]
492 [ + - + - : 57 : auto *mainPaneLayout = new QVBoxLayout(mainPane);
- + - - ]
493 [ + - ]: 57 : mainPaneLayout->setContentsMargins(0, 0, 0, 0);
494 [ + - ]: 57 : mainPaneLayout->setSpacing(0);
495 [ + - ]: 57 : mainPaneLayout->addWidget(m_horizontalSplitter, 1);
496 : :
497 : : // Tab bar (auto-hidden when only 1 tab)
498 [ + - + - : 57 : m_tabBar = new QTabBar(this);
- + - - ]
499 [ + - ]: 57 : m_tabBar->setMovable(false);
500 [ + - ]: 57 : m_tabBar->setExpanding(false);
501 [ + - ]: 57 : m_tabBar->setDocumentMode(true);
502 [ + - ]: 57 : m_tabBar->hide(); // Only shown when >1 tab
503 : :
504 : : // Stacked widget holds mainPane + future tab widgets
505 [ + - + - : 57 : m_tabStack = new QStackedWidget(this);
- + - - ]
506 [ + - ]: 57 : m_tabStack->addWidget(mainPane);
507 : :
508 : : // TabManager wires everything together
509 [ + - + - : 57 : m_tabManager = new TabManager(m_tabBar, m_tabStack, this);
- + - - ]
510 : :
511 : : // T-148/T-233: Shortcut help overlay — parented to m_tabStack so it
512 : : // renders above the stacked content (z-order fix after Sprint 23 tabs)
513 [ + - + - : 57 : m_helpOverlay = new ShortcutHelpOverlay(m_tabStack);
- + - - ]
514 : :
515 : : // Central container: tab bar on top, stacked content below
516 [ + - + - : 57 : auto *centralContainer = new QWidget(this);
- + - - ]
517 [ + - + - : 57 : auto *centralLayout = new QVBoxLayout(centralContainer);
- + - - ]
518 [ + - ]: 57 : centralLayout->setContentsMargins(0, 0, 0, 0);
519 [ + - ]: 57 : centralLayout->setSpacing(0);
520 [ + - ]: 57 : centralLayout->addWidget(m_tabBar);
521 [ + - ]: 57 : centralLayout->addWidget(m_tabStack, 1);
522 : : // Sprint 75: CommandBar is now an overlay (Tridactyl/Vimperator
523 : : // behaviour). It is reparented to centralContainer and positioned as
524 : : // a direct child that floats above m_tabStack — opening it no longer
525 : : // pushes the mail content upward. centralLayout afterwards contains
526 : : // only m_tabBar + m_tabStack.
527 [ + - ]: 57 : m_commandBar->setOverlayHost(centralContainer);
528 : :
529 [ + - ]: 57 : setCentralWidget(centralContainer);
530 : :
531 : : // T-141: Set up command list for autocomplete
532 [ + - + + : 1653 : m_commandBar->setCommandList({
- - ]
533 : 57 : QStringLiteral("reply"), QStringLiteral("reply-all"),
534 : 57 : QStringLiteral("forward"), QStringLiteral("compose"),
535 : 57 : QStringLiteral("settings"), QStringLiteral("subscriptions"),
536 : 57 : QStringLiteral("quit"), QStringLiteral("thread-view"),
537 : 57 : QStringLiteral("mark-read"), QStringLiteral("mark-unread"),
538 : 57 : QStringLiteral("star"), QStringLiteral("unstar"),
539 : 57 : QStringLiteral("archive"), QStringLiteral("delete"),
540 : 57 : QStringLiteral("filter unread"), QStringLiteral("filter starred"),
541 : 57 : QStringLiteral("filter clear"),
542 : 57 : QStringLiteral("search-more"), QStringLiteral("sm"),
543 : 57 : QStringLiteral("help"), QStringLiteral("contacts"),
544 : : // T-291: Folder management commands
545 : 57 : QStringLiteral("create"), QStringLiteral("rename"),
546 : 57 : QStringLiteral("move"),
547 : : // Sprint 32: Calendar commands (always available)
548 : 57 : QStringLiteral("calendar"), QStringLiteral("cal"),
549 : 57 : QStringLiteral("tasks"), QStringLiteral("todo"),
550 : : });
551 : :
552 : : // T-145: gg sequence timer (500ms timeout)
553 [ + - + - : 57 : m_ggTimer = new QTimer(this);
- + - - ]
554 [ + - ]: 57 : m_ggTimer->setSingleShot(true);
555 [ + - ]: 57 : m_ggTimer->setInterval(500);
556 [ + - ]: 57 : connect(m_ggTimer, &QTimer::timeout, this, [this]() {
557 : 0 : m_gPending = false;
558 : 0 : });
559 : :
560 : : // IMAP Service
561 [ + - + - : 57 : m_imapService = new ImapService(this);
- + - - ]
562 : :
563 : : // T-720: Health monitor owns periodic liveness probes + backoff
564 : : // reconnect + suspend/network reactivity for the main IMAP connection.
565 : : // Wired before the stateChanged lambda below so the monitor sees every
566 : : // transition; reconnect config is installed in loadAccounts().
567 : : // systemWatchEnabled=true: this is the PRIMARY monitor and the only one
568 : : // that wires the suspend + network-change hooks. The body/search/sync
569 : : // monitors in MailController/SettingsSyncService pass false.
570 [ + - + - : 57 : m_imapHealth = new ConnectionHealthMonitor(true, this);
- + - - ]
571 [ + - ]: 57 : m_imapHealth->attach(m_imapService);
572 : 57 : connect(m_imapHealth, &ConnectionHealthMonitor::statusMessage, this,
573 [ + - ]: 57 : [this](const QString &msg) {
574 [ + - ]: 228 : setStatus(QStringLiteral("reconnect"), msg);
575 : 114 : });
576 : 57 : connect(m_imapHealth, &ConnectionHealthMonitor::connectionRestored, this,
577 [ + - - - ]: 57 : [this]() { clearStatus(QStringLiteral("reconnect")); });
578 : :
579 : : // Mail Cache (SQLite)
580 [ + - + - : 57 : m_cache = new MailCache(this);
- + - - ]
581 : : auto configDir =
582 [ + - ]: 114 : QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)
583 [ + - ]: 171 : + QStringLiteral("/mailjd");
584 [ + - + - ]: 57 : QDir().mkpath(configDir);
585 [ + - ]: 57 : auto cachePath = configDir + QStringLiteral("/mail_cache.db");
586 [ + - - + ]: 57 : if (!m_cache->open(cachePath)) {
587 [ # # # # : 0 : qCWarning(lcMainWindow) << "Failed to open mail cache:" << cachePath;
# # # # #
# ]
588 : : } else {
589 : : // T-179/T-545: One-time FTS index rebuild — runs in background thread
590 : : // to avoid blocking the UI (can take seconds on large caches).
591 [ + - + + ]: 57 : if (m_cache->searchIndexEmpty()) {
592 [ + - + - : 18 : qCInfo(lcMainWindow) << "FTS5 index empty — rebuilding in background...";
+ - + + ]
593 : 9 : QString cacheDbPath = m_cache->databasePath();
594 [ + - ]: 9 : auto *worker = QThread::create([cacheDbPath]() {
595 [ + - ]: 9 : MailCache threadCache;
596 [ + - ]: 9 : threadCache.open(cacheDbPath);
597 [ + - ]: 9 : threadCache.rebuildSearchIndex();
598 [ + - ]: 9 : threadCache.close();
599 : 9 : });
600 [ + - ]: 9 : connect(worker, &QThread::finished, this, [this]() {
601 [ + - + - : 18 : qCInfo(lcMainWindow) << "FTS5 index rebuild complete (background).";
+ - + + ]
602 : 9 : });
603 [ + - ]: 9 : connect(worker, &QThread::finished, worker, &QObject::deleteLater);
604 [ + - ]: 9 : worker->start();
605 : 9 : }
606 : : }
607 [ + - ]: 57 : m_mailView->setCache(m_cache); // T-122: whitelist access
608 : 57 : connect(m_mailView, &MailView::whitelistChanged, this,
609 [ + - ]: 57 : &MainWindow::triggerSettingsUpload); // Trigger C: whitelist sync
610 : :
611 : : // T-351: Connect MailView context menu signals to existing command handlers
612 [ + - ]: 57 : connect(m_mailView, &MailView::replyRequested, this, [this]() {
613 [ + - ]: 2 : executeCommand(QStringLiteral("reply"));
614 : 1 : });
615 [ + - ]: 57 : connect(m_mailView, &MailView::replyAllRequested, this, [this]() {
616 [ + - ]: 2 : executeCommand(QStringLiteral("reply-all"));
617 : 1 : });
618 [ + - ]: 57 : connect(m_mailView, &MailView::forwardRequested, this, [this]() {
619 [ + - ]: 2 : executeCommand(QStringLiteral("forward"));
620 : 1 : });
621 [ + - ]: 57 : connect(m_mailView, &MailView::moveRequested, this, [this]() {
622 : 1 : m_commandBar->activate(CommandBar::MoveToFolder);
623 : 1 : });
624 [ + - ]: 57 : connect(m_mailView, &MailView::archiveRequested, this, [this]() {
625 [ # # ]: 0 : executeCommand(QStringLiteral("archive"));
626 : 0 : });
627 [ + - ]: 57 : connect(m_mailView, &MailView::deleteRequested, this, [this]() {
628 [ # # ]: 0 : executeCommand(QStringLiteral("delete"));
629 : 0 : });
630 : :
631 : : // MailController
632 [ - + - - ]: 57 : m_controller = new MailController(m_imapService, m_cache, m_mailListModel,
633 [ + - + - ]: 57 : m_mailView, this);
634 : 57 : m_controller->setFolderTree(m_folderTree);
635 : 57 : m_controller->setThreadModel(m_mailThreadModel);
636 : :
637 : : // T-211: UndoManager
638 [ + - + - : 57 : m_undoManager = new UndoManager(this);
- + - - ]
639 : 57 : m_controller->setUndoManager(m_undoManager);
640 : :
641 : : // T-142: MailFilterProxyModel no longer uses QuickFilterBar
642 : : // Filter text is set directly from CommandBar signals
643 [ + - - - : 1710 : }
- - ]
644 : :
645 : 143 : void MainWindow::setupMenuBar() {
646 : : // File menu
647 [ + - + - ]: 143 : auto *fileMenu = menuBar()->addMenu(tr("&File"));
648 : :
649 : : // T-089: Compose new mail
650 [ + - + - : 143 : fileMenu->addAction(tr("&New Message"), QKeySequence("Ctrl+N"), this,
+ - ]
651 [ + - ]: 143 : [this]() {
652 [ + - - + : 1 : auto *compose = new ComposeWindow(this);
- - ]
653 : 1 : configureComposeWindow(compose);
654 : 1 : setupComposeTracking(compose);
655 : 1 : compose->setAttribute(Qt::WA_DeleteOnClose);
656 : 1 : compose->show();
657 : 1 : });
658 : :
659 : 143 : fileMenu->addSeparator();
660 [ + - + - : 143 : fileMenu->addAction(tr("&Quit"), QKeySequence("Ctrl+Q"), this,
+ - ]
661 [ + - ]: 143 : &MainWindow::quitApp); // T-124: real quit
662 : :
663 : : // Edit menu
664 [ + - + - ]: 143 : auto *editMenu = menuBar()->addMenu(tr("&Edit"));
665 [ + - + - : 143 : editMenu->addAction(tr("&Settings..."), QKeySequence("Ctrl+,"), this,
+ - ]
666 [ + - ]: 143 : &MainWindow::showSettings);
667 [ + - ]: 143 : editMenu->addAction(tr("&Subscriptions..."), this,
668 [ + - ]: 143 : &MainWindow::showSubscriptionDialog);
669 : :
670 : : // T-099: View menu
671 [ + - + - ]: 143 : auto *viewMenu = menuBar()->addMenu(tr("&View"));
672 [ + - ]: 143 : m_threadViewAction = viewMenu->addAction(
673 [ + - + - : 286 : tr("&Thread View"), QKeySequence("Ctrl+T"));
+ - ]
674 : 143 : m_threadViewAction->setCheckable(true);
675 : 143 : m_threadViewAction->setChecked(false);
676 : 143 : connect(m_threadViewAction, &QAction::toggled, this,
677 [ + - ]: 143 : &MainWindow::toggleThreadView);
678 : 143 : viewMenu->addSeparator();
679 [ + - + - : 143 : viewMenu->addAction(tr("&Calendar"), QKeySequence("Ctrl+Shift+K"), this,
+ - ]
680 [ + - ]: 143 : &MainWindow::openCalendarTab);
681 [ + - + - : 143 : viewMenu->addAction(tr("&Tasks"), QKeySequence("Ctrl+Shift+T"), this,
+ - ]
682 [ + - ]: 143 : &MainWindow::openTaskTab);
683 : :
684 : : // T-163: Extras menu with Contacts
685 [ + - + - ]: 143 : auto *extrasMenu = menuBar()->addMenu(tr("E&xtras"));
686 [ + - + - : 143 : extrasMenu->addAction(tr("&Manage Contacts\u2026"), QKeySequence("Ctrl+K"), this,
+ - ]
687 [ + - ]: 143 : &MainWindow::showContactManager);
688 : 143 : }
689 : :
690 : 57 : void MainWindow::setupStatusBar() {
691 [ + - + - : 57 : m_statusLabel = new QLabel(tr("Ready"), this);
- + - - ]
692 : 57 : statusBar()->addWidget(m_statusLabel, 1);
693 : :
694 : : // T-168/T-181: Folder suggestion label on the right side (clickable)
695 [ + - - + : 57 : m_suggestionLabel = new QLabel(this);
- - ]
696 [ + - ]: 114 : m_suggestionLabel->setObjectName(QStringLiteral("suggestionLabel"));
697 [ + - ]: 114 : m_suggestionLabel->setToolTip(QStringLiteral(
698 : : "Ordnervorschlag – Klick oder S zum Verschieben"));
699 [ + - + - ]: 57 : m_suggestionLabel->setCursor(Qt::PointingHandCursor);
700 : 57 : m_suggestionLabel->hide();
701 : 57 : statusBar()->addPermanentWidget(m_suggestionLabel);
702 : :
703 : : // T-181: Click on suggestion label → quick-move
704 : 57 : m_suggestionLabel->installEventFilter(this);
705 : 57 : }
706 : :
707 : 57 : void MainWindow::connectSignals() {
708 : : // IMAP state changes → status bar
709 : 57 : connect(m_imapService, &ImapService::stateChanged, this,
710 [ + - ]: 57 : [this](ImapService::State state) {
711 [ + + + + : 343 : switch (state) {
+ ]
712 : 88 : case ImapService::State::Connecting:
713 [ + - + - ]: 88 : setStatus("Connecting...");
714 : 88 : break;
715 : 2 : case ImapService::State::Authenticating:
716 [ + - + - ]: 2 : setStatus("Authenticating...");
717 : 2 : break;
718 : 3 : case ImapService::State::Authenticated:
719 [ + - + - ]: 3 : setStatus("Connected. Loading folders...");
720 : 3 : m_imapService->listFolders();
721 [ + - ]: 6 : clearStatus(QStringLiteral("reconnect"));
722 : 3 : break;
723 : 88 : case ImapService::State::Error:
724 : : case ImapService::State::Disconnected:
725 : : // T-720: reconnect scheduling moved into ConnectionHealthMonitor.
726 : : // The monitor sees this same stateChanged signal and arms its
727 : : // backoff timer; this branch only updates the status display.
728 [ + - ]: 88 : setStatus(state == ImapService::State::Error
729 [ + + - - ]: 348 : ? QStringLiteral("Connection error")
730 [ + + + + : 88 : : QStringLiteral("Disconnected"));
- - ]
731 : 88 : break;
732 : 162 : default:
733 : 162 : break;
734 : : }
735 : 343 : });
736 : :
737 : : // IMAP authenticated → request folder list
738 : 57 : connect(m_imapService, &ImapService::folderListReceived, this,
739 [ + - ]: 57 : [this](const QList<FolderInfo> &folders) {
740 : : // T-545: Timing for folderListReceived handler
741 : 6 : QElapsedTimer ft; ft.start();
742 : :
743 : : // T-069: Load hidden folders before populating the tree
744 : : QString configDir =
745 [ + - ]: 12 : QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)
746 [ + - ]: 6 : + "/mailjd";
747 : : QStringList hidden =
748 [ + - ]: 6 : FolderSubscriptionDialog::loadHidden(configDir);
749 : 6 : m_folderTree->setHiddenFolders(hidden);
750 : 6 : m_lastFolderList = folders; // T-069: keep for refresh after hide
751 : 6 : m_folderOps->setFolderList(folders);
752 : :
753 : : // T-290: Extract IMAP delimiter from first folder
754 [ + - + - : 6 : if (!folders.isEmpty() && !folders.first().delimiter.isEmpty())
+ - ]
755 : 6 : m_imapDelimiter = folders.first().delimiter;
756 : 6 : m_folderOps->setDelimiter(m_imapDelimiter);
757 : :
758 : : // T-546: Save expand state before setFolders (which clears the model)
759 [ + - ]: 6 : if (m_pendingExpandedFolders.isEmpty()) {
760 [ + - ]: 6 : m_reconnectExpandedFolders = m_folderTree->expandedFolderPaths();
761 : : }
762 : :
763 [ + - ]: 6 : m_folderTree->setFolders(folders);
764 [ + - + - : 12 : qCInfo(lcMainWindow) << "⏱ setFolders:" << ft.elapsed() << "ms";
+ - + - +
- + + ]
765 : 6 : ft.restart();
766 [ + - + - : 12 : setStatus(QString("Connected – %1 folders").arg(folders.size()));
+ - ]
767 [ + - + - : 12 : qCInfo(lcMainWindow) << "Loaded" << folders.size() << "folders";
+ - + - +
- + + ]
768 : :
769 : : // T-062: Build selectable folder list, load subscriptions
770 : 6 : QStringList selectablePaths;
771 [ + + ]: 51 : for (const auto &f : folders) {
772 [ + - + - ]: 45 : if (!f.flags.contains("\\Noselect", Qt::CaseInsensitive)) {
773 [ + - ]: 45 : selectablePaths.append(f.path);
774 : : }
775 : : }
776 : 6 : m_allFolderPaths = selectablePaths;
777 : 6 : m_search->setKnownFolders(selectablePaths);
778 : :
779 : : // T-142: Feed folder list to CommandBar
780 [ + - ]: 6 : m_commandBar->setFolderList(selectablePaths);
781 : :
782 : : // T-147: Detect special folders from IMAP flags
783 [ + + ]: 51 : for (const auto &f : folders) {
784 [ + + ]: 46 : for (const auto &flag : f.flags) {
785 [ + - - + ]: 1 : if (flag.compare("\\Trash", Qt::CaseInsensitive) == 0)
786 : 0 : m_trashFolder = f.path;
787 [ + - - + ]: 1 : else if (flag.compare("\\Archive", Qt::CaseInsensitive) == 0)
788 : 0 : m_archiveFolder = f.path;
789 : : }
790 : : }
791 : : // Heuristic fallback if flags not set
792 [ + + ]: 6 : if (m_trashFolder.isEmpty()) {
793 [ + - + - : 11 : for (const auto &p : selectablePaths) {
+ + ]
794 [ + - + - : 28 : if (p.contains("Trash", Qt::CaseInsensitive) ||
+ + + + -
- ]
795 [ + - + - : 18 : p.contains("Papierkorb", Qt::CaseInsensitive)) {
- + + + +
- - - ]
796 : 2 : m_trashFolder = p;
797 : 2 : break;
798 : : }
799 : : }
800 : : }
801 [ + - ]: 6 : if (m_archiveFolder.isEmpty()) {
802 [ + - + - : 51 : for (const auto &p : selectablePaths) {
+ + ]
803 [ + - + - : 135 : if (p.contains("Archive", Qt::CaseInsensitive) ||
+ - - + -
- ]
804 [ + - + - : 90 : p.contains("Archiv", Qt::CaseInsensitive)) {
- + + - +
- - - ]
805 : 0 : m_archiveFolder = p;
806 : 0 : break;
807 : : }
808 : : }
809 : : }
810 : :
811 : : // Junk folder detection
812 [ + + ]: 51 : for (const auto &f : folders) {
813 [ + + ]: 46 : for (const auto &flag : f.flags) {
814 [ + - - + ]: 1 : if (flag.compare("\\Junk", Qt::CaseInsensitive) == 0)
815 : 0 : m_junkFolder = f.path;
816 : : }
817 : : }
818 [ + - ]: 6 : if (m_junkFolder.isEmpty()) {
819 [ + - + - : 51 : for (const auto &p : selectablePaths) {
+ + ]
820 [ + - + - : 135 : if (p.contains("Junk", Qt::CaseInsensitive) ||
+ - - + -
- ]
821 [ + - + - : 90 : p.contains("Spam", Qt::CaseInsensitive)) {
- + + - +
- - - ]
822 : 0 : m_junkFolder = p;
823 : 0 : break;
824 : : }
825 : : }
826 : : }
827 : :
828 [ + - + - : 12 : qCInfo(lcMainWindow) << "Special folders: Trash=" << m_trashFolder
+ - + - +
+ ]
829 [ + - + - ]: 6 : << "Archive=" << m_archiveFolder
830 [ + - + - ]: 6 : << "Junk=" << m_junkFolder;
831 : :
832 : : // T-177: Drafts folder detection (IMAP flag first, then name)
833 [ + + ]: 51 : for (const auto &f : folders) {
834 [ + + ]: 46 : for (const auto &flag : f.flags) {
835 [ + - - + ]: 1 : if (flag.compare("\\Drafts", Qt::CaseInsensitive) == 0)
836 : 0 : m_draftsFolder = f.path;
837 : : }
838 : : }
839 [ + + ]: 6 : if (m_draftsFolder.isEmpty()) {
840 [ + - ]: 6 : m_draftsFolder = detectSpecialFolder(QStringLiteral("Drafts"));
841 : : }
842 : :
843 : : // T-178: Sent folder detection (IMAP flag first, then name)
844 [ + + ]: 51 : for (const auto &f : folders) {
845 [ + + ]: 46 : for (const auto &flag : f.flags) {
846 [ + - + - ]: 1 : if (flag.compare("\\Sent", Qt::CaseInsensitive) == 0)
847 : 1 : m_sentFolder = f.path;
848 : : }
849 : : }
850 [ + + ]: 6 : if (m_sentFolder.isEmpty()) {
851 [ + - ]: 4 : m_sentFolder = detectSpecialFolder(QStringLiteral("Sent"));
852 : : }
853 : :
854 [ + - + - : 12 : qCInfo(lcMainWindow) << "Special folders: Drafts=" << m_draftsFolder
+ - + - +
+ ]
855 [ + - + - ]: 6 : << "Sent=" << m_sentFolder;
856 : :
857 : : // Load subscriptions from JSON (empty = first run → subscribe all)
858 : : QStringList subscribed =
859 [ + - ]: 6 : FolderSubscriptionDialog::loadSubscriptions(configDir);
860 [ + + ]: 6 : if (subscribed.isEmpty()) {
861 : : // First run: subscribe all folders and persist
862 : 3 : subscribed = selectablePaths;
863 [ + - ]: 3 : FolderSubscriptionDialog::saveSubscriptions(configDir,
864 : : subscribed);
865 : : }
866 [ + - ]: 6 : m_controller->setSubscribedFolders(subscribed);
867 : :
868 : : // Session restore: expand folders and select last folder
869 [ + - ]: 6 : restoreSessionFolder();
870 : :
871 : : // T-075: Load cached badges and apply immediately
872 [ + - ]: 6 : const auto accs = AccountConfigLoader::loadAll();
873 [ + - ]: 6 : if (!accs.empty()) {
874 [ + - ]: 6 : auto badges = m_cache->loadAllBadges(accs.front().name);
875 [ + - + - : 6 : for (auto it = badges.constBegin(); it != badges.constEnd();
- + ]
876 : 0 : ++it) {
877 [ # # ]: 0 : m_folderTree->setUnreadCount(it.key(), it.value());
878 : : }
879 : 6 : }
880 [ + - + - : 12 : qCInfo(lcMainWindow) << "⏱ restoreSession+badges:"
+ - + + ]
881 [ + - + - ]: 6 : << ft.elapsed() << "ms";
882 : 6 : });
883 : :
884 : : // IMAP errors → user-facing message
885 : 57 : connect(m_imapService, &ImapService::errorOccurred, this,
886 [ + - ]: 57 : [this](const QString &error) {
887 [ + - + - ]: 85 : setStatus("Error: " + error);
888 [ + - + - : 170 : qCWarning(lcMainWindow) << "IMAP error:" << error;
+ - + - +
+ ]
889 : 85 : });
890 : :
891 : : // T-266: Track previous folder — BEFORE controller connect,
892 : : // because onFolderSelected() overwrites currentFolder().
893 : : // Qt invokes slots in connection order.
894 : 57 : connect(m_folderTree, &FolderTree::folderSelected, this,
895 [ + - ]: 57 : [this](const QString &newFolder) {
896 : 28 : QString current = m_controller->currentFolder();
897 [ + + + + : 28 : if (!current.isEmpty() && current != newFolder)
+ + ]
898 : 22 : m_previousFolder = current;
899 : 28 : });
900 : :
901 : : // Folder selection → MailController
902 : 57 : connect(m_folderTree, &FolderTree::folderSelected, m_controller,
903 [ + - ]: 57 : &MailController::onFolderSelected);
904 : :
905 : : // "Suche" node select/close and the search-mode teardown on real-folder
906 : : // selection are owned by SearchCoordinator (wired in its constructor).
907 : :
908 : : // Clear search mode when switching to a real folder
909 : 57 : connect(m_folderTree, &FolderTree::folderSelected, this,
910 [ + - ]: 57 : [this](const QString &) {
911 : 28 : m_search->onRealFolderSelected();
912 : : // T-234: Clear alternate toggle on folder change
913 : 28 : m_alternateUids.clear();
914 : : // T-232: Deactivate suggestion overlay on folder change
915 [ - + ]: 28 : if (m_suggestionColumnVisible) {
916 : 0 : m_mailList->setColumnHidden(MailListModel::Suggestion, true);
917 : 0 : m_suggestionColumnVisible = false;
918 : 0 : m_mailListModel->clearSuggestions();
919 : 0 : m_mailThreadModel->clearSuggestions();
920 : 0 : m_suggestedUids.clear();
921 : : }
922 : 28 : });
923 : :
924 : : // T-069: Folder hide requested → persist + refresh tree
925 : 57 : connect(m_folderTree, &FolderTree::folderHideRequested, this,
926 [ + - ]: 57 : [this](const QString &path) {
927 : : QString configDir =
928 [ + - ]: 2 : QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)
929 [ + - ]: 1 : + "/mailjd";
930 : : QStringList hidden =
931 [ + - ]: 1 : FolderSubscriptionDialog::loadHidden(configDir);
932 [ + - ]: 1 : if (!hidden.contains(path)) {
933 [ + - ]: 1 : hidden.append(path);
934 [ + - ]: 1 : FolderSubscriptionDialog::saveHidden(configDir, hidden);
935 : : }
936 : 1 : m_folderTree->setHiddenFolders(hidden);
937 [ + - ]: 1 : refreshTreeWithBadges();
938 [ + - ]: 1 : triggerSettingsUpload(); // Trigger D: context-menu hide
939 : 1 : });
940 : :
941 : : // T-200: Mark all mails in folder as read
942 : 57 : connect(m_folderTree, &FolderTree::markAllReadRequested,
943 [ + - ]: 57 : m_controller, &MailController::markFolderAllSeen);
944 : :
945 : : // T-134: Folder properties dialog
946 : 57 : connect(m_folderTree, &FolderTree::folderPropertiesRequested, this,
947 [ + - ]: 57 : [this](const QString &path) {
948 : : QString configDir =
949 [ + - ]: 6 : QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)
950 [ + - ]: 3 : + "/mailjd";
951 [ + - ]: 3 : QSettings s;
952 : :
953 : 3 : FolderPropertiesDialog::Options opts;
954 : 3 : opts.folderPath = path;
955 [ + - + - : 3 : opts.currentIcon = s.value("folder/icon/" + path).toString();
+ - ]
956 [ + - + - : 3 : opts.currentColor = s.value("folder/color/" + path).toString();
+ - ]
957 : :
958 : : QStringList subs =
959 [ + - ]: 3 : FolderSubscriptionDialog::loadSubscriptions(configDir);
960 [ + + ]: 3 : if (subs.isEmpty())
961 : 1 : subs = m_allFolderPaths;
962 : 3 : opts.isSubscribed = subs.contains(path);
963 : :
964 : : QStringList hidden =
965 [ + - ]: 3 : FolderSubscriptionDialog::loadHidden(configDir);
966 : 3 : opts.isHidden = hidden.contains(path);
967 : :
968 : : auto *dlg = new FolderPropertiesDialog(
969 : : opts, m_cache, m_folderPredictor,
970 [ + - + - : 3 : m_controller->accountId(), this);
- + - - ]
971 [ + - + + ]: 3 : if (m_runDialog(dlg) != QDialog::Accepted) {
972 [ + - ]: 1 : dlg->deleteLater();
973 : 1 : return;
974 : : }
975 : :
976 : : // --- Icon / Color ---
977 : 2 : QString newIcon = dlg->selectedIcon();
978 : 2 : QString newColor = dlg->selectedColor();
979 [ + - ]: 2 : if (newIcon.isEmpty())
980 [ + - + - ]: 2 : s.remove("folder/icon/" + path);
981 : : else
982 [ # # # # ]: 0 : s.setValue("folder/icon/" + path, newIcon);
983 [ + - ]: 2 : if (newColor.isEmpty())
984 [ + - + - ]: 2 : s.remove("folder/color/" + path);
985 : : else
986 [ # # # # ]: 0 : s.setValue("folder/color/" + path, newColor);
987 : :
988 : : // Update tree item visuals (icons are applied on setFolders)
989 [ + - ]: 2 : refreshTreeWithBadges();
990 : :
991 : : // --- Subscription ---
992 [ + - ]: 2 : bool subChanged = (dlg->isSubscribed() != opts.isSubscribed);
993 [ + + ]: 2 : if (subChanged) {
994 [ + - + - : 1 : if (dlg->isSubscribed() && !subs.contains(path))
+ - + - ]
995 [ + - ]: 1 : subs.append(path);
996 [ # # # # ]: 0 : else if (!dlg->isSubscribed())
997 [ # # ]: 0 : subs.removeAll(path);
998 [ + - ]: 1 : FolderSubscriptionDialog::saveSubscriptions(configDir, subs);
999 [ + - ]: 1 : m_controller->setSubscribedFolders(subs);
1000 : : }
1001 : :
1002 : : // --- Hidden ---
1003 [ + - ]: 2 : bool hiddenChanged = (dlg->isHidden() != opts.isHidden);
1004 [ + + ]: 2 : if (hiddenChanged) {
1005 [ + - - + : 1 : if (dlg->isHidden() && !hidden.contains(path))
- - - + ]
1006 [ # # ]: 0 : hidden.append(path);
1007 [ + - + - ]: 1 : else if (!dlg->isHidden())
1008 [ + - ]: 1 : hidden.removeAll(path);
1009 [ + - ]: 1 : FolderSubscriptionDialog::saveHidden(configDir, hidden);
1010 : 1 : m_folderTree->setHiddenFolders(hidden);
1011 [ + - ]: 1 : refreshTreeWithBadges();
1012 : : }
1013 : :
1014 [ + - ]: 2 : triggerSettingsUpload(); // Trigger A: folder props (icon/color/hidden)
1015 [ + - ]: 2 : dlg->deleteLater();
1016 [ + + + + : 7 : });
+ + + + +
+ ]
1017 : :
1018 : :
1019 : : // T-103/T-104: DnD move from mail list to folder tree
1020 : 57 : connect(m_folderTree, &FolderTree::moveRequested, this,
1021 [ + - ]: 57 : [this](const QList<qint64> &uids, const QString &targetFolder) {
1022 : 1 : QList<MailId> mails;
1023 : 1 : const qint64 sourceFolderId = m_controller->currentFolderId();
1024 : 1 : const QString sourceFolderPath = m_controller->currentFolder();
1025 [ + + ]: 2 : for (qint64 uid : uids) {
1026 : 1 : MailId mail;
1027 : 1 : mail.uid = uid;
1028 : 1 : mail.folderId = sourceFolderId;
1029 : 1 : mail.folderPath = sourceFolderPath;
1030 [ + - ]: 1 : mails.append(mail);
1031 : 1 : }
1032 [ + - ]: 1 : trainAfterMove(mails, targetFolder); // T-170
1033 [ + - ]: 1 : m_controller->moveMailsToFolder(uids, targetFolder);
1034 : 1 : });
1035 : 57 : connect(m_folderTree, &FolderTree::moveMailIdsRequested, this,
1036 [ + - ]: 57 : [this](const QList<MailIdentity> &mails,
1037 : : const QString &targetFolder) {
1038 : 2 : QList<qint64> uids;
1039 : 2 : QList<MailId> mailIds;
1040 : 2 : QMap<qint64, QList<qint64>> byFolder;
1041 : 2 : QMap<qint64, QString> folderPaths;
1042 [ + + ]: 5 : for (const auto &mail : mails) {
1043 [ + + ]: 3 : if (!mail.isValid())
1044 : 2 : continue;
1045 [ + - ]: 1 : uids.append(mail.uid);
1046 [ + - + - ]: 1 : byFolder[mail.folderId].append(mail.uid);
1047 [ + - + - ]: 1 : folderPaths[mail.folderId] = m_cache->folderPath(mail.folderId);
1048 : :
1049 : 1 : MailId id;
1050 : 1 : id.uid = mail.uid;
1051 : 1 : id.folderId = mail.folderId;
1052 [ + - ]: 1 : id.folderPath = folderPaths[mail.folderId];
1053 [ + - ]: 1 : mailIds.append(id);
1054 : 1 : }
1055 [ + + ]: 2 : if (uids.isEmpty())
1056 : 1 : return;
1057 [ + - ]: 1 : trainAfterMove(mailIds, targetFolder); // T-170
1058 [ + - ]: 1 : copyTabCacheToFolder(uids, targetFolder);
1059 [ + - ]: 1 : selectNextAfterMove();
1060 [ + - + - : 2 : for (auto it = byFolder.constBegin(); it != byFolder.constEnd();
+ + ]
1061 : 1 : ++it) {
1062 [ + - ]: 1 : m_controller->moveMailsToFolderFrom(
1063 [ + - ]: 1 : it.value(), it.key(), folderPaths[it.key()], targetFolder);
1064 : : }
1065 [ + + + + : 5 : });
+ + + + ]
1066 : :
1067 : : // T-289/T-290: folder management requests are wired inside
1068 : : // FolderOperationsController (Sprint 65 P2.2).
1069 : :
1070 : : // T-281/T-290: IMAP folder operation results
1071 : 57 : connect(m_imapService, &ImapService::folderCreated, this,
1072 [ + - ]: 57 : [this](const QString &folderPath) {
1073 [ + - ]: 4 : setStatus(QStringLiteral("folder"),
1074 [ + - ]: 6 : QStringLiteral("Ordner erstellt: %1").arg(folderPath), 3000);
1075 : : // Auto-subscribe new folder
1076 : : QString configDir =
1077 [ + - ]: 4 : QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)
1078 [ + - ]: 2 : + "/mailjd";
1079 : : QStringList subs =
1080 [ + - ]: 2 : FolderSubscriptionDialog::loadSubscriptions(configDir);
1081 [ + - ]: 2 : if (!subs.contains(folderPath)) {
1082 [ + - ]: 2 : subs.append(folderPath);
1083 [ + - ]: 2 : FolderSubscriptionDialog::saveSubscriptions(configDir, subs);
1084 [ + - ]: 2 : m_controller->setSubscribedFolders(subs);
1085 : : }
1086 [ + - ]: 2 : m_imapService->executeAfterIdle([this]() {
1087 : 2 : m_imapService->listFolders();
1088 : 2 : });
1089 : 2 : });
1090 : 57 : connect(m_imapService, &ImapService::folderDeleted, this,
1091 [ + - ]: 57 : [this](const QString &folderPath) {
1092 [ + - ]: 14 : setStatus(QStringLiteral("folder"),
1093 [ + - ]: 21 : QStringLiteral("Ordner geloescht: %1").arg(folderPath), 3000);
1094 : : // Purge cache + predictor
1095 [ + - ]: 7 : const auto accs = AccountConfigLoader::loadAll();
1096 [ + - ]: 7 : if (!accs.empty()) {
1097 [ + - ]: 7 : m_cache->purgeFolderByPath(accs.front().name, folderPath);
1098 [ + - ]: 7 : if (m_folderPredictor)
1099 [ + - ]: 7 : m_folderPredictor->resetFolder(folderPath);
1100 : : }
1101 : : // Remove from subscription + hidden lists (also children)
1102 : : auto configDir =
1103 [ + - + - ]: 14 : QStringLiteral("%1/.config/mailjd").arg(QDir::homePath());
1104 [ + + ]: 14 : QString delimiter = m_imapDelimiter.isEmpty() ? QStringLiteral(".")
1105 [ + + ]: 12 : : m_imapDelimiter;
1106 : : QStringList subs =
1107 [ + - ]: 7 : FolderSubscriptionDialog::loadSubscriptions(configDir);
1108 : : QStringList hidden =
1109 [ + - ]: 7 : FolderSubscriptionDialog::loadHidden(configDir);
1110 [ + - ]: 7 : subs.removeAll(folderPath);
1111 [ + - ]: 7 : hidden.removeAll(folderPath);
1112 : : // Also remove children (e.g. "Mailinglisten/Test/Sub")
1113 [ + - ]: 7 : QString prefix = folderPath + delimiter;
1114 [ + - + - : 7 : subs.erase(std::remove_if(subs.begin(), subs.end(),
+ - + - +
- ]
1115 : 0 : [&prefix](const QString &s) {
1116 : 0 : return s.startsWith(prefix);
1117 : : }),
1118 : : subs.end());
1119 [ + - + - : 7 : hidden.erase(std::remove_if(hidden.begin(), hidden.end(),
+ - + - +
- ]
1120 : 0 : [&prefix](const QString &s) {
1121 : 0 : return s.startsWith(prefix);
1122 : : }),
1123 : : hidden.end());
1124 [ + - ]: 7 : FolderSubscriptionDialog::saveSubscriptions(configDir, subs);
1125 [ + - ]: 7 : FolderSubscriptionDialog::saveHidden(configDir, hidden);
1126 [ + - ]: 7 : m_controller->setSubscribedFolders(subs);
1127 : : // Switch to INBOX if deleted folder was active
1128 [ + + ]: 7 : if (m_controller->currentFolder() == folderPath)
1129 [ + - ]: 2 : m_folderTree->selectFolder(QStringLiteral("INBOX"));
1130 [ + - ]: 7 : m_imapService->executeAfterIdle([this]() {
1131 : 7 : m_imapService->listFolders();
1132 : 7 : });
1133 : 7 : });
1134 : 57 : connect(m_imapService, &ImapService::folderRenamed, this,
1135 [ + - ]: 57 : [this](const QString &oldPath, const QString &newPath) {
1136 [ + - ]: 6 : setStatus(QStringLiteral("folder"),
1137 : 6 : QStringLiteral("Ordner umbenannt: %1 → %2")
1138 [ + - ]: 6 : .arg(oldPath, newPath), 3000);
1139 : : // Migrate cache + predictor data
1140 [ + - ]: 3 : const auto accs = AccountConfigLoader::loadAll();
1141 [ + - ]: 3 : if (!accs.empty()) {
1142 [ + - ]: 3 : m_cache->renameFolderPath(accs.front().name, oldPath, newPath);
1143 [ + - ]: 3 : if (m_folderPredictor)
1144 [ + - ]: 3 : m_folderPredictor->renameFolderData(oldPath, newPath);
1145 : : }
1146 : : // Migrate QSettings (icon/color)
1147 [ + - ]: 3 : QSettings s;
1148 : 6 : auto migrateKey = [&](const QString &prefix) {
1149 [ + - + - ]: 6 : auto old = s.value(prefix + oldPath);
1150 [ + - + + ]: 6 : if (old.isValid()) {
1151 [ + - + - ]: 1 : s.setValue(prefix + newPath, old);
1152 [ + - + - ]: 1 : s.remove(prefix + oldPath);
1153 : : }
1154 : 9 : };
1155 [ + - ]: 3 : migrateKey(QStringLiteral("folder/icon/"));
1156 [ + - ]: 3 : migrateKey(QStringLiteral("folder/color/"));
1157 : : // Migrate subscriptions
1158 : : QString configDir =
1159 [ + - ]: 6 : QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)
1160 [ + - ]: 3 : + "/mailjd";
1161 : : QStringList subs =
1162 [ + - ]: 3 : FolderSubscriptionDialog::loadSubscriptions(configDir);
1163 [ + - ]: 3 : if (subs.contains(oldPath)) {
1164 [ + - ]: 3 : subs.removeAll(oldPath);
1165 [ + - ]: 3 : subs.append(newPath);
1166 [ + - ]: 3 : FolderSubscriptionDialog::saveSubscriptions(configDir, subs);
1167 [ + - ]: 3 : m_controller->setSubscribedFolders(subs);
1168 : : }
1169 : : // Follow renamed folder if active
1170 [ + + ]: 3 : if (m_controller->currentFolder() == oldPath)
1171 [ + - ]: 1 : QTimer::singleShot(500, this, [this, newPath]() {
1172 : 1 : m_folderTree->selectFolder(newPath);
1173 : 1 : });
1174 [ + - ]: 3 : m_imapService->executeAfterIdle([this]() {
1175 : 3 : m_imapService->listFolders();
1176 : 3 : });
1177 : 3 : });
1178 : 57 : connect(m_imapService, &ImapService::folderOperationError, this,
1179 [ + - ]: 57 : [this](const QString &operation, const QString &error) {
1180 [ + - ]: 34 : setStatus(QStringLiteral("folder"),
1181 [ + - ]: 34 : QStringLiteral("Fehler bei %1: %2").arg(operation, error),
1182 : : 5000);
1183 : 17 : });
1184 : :
1185 : : // Mail list selection → MailController
1186 [ + - ]: 57 : reconnectSelectionHandler();
1187 : :
1188 : : // Live label refresh: when model data changes, update MailView labels
1189 : : // Uses UID-based matching to work correctly in both flat and thread view.
1190 : 57 : connect(m_mailListModel, &QAbstractItemModel::dataChanged, this,
1191 [ + - ]: 57 : [this](const QModelIndex &topLeft, const QModelIndex &bottomRight) {
1192 [ + - + - ]: 109 : auto selIdx = m_mailList->selectionModel()->currentIndex();
1193 [ + + ]: 109 : if (!selIdx.isValid())
1194 : 75 : return;
1195 : : // Resolve UID of currently displayed mail (works in all view modes)
1196 [ + - ]: 103 : qint64 displayUid = uidFromViewIndex(selIdx);
1197 [ - + ]: 103 : if (displayUid < 0)
1198 : 0 : return;
1199 : : // Check if changed range in flat model includes this UID
1200 [ + + ]: 137 : for (int row = topLeft.row(); row <= bottomRight.row(); ++row) {
1201 [ + - + + ]: 103 : if (m_mailListModel->uidAt(row) == displayUid) {
1202 [ + - ]: 69 : const auto *hdr = m_mailListModel->headerAt(row);
1203 [ + - ]: 69 : if (hdr)
1204 [ + - ]: 69 : m_mailView->refreshLabels(hdr->labels);
1205 : 69 : return;
1206 : : }
1207 : : }
1208 : : });
1209 : :
1210 : : // MailController status → status bar ("folder" key — persists alongside search/body)
1211 : 57 : connect(m_controller, &MailController::statusMessage, this,
1212 [ + - ]: 57 : [this](const QString &msg) {
1213 [ + - ]: 470 : setStatus(QStringLiteral("folder"), msg);
1214 : 235 : });
1215 : :
1216 : : // T-211: Undo feedback → status bar
1217 : 57 : connect(m_undoManager, &UndoManager::undoPerformed, this,
1218 [ + - ]: 57 : [this](const QString &desc) {
1219 [ + - ]: 6 : setStatus(QStringLiteral("undo"),
1220 [ + - ]: 9 : QStringLiteral("Rückgängig: %1").arg(desc), 4000);
1221 : 3 : });
1222 : :
1223 : : // T-215: Create widgets for restored tabs (signal fires during restoreState)
1224 : 57 : connect(m_tabManager, &TabManager::mailTabRequested, this,
1225 [ + - ]: 57 : [this](qint64 uid, qint64 folderId, const QString &messageId) {
1226 [ + - ]: 1 : int tabIdx = m_tabManager->findTabByUid(uid);
1227 [ - + ]: 1 : if (tabIdx < 0) return;
1228 : :
1229 [ + - + - : 1 : auto *tabWidget = new MailTabWidget(m_tabStack);
- + - - ]
1230 [ + - ]: 1 : tabWidget->setCache(m_cache);
1231 [ + - ]: 1 : m_tabStack->insertWidget(tabIdx, tabWidget);
1232 [ + - ]: 1 : m_tabManager->setTabWidget(tabIdx, tabWidget);
1233 : :
1234 : : // Try loading from original folderId+uid
1235 [ + - ]: 1 : auto h = m_cache->header(folderId, uid);
1236 [ - + - - : 1 : if (!h && !messageId.isEmpty()) {
- + ]
1237 : : // Fallback: mail was moved — search by messageId
1238 [ # # ]: 0 : auto loc = m_cache->findByMessageId(messageId);
1239 [ # # ]: 0 : if (loc) {
1240 : 0 : folderId = loc->first;
1241 : 0 : uid = loc->second;
1242 [ # # ]: 0 : h = m_cache->header(folderId, uid);
1243 : : // Update the tab with the new location
1244 [ # # ]: 0 : m_tabManager->updateTabFolder(tabIdx, folderId);
1245 [ # # ]: 0 : m_tabManager->updateTabUid(tabIdx, uid);
1246 : : }
1247 : : }
1248 : :
1249 [ + - ]: 1 : tabWidget->setMailInfo(uid, folderId);
1250 : :
1251 [ - + - - ]: 1 : if (!h) { tabWidget->showNotFoundMessage(); return; }
1252 : :
1253 [ + - ]: 1 : auto body = m_cache->body(folderId, uid);
1254 [ - + ]: 1 : if (body) {
1255 [ # # ]: 0 : MailBody displayBody = body.value();
1256 [ # # ]: 0 : displayBody.attachments = m_cache->attachments(folderId, uid);
1257 [ # # ]: 0 : tabWidget->displayMail(*h, displayBody);
1258 : 0 : } else {
1259 [ + - ]: 1 : tabWidget->showLoadingMessage();
1260 : : // T-544: Wire bodyLoaded signal (same as T-540 fix in openMailInTab)
1261 : 1 : auto *tw = tabWidget;
1262 : 1 : auto hdr = *h;
1263 [ + - ]: 1 : connect(m_controller, &MailController::bodyLoaded, tw,
1264 : 2 : [this, tw, uid, folderId, hdr](qint64 loadedUid, qint64 loadedFolderId) {
1265 [ + - - + ]: 1 : if (loadedUid != uid || loadedFolderId != folderId)
1266 : 0 : return;
1267 [ + - ]: 1 : auto cachedBody = m_cache->body(folderId, uid);
1268 [ + - ]: 1 : if (cachedBody) {
1269 [ + - ]: 1 : MailBody displayBody = cachedBody.value();
1270 [ + - ]: 1 : displayBody.attachments = m_cache->attachments(folderId, uid);
1271 [ + - ]: 1 : tw->displayMail(hdr, displayBody);
1272 : 1 : }
1273 : 1 : });
1274 [ + - ]: 1 : m_controller->onMailSelectedInFolder(uid, folderId);
1275 : 1 : }
1276 [ + - ]: 1 : });
1277 : :
1278 : : // Sprint 32: Restore calendar/task tabs on startup
1279 : 57 : connect(m_tabManager, &TabManager::calendarTabRequested, this,
1280 [ + - ]: 57 : &MainWindow::openCalendarTab);
1281 : 57 : connect(m_tabManager, &TabManager::taskTabRequested, this,
1282 [ + - ]: 57 : &MainWindow::openTaskTab);
1283 : :
1284 : : // Persist tabs + active index on every change so an unclean exit (Ctrl+C)
1285 : : // still restores the tab the user was actually on.
1286 : 57 : connect(m_tabManager, &TabManager::currentTabChanged, this,
1287 [ + - ]: 98 : [this](int, TabInfo::Type) { persistTabState(); });
1288 : 57 : connect(m_tabManager, &TabManager::tabCountChanged, this,
1289 [ + - ]: 86 : [this](int) { persistTabState(); });
1290 : :
1291 : : // Sprint 32: Clear mail-specific shortcuts in Calendar/Task tabs
1292 : : // (setEnabled alone doesn't work — disabled QActions still consume keys)
1293 : 57 : connect(m_tabManager, &TabManager::currentTabChanged, this,
1294 [ + - ]: 57 : [this](int /*index*/, TabInfo::Type type) {
1295 : 41 : bool isCalOrTask =
1296 [ + + + + ]: 41 : (type == TabInfo::CalendarTab || type == TabInfo::TaskTab);
1297 [ + + ]: 41 : if (isCalOrTask) {
1298 : : // Bug 1: Guard against double-fire. If shortcuts are already
1299 : : // saved (e.g. from tab restore or switching between Cal/Task
1300 : : // tabs), don't overwrite m_savedShortcuts with empty sequences.
1301 [ + + ]: 21 : if (!m_savedShortcuts.isEmpty())
1302 : 8 : return;
1303 [ + - + - : 507 : for (auto *action : m_normalModeActions) {
+ + ]
1304 [ + - ]: 494 : QKeySequence seq = action->shortcut();
1305 : : // Keep CommandBar (:) and Filter (/) active in all tabs
1306 [ + - + - : 1469 : if (seq == QKeySequence(Qt::Key_Colon) ||
+ + + + -
- ]
1307 [ + - + - : 975 : seq == QKeySequence(Qt::Key_Slash))
+ + + + +
- - - ]
1308 : 26 : continue;
1309 [ + - + - ]: 468 : m_savedShortcuts[action] = seq;
1310 [ + - + - ]: 468 : action->setShortcut(QKeySequence());
1311 [ + + ]: 494 : }
1312 : : // Calendar-specific command list
1313 [ + - + + : 143 : m_commandBar->setCommandList({
- - ]
1314 : 13 : QStringLiteral("calendar"), QStringLiteral("cal"),
1315 : 13 : QStringLiteral("tasks"), QStringLiteral("todo"),
1316 : 13 : QStringLiteral("today"), QStringLiteral("week"),
1317 : 13 : QStringLiteral("month"),
1318 : 13 : QStringLiteral("settings"), QStringLiteral("quit"),
1319 : 13 : QStringLiteral("help"),
1320 : : });
1321 : : } else {
1322 [ + - ]: 20 : for (auto it = m_savedShortcuts.begin();
1323 [ + - + + ]: 308 : it != m_savedShortcuts.end(); ++it)
1324 [ + - ]: 288 : it.key()->setShortcut(it.value());
1325 : 20 : m_savedShortcuts.clear();
1326 : : // Restore full command list
1327 [ + - + + : 540 : m_commandBar->setCommandList({
- - ]
1328 : 20 : QStringLiteral("reply"), QStringLiteral("reply-all"),
1329 : 20 : QStringLiteral("forward"), QStringLiteral("compose"),
1330 : 20 : QStringLiteral("settings"), QStringLiteral("subscriptions"),
1331 : 20 : QStringLiteral("quit"), QStringLiteral("thread-view"),
1332 : 20 : QStringLiteral("mark-read"), QStringLiteral("mark-unread"),
1333 : 20 : QStringLiteral("star"), QStringLiteral("unstar"),
1334 : 20 : QStringLiteral("archive"), QStringLiteral("delete"),
1335 : 20 : QStringLiteral("filter unread"),
1336 : 20 : QStringLiteral("filter starred"),
1337 : 20 : QStringLiteral("filter clear"),
1338 : 20 : QStringLiteral("help"), QStringLiteral("contacts"),
1339 : 20 : QStringLiteral("create"), QStringLiteral("rename"),
1340 : 20 : QStringLiteral("move"),
1341 : 20 : QStringLiteral("calendar"), QStringLiteral("cal"),
1342 : 20 : QStringLiteral("tasks"), QStringLiteral("todo"),
1343 : : });
1344 : : }
1345 [ + - + - : 683 : });
- - - - -
- - - ]
1346 : :
1347 : : // Unread count badge updates (from flag sync and IDLE)
1348 : 57 : connect(m_controller, &MailController::unreadCountChanged, this,
1349 [ + - ]: 57 : [this](const QString &folder, int count) {
1350 : : // T-197: During search, model->unreadCount() reports search results,
1351 : : // not the real folder count. Skip badge update for the active folder.
1352 [ - + - - ]: 151 : if (m_search->isSearchMode() &&
1353 [ - + - + ]: 151 : folder == m_controller->currentFolder()) {
1354 : 0 : return;
1355 : : }
1356 : 151 : m_folderTree->setUnreadCount(folder, count);
1357 [ + + ]: 151 : if (folder == QStringLiteral("INBOX"))
1358 : 118 : updateTrayIcon(count); // T-124
1359 : : });
1360 : :
1361 : : // T-176: Train predictor when headers arrive from IMAP (train-on-visit)
1362 : 57 : connect(m_controller, &MailController::headersStored, this,
1363 [ + - ]: 57 : [this](const QString &folderPath, const QList<MailHeader> &headers) {
1364 [ + - - + : 10 : if (!m_folderPredictor || !m_folderPredictor->isOpen())
- + ]
1365 : 0 : return;
1366 : : // Skip excluded folders
1367 : : static const QStringList excludes = {
1368 : 3 : QStringLiteral("INBOX"), QStringLiteral("Sent"),
1369 : 3 : QStringLiteral("Trash"), QStringLiteral("Drafts"),
1370 : 3 : QStringLiteral("Junk"), QStringLiteral("Spam"),
1371 [ + + + - : 37 : QStringLiteral("Archive")};
+ + - - -
- ]
1372 [ + - ]: 17 : for (const QString &ex : excludes) {
1373 : 48 : if (folderPath.compare(ex, Qt::CaseInsensitive) == 0 ||
1374 [ + - + - : 24 : folderPath.endsWith(QLatin1Char('.') + ex,
+ - + + -
- ]
1375 : 14 : Qt::CaseInsensitive) ||
1376 [ + - + - : 24 : folderPath.endsWith(QLatin1Char('/') + ex,
+ - + + -
- ]
1377 : 14 : Qt::CaseInsensitive) ||
1378 [ + - + - : 24 : folderPath.startsWith(ex + QLatin1Char('.'),
+ - + + -
- ]
1379 [ + + ]: 24 : Qt::CaseInsensitive) ||
1380 [ + - + - : 24 : folderPath.startsWith(ex + QLatin1Char('/'),
- + + + +
+ - - ]
1381 : : Qt::CaseInsensitive))
1382 : 10 : return;
1383 : : }
1384 [ # # ]: 0 : for (const auto &h : headers) {
1385 [ # # ]: 0 : m_folderPredictor->train(h.from, h.subject, h.to, folderPath);
1386 : : }
1387 [ + - - - : 24 : });
- - ]
1388 : :
1389 : : // Sprint 49 / 67.A2: Desktop notification for new mails. All INBOX
1390 : : // header batches go through the NotificationBatcher, which buffers
1391 : : // until the first INBOX sync of the session finished (replaces the old
1392 : : // fragile rowCount-vs-batch-size heuristic) and clusters bursts into
1393 : : // summary notifications.
1394 : 57 : connect(m_controller, &MailController::headersStored, this,
1395 [ + - ]: 67 : [this](const QString &folderPath, const QList<MailHeader> &headers) {
1396 [ + + ]: 10 : if (folderPath != QStringLiteral("INBOX"))
1397 : 3 : return;
1398 : : // headersStored emits headers before their folderId is fixed up
1399 : : // (IMAP-parsed headers carry folderId=0) — resolve from the
1400 : : // controller, which is on INBOX when this signal fires.
1401 : 7 : const qint64 folderId = m_controller->currentFolderId();
1402 [ + + ]: 36 : for (const auto &h : headers) {
1403 [ + - ]: 29 : m_notificationBatcher->addPending(h.from, h.subject, h.uid,
1404 : : folderId);
1405 : : }
1406 : : });
1407 : 57 : connect(m_controller, &MailController::inboxFirstSyncCompleted, this,
1408 [ + - ]: 57 : [this](bool initialLoad) {
1409 : 2 : m_notificationBatcher->setSyncComplete(initialLoad);
1410 : 2 : });
1411 : :
1412 : : // T-099 fix: When flat model resets and thread view is active,
1413 : : // sync the thread model so the view stays current during folder switch.
1414 : : // IMPORTANT: This must run BEFORE restoreSessionMail so the thread model
1415 : : // is up-to-date when the proxy maps source indices.
1416 : 57 : connect(m_mailListModel, &QAbstractItemModel::modelReset, this,
1417 [ + - ]: 57 : [this]() {
1418 [ + + ]: 112 : if (m_threadViewActive) {
1419 : 17 : saveExpandedState();
1420 : 17 : m_mailThreadModel->setHeaders(m_mailListModel->allHeaders());
1421 [ + - ]: 17 : if (m_threadExpandedInitial) {
1422 : 17 : restoreExpandedState();
1423 : : } else {
1424 : 0 : m_mailList->expandAll();
1425 : 0 : m_threadExpandedInitial = true;
1426 : : }
1427 : : }
1428 : 112 : });
1429 : :
1430 : : // When model is reset (headers loaded), try to restore session mail.
1431 : : // Runs AFTER thread model sync so proxy mapping is correct in thread view.
1432 : 57 : connect(m_mailListModel, &QAbstractItemModel::modelReset, this,
1433 [ + - ]: 57 : [this]() {
1434 : 112 : restoreSessionMail();
1435 : : // T-127: Restore thread view on first load
1436 [ + + + - ]: 112 : if (m_pendingThreadView && !m_threadViewActive
1437 [ + - ]: 20 : && m_threadViewAction) {
1438 : 20 : m_threadViewAction->setChecked(true);
1439 : 20 : m_pendingThreadView = false;
1440 : : }
1441 : 112 : });
1442 : :
1443 : : // Also sync when flat model gets new rows appended (incremental IMAP sync)
1444 : 57 : connect(m_mailListModel, &QAbstractItemModel::rowsInserted, this,
1445 [ + - ]: 57 : [this]() {
1446 [ + + ]: 19 : if (m_threadViewActive) {
1447 : : // T-548: Save current selection before thread model reset
1448 : : // (setHeaders calls beginResetModel which destroys QTreeView selection)
1449 : 8 : qint64 savedUid = -1;
1450 : : {
1451 [ + - + - ]: 8 : auto idx = m_mailList->selectionModel()->currentIndex();
1452 [ - + ]: 8 : if (idx.isValid())
1453 [ # # ]: 0 : savedUid = uidFromViewIndex(idx);
1454 : : }
1455 : :
1456 : 8 : saveExpandedState();
1457 : 8 : m_mailThreadModel->setHeaders(m_mailListModel->allHeaders());
1458 : 8 : restoreExpandedState();
1459 : :
1460 : : // T-548: Restore selection after model reset
1461 [ - + ]: 8 : if (savedUid > 0) {
1462 [ # # ]: 0 : auto newIdx = m_mailThreadModel->indexForUid(
1463 : 0 : savedUid, m_controller->currentFolderId());
1464 [ # # ]: 0 : if (newIdx.isValid()) {
1465 [ # # ]: 0 : auto proxyIdx = m_mailListProxy->mapFromSource(newIdx);
1466 [ # # ]: 0 : if (proxyIdx.isValid()) {
1467 [ # # # # ]: 0 : m_mailList->selectionModel()->setCurrentIndex(
1468 : : proxyIdx,
1469 : : QItemSelectionModel::ClearAndSelect |
1470 : : QItemSelectionModel::Rows);
1471 : : }
1472 : : }
1473 : : }
1474 : : }
1475 : 19 : });
1476 : :
1477 : : // AttachmentBar download signals → MailController
1478 [ + - ]: 57 : connect(m_mailView->attachmentBar(), &AttachmentBar::downloadRequested, this,
1479 : 57 : [this](qint64 attachmentId, const QString &filename) {
1480 : 2 : auto savePath = QFileDialog::getSaveFileName(
1481 : 4 : this, QStringLiteral("Attachment speichern"),
1482 [ + - + - ]: 6 : attachmentSaveDialogPath(filename));
1483 [ + + ]: 2 : if (!savePath.isEmpty()) {
1484 [ + - ]: 1 : m_controller->downloadAttachment(attachmentId, savePath);
1485 : : }
1486 : 2 : });
1487 : :
1488 [ + - ]: 57 : connect(m_mailView->attachmentBar(), &AttachmentBar::downloadAllRequested,
1489 : 57 : this, [this]() {
1490 : 2 : const auto attachments = m_mailView->attachmentBar()->attachments();
1491 [ + + ]: 2 : if (attachments.isEmpty()) {
1492 [ + - ]: 2 : setStatus(QStringLiteral("Keine Attachments zum Speichern"));
1493 : 1 : return;
1494 : : }
1495 : :
1496 : 1 : auto dir = QFileDialog::getExistingDirectory(
1497 [ + - ]: 1 : this, QStringLiteral("Ordner für Attachments"));
1498 [ - + ]: 1 : if (dir.isEmpty())
1499 : 0 : return;
1500 : :
1501 [ + - ]: 1 : const QDir targetDir(dir);
1502 : 1 : QSet<QString> reservedNames;
1503 : 1 : int saved = 0;
1504 : 1 : int failed = 0;
1505 [ + + ]: 3 : for (const auto &attachment : attachments) {
1506 : : const QString savePath = uniqueAttachmentSavePath(
1507 [ + - ]: 2 : targetDir, attachment.filename, &reservedNames);
1508 [ + - + - ]: 2 : if (m_controller->downloadAttachment(attachment.id, savePath))
1509 : 2 : ++saved;
1510 : : else
1511 : 0 : ++failed;
1512 : 2 : }
1513 : :
1514 [ + - ]: 1 : if (failed == 0) {
1515 [ + - ]: 4 : setStatus(QStringLiteral("%1 Attachments gespeichert")
1516 [ + - ]: 3 : .arg(saved));
1517 : : } else {
1518 [ # # ]: 0 : setStatus(QStringLiteral(
1519 : : "%1 Attachments gespeichert, %2 fehlgeschlagen")
1520 [ # # ]: 0 : .arg(saved)
1521 [ # # ]: 0 : .arg(failed));
1522 : : }
1523 [ + - + + ]: 2 : });
1524 : :
1525 : : // T-060: Context menu on MailList for toggle read status
1526 : 57 : connect(m_mailList, &QWidget::customContextMenuRequested, this,
1527 [ + - ]: 57 : [this](const QPoint &pos) {
1528 [ + - ]: 1 : auto idx = m_mailList->indexAt(pos);
1529 [ - + ]: 1 : if (!idx.isValid())
1530 : 0 : return;
1531 [ + - ]: 1 : auto id = mailIdFromViewIndex(idx);
1532 [ - + ]: 1 : if (!id.isValid())
1533 : 0 : return;
1534 : 1 : qint64 uid = id.uid;
1535 [ + - ]: 1 : int row = m_mailListModel->rowForUid(uid, id.folderId);
1536 [ + - ]: 1 : auto *header = m_mailListModel->headerAt(row);
1537 [ - + ]: 1 : if (!header)
1538 : 0 : return;
1539 : :
1540 : : // T-407: Resolve folderId for search-mode safety
1541 : 1 : qint64 folderId = id.folderId;
1542 [ - + - - ]: 1 : bool crossFolder = isSearchMode() && folderId > 0;
1543 : :
1544 [ + - ]: 1 : QMenu menu(this);
1545 : 1 : QString label = header->isSeen()
1546 : 0 : ? QStringLiteral("Als ungelesen markieren")
1547 [ - + + - : 2 : : QStringLiteral("Als gelesen markieren");
- + ]
1548 [ + - ]: 1 : menu.addAction(label, [this, uid, folderId, crossFolder]() {
1549 [ - + ]: 1 : if (crossFolder)
1550 : 0 : m_controller->toggleReadStatusInFolder(uid, folderId);
1551 : : else
1552 : 1 : m_controller->toggleReadStatus(uid);
1553 : 1 : });
1554 : :
1555 : : // Star toggle
1556 : 1 : QString starLabel = header->isFlagged()
1557 : 0 : ? QStringLiteral("Markierung entfernen")
1558 [ - + + - : 2 : : QStringLiteral("Markieren ★");
- + ]
1559 [ + - ]: 1 : menu.addAction(starLabel, [this, uid, folderId, crossFolder]() {
1560 [ - + ]: 1 : if (crossFolder)
1561 : 0 : m_controller->toggleStarredInFolder(uid, folderId);
1562 : : else
1563 : 1 : m_controller->toggleStarred(uid);
1564 : 1 : });
1565 : :
1566 : : // Label submenu (T-088)
1567 [ + - + - ]: 1 : auto *labelMenu = menu.addMenu(tr("Label"));
1568 : : struct LabelDef {
1569 : : QString id;
1570 : : QString name;
1571 : : };
1572 : : QList<LabelDef> labels = {
1573 : : {"$label1", "Wichtig"}, {"$label2", "Arbeit"},
1574 : : {"$label3", "Persönlich"}, {"$label4", "To Do"},
1575 : : {"$label5", "Später"}, {"$Important", "Important"},
1576 [ + + - - ]: 7 : };
1577 [ + - + - : 7 : for (const auto &ld : labels) {
+ + ]
1578 : 6 : bool has = header->labels.contains(ld.id);
1579 [ + + + - : 7 : QString text = (has ? QStringLiteral("✓ ") : QString()) + ld.name;
+ + - - ]
1580 : 12 : labelMenu->addAction(
1581 [ + - - - ]: 6 : text, [this, uid, folderId, crossFolder,
1582 : 6 : id = ld.id, has]() {
1583 [ + + ]: 2 : if (has) {
1584 [ - + ]: 1 : if (crossFolder)
1585 : 0 : m_controller->removeLabelInFolder(uid, folderId, id);
1586 : : else
1587 : 1 : m_controller->removeLabel(uid, id);
1588 : : } else {
1589 [ - + ]: 1 : if (crossFolder)
1590 : 0 : m_controller->addLabelInFolder(uid, folderId, id);
1591 : : else
1592 : 1 : m_controller->addLabel(uid, id);
1593 : : }
1594 : 2 : });
1595 : 6 : }
1596 : :
1597 [ + - ]: 1 : menu.addSeparator();
1598 : :
1599 : : // T-092: Reply / Reply All / Forward
1600 [ + - + - ]: 1 : menu.addAction(tr("Reply"), [this, uid]() {
1601 : 1 : openReply(uid, false);
1602 : 1 : });
1603 [ + - + - ]: 1 : menu.addAction(tr("Reply All"), [this, uid]() {
1604 : 1 : openReply(uid, true);
1605 : 1 : });
1606 [ + - + - ]: 1 : menu.addAction(tr("Forward"), [this, uid]() {
1607 : 1 : openForward(uid);
1608 : 1 : });
1609 : :
1610 [ + - + - : 1 : menu.exec(m_mailList->viewport()->mapToGlobal(pos));
+ - ]
1611 [ + - + - : 2 : });
+ - + - +
- + - + -
+ - + - +
- + - + -
+ - + - -
- - - - -
- - - - -
- - - -
- ]
1612 : :
1613 : : // ═══════════════════════════════════════════════════════
1614 : : // T-143/T-144: Tridactyl-Style Keyboard Shortcuts
1615 : : // ═══════════════════════════════════════════════════════
1616 : :
1617 : : // Helper lambda: get UID of currently selected mail
1618 : 5 : auto currentUid = [this]() -> qint64 {
1619 : : // T-216: In a mail tab, use the tab's UID
1620 [ + - + - : 5 : if (m_tabManager && !m_tabManager->isMainView()) {
- + - + ]
1621 [ # # ]: 0 : return m_tabManager->currentTabInfo().mailUid;
1622 : : }
1623 [ + - + - ]: 5 : auto idx = m_mailList->selectionModel()->currentIndex();
1624 [ + + ]: 5 : if (!idx.isValid()) return -1;
1625 [ + - ]: 2 : return uidFromViewIndex(idx);
1626 : 57 : };
1627 : : // --- T-144: r = Read/Unread toggle (was U) ---
1628 [ + - + - : 57 : auto *toggleReadAction = new QAction(this);
- + - - ]
1629 [ + - + - ]: 57 : toggleReadAction->setShortcut(QKeySequence(Qt::Key_R));
1630 [ + - ]: 57 : connect(toggleReadAction, &QAction::triggered, this, [this]() {
1631 [ + - ]: 2 : auto id = currentMailId();
1632 [ - + ]: 2 : if (!id.isValid()) return;
1633 [ - + - - : 2 : if (isSearchMode() && id.hasFolderId())
- + ]
1634 [ # # ]: 0 : m_controller->toggleReadStatusInFolder(id.uid, id.folderId);
1635 : : else
1636 [ + - ]: 2 : m_controller->toggleReadStatus(id.uid);
1637 [ + - ]: 2 : });
1638 [ + - ]: 57 : addAction(toggleReadAction);
1639 [ + - ]: 57 : m_normalModeActions.append(toggleReadAction);
1640 : :
1641 : : // --- T-144: m = Star/Markierung toggle (was S) ---
1642 [ + - + - : 57 : auto *toggleStarAction = new QAction(this);
- + - - ]
1643 [ + - + - ]: 57 : toggleStarAction->setShortcut(QKeySequence(Qt::Key_M));
1644 [ + - ]: 57 : connect(toggleStarAction, &QAction::triggered, this, [this]() {
1645 [ + - ]: 2 : auto id = currentMailId();
1646 [ - + ]: 2 : if (!id.isValid()) return;
1647 [ - + - - : 2 : if (isSearchMode() && id.hasFolderId())
- + ]
1648 [ # # ]: 0 : m_controller->toggleStarredInFolder(id.uid, id.folderId);
1649 : : else
1650 [ + - ]: 2 : m_controller->toggleStarred(id.uid);
1651 [ + - ]: 2 : });
1652 [ + - ]: 57 : addAction(toggleStarAction);
1653 [ + - ]: 57 : m_normalModeActions.append(toggleStarAction);
1654 : :
1655 : : // --- T-144: n = Neue Nachricht ---
1656 [ + - + - : 57 : auto *composeAction = new QAction(this);
- + - - ]
1657 [ + - + - ]: 57 : composeAction->setShortcut(QKeySequence(Qt::Key_N));
1658 [ + - ]: 57 : connect(composeAction, &QAction::triggered, this, [this]() {
1659 [ + - - + : 1 : auto *compose = new ComposeWindow(this);
- - ]
1660 : 1 : configureComposeWindow(compose);
1661 : 1 : setupComposeTracking(compose);
1662 : 1 : compose->setAttribute(Qt::WA_DeleteOnClose);
1663 : 1 : compose->show();
1664 : 1 : });
1665 [ + - ]: 57 : addAction(composeAction);
1666 [ + - ]: 57 : m_normalModeActions.append(composeAction);
1667 : :
1668 : : // --- T-144: : = CommandBar (Command mode) ---
1669 [ + - + - : 57 : auto *cmdBarAction = new QAction(this);
- + - - ]
1670 [ + - + - ]: 57 : cmdBarAction->setShortcut(QKeySequence(Qt::Key_Colon));
1671 [ + - ]: 57 : connect(cmdBarAction, &QAction::triggered, this, [this]() {
1672 : 1 : m_commandBar->activate(CommandBar::Command);
1673 : 1 : });
1674 [ + - ]: 57 : addAction(cmdBarAction);
1675 [ + - ]: 57 : m_normalModeActions.append(cmdBarAction);
1676 : :
1677 : : // --- T-144: / = CommandBar (Filter mode) ---
1678 [ + - + - : 57 : auto *filterAction = new QAction(this);
- + - - ]
1679 [ + - + - ]: 57 : filterAction->setShortcut(QKeySequence(Qt::Key_Slash));
1680 [ + - ]: 57 : connect(filterAction, &QAction::triggered, this, [this]() {
1681 : 1 : m_commandBar->activate(CommandBar::Filter);
1682 : 1 : });
1683 [ + - ]: 57 : addAction(filterAction);
1684 [ + - ]: 57 : m_normalModeActions.append(filterAction);
1685 : :
1686 : : // --- Esc = Close CommandBar from anywhere (NOT in normalModeActions) ---
1687 [ + - + - : 57 : auto *escAction = new QAction(this);
- + - - ]
1688 [ + - + - ]: 57 : escAction->setShortcut(QKeySequence(Qt::Key_Escape));
1689 [ + - ]: 57 : connect(escAction, &QAction::triggered, this, [this]() {
1690 : : // Esc closes the shortcut help overlay (opened with ?) if it is showing.
1691 : : // The global Esc QAction (WindowShortcut) otherwise consumes the key
1692 : : // before the overlay's own keyPressEvent can run.
1693 [ + - + + : 176 : if (m_helpOverlay && m_helpOverlay->isVisible()) {
+ + ]
1694 : 1 : m_helpOverlay->hide();
1695 : 1 : return;
1696 : : }
1697 : : // T-232: Esc closes suggestion overlay if active
1698 [ + + ]: 175 : if (m_suggestionColumnVisible) {
1699 : 1 : m_mailList->setColumnHidden(MailListModel::Suggestion, true);
1700 : 1 : m_suggestionColumnVisible = false;
1701 : 1 : m_mailListModel->clearSuggestions();
1702 : 1 : m_mailThreadModel->clearSuggestions();
1703 : 1 : m_suggestedUids.clear();
1704 : 1 : return;
1705 : : }
1706 : : // Fix: CommandBar check FIRST, tab close SECOND
1707 [ + + ]: 174 : if (m_commandBar->isActive()) {
1708 : 4 : bool wasSearch = (m_commandBar->currentMode() == CommandBar::Search);
1709 : 4 : m_commandBar->deactivate();
1710 [ + - ]: 4 : m_mailListProxy->setFilterText({});
1711 : : // If Search mode was active, also restore the pre-search folder
1712 [ + + ]: 4 : if (wasSearch)
1713 : 1 : m_search->onSearchBarEscape();
1714 : 4 : m_mailList->setFocus();
1715 [ + - - + : 170 : } else if (m_tabManager && !m_tabManager->isMainView()) {
- + ]
1716 : : // Sprint 56: If task description editor is active, close it first
1717 [ # # # # : 0 : if (m_taskListWidget && m_taskListWidget->isEditing()) {
# # ]
1718 [ # # ]: 0 : m_taskListWidget->finishDescriptionEdit(true);
1719 : 0 : return;
1720 : : }
1721 [ # # ]: 0 : auto info = m_tabManager->currentTabInfo();
1722 [ # # ]: 0 : if (info.type == TabInfo::MailTab)
1723 [ # # ]: 0 : m_tabManager->closeCurrentTab();
1724 : : else
1725 [ # # ]: 0 : m_tabManager->switchToMainView();
1726 [ + - + - : 340 : } else if (m_tabManager && m_tabManager->isMainView() &&
+ - ]
1727 [ + + + - : 340 : !m_mailListProxy->filterText().isEmpty()) {
+ + - - ]
1728 : : // Sprint 59 (U4): a "/" quick filter narrows the ALREADY-loaded results
1729 : : // (search or folder) locally. Esc lifts that extra narrowing FIRST and
1730 : : // leaves the underlying search intact; a second Esc then exits the
1731 : : // search. This reorders the Sprint 58 (K2) behaviour deliberately so the
1732 : : // quick filter composes with — rather than is consumed by — search mode.
1733 [ + - ]: 2 : m_mailListProxy->setFilterText({});
1734 : 2 : m_mailList->setFocus();
1735 [ + + ]: 168 : } else if (m_search->isSearchMode()) {
1736 : : // T-188: Esc from search results → cancel server search + restore folder
1737 : 2 : m_search->exitSearch();
1738 : : }
1739 : : });
1740 [ + - ]: 57 : addAction(escAction);
1741 : :
1742 : : // --- T-144: b = CommandBar (Folder switch mode) ---
1743 [ + - + - : 57 : auto *folderSwitchAction = new QAction(this);
- + - - ]
1744 [ + - + - ]: 57 : folderSwitchAction->setShortcut(QKeySequence(Qt::Key_B));
1745 [ + - ]: 57 : connect(folderSwitchAction, &QAction::triggered, this, [this]() {
1746 : 1 : m_commandBar->activate(CommandBar::FolderSwitch);
1747 : 1 : });
1748 [ + - ]: 57 : addAction(folderSwitchAction);
1749 [ + - ]: 57 : m_normalModeActions.append(folderSwitchAction);
1750 : :
1751 : : // --- Sprint 32: c = Open Calendar tab ---
1752 [ + - + - : 57 : auto *calendarAction = new QAction(this);
- + - - ]
1753 [ + - + - ]: 57 : calendarAction->setShortcut(QKeySequence(Qt::Key_C));
1754 : 57 : connect(calendarAction, &QAction::triggered, this,
1755 [ + - ]: 57 : &MainWindow::openCalendarTab);
1756 [ + - ]: 57 : addAction(calendarAction);
1757 [ + - ]: 57 : m_normalModeActions.append(calendarAction);
1758 : :
1759 : : // --- Sprint 39: t = Open AddTask mode in CommandBar ---
1760 [ + - + - : 57 : auto *addTaskAction = new QAction(this);
- + - - ]
1761 [ + - + - ]: 57 : addTaskAction->setShortcut(QKeySequence(Qt::Key_T));
1762 [ + - ]: 57 : connect(addTaskAction, &QAction::triggered, this, [this]() {
1763 [ + - + - ]: 1 : if (!m_calendarStore) initCalendarSync();
1764 : : // Populate calendar list for autocomplete
1765 [ + - ]: 1 : auto cals = m_calendarStore->allCalendars();
1766 : 1 : QStringList calPaths;
1767 [ + - + - : 1 : for (const auto &c : cals)
- + ]
1768 [ # # ]: 0 : calPaths << c.path;
1769 [ + - ]: 1 : m_commandBar->setCalendarList(calPaths);
1770 [ + - ]: 1 : m_commandBar->activate(CommandBar::AddTask);
1771 : 1 : });
1772 [ + - ]: 57 : addAction(addTaskAction);
1773 [ + - ]: 57 : m_normalModeActions.append(addTaskAction);
1774 : :
1775 : : // --- T-266: Shift+B = Go to previous folder ---
1776 [ + - + - : 57 : auto *prevFolderAction = new QAction(this);
- + - - ]
1777 [ + - + - ]: 57 : prevFolderAction->setShortcut(QKeySequence(Qt::SHIFT | Qt::Key_B));
1778 [ + - ]: 57 : connect(prevFolderAction, &QAction::triggered, this, [this]() {
1779 [ - + ]: 2 : if (m_previousFolder.isEmpty()) {
1780 [ # # ]: 0 : setStatus(QStringLiteral("Kein vorheriger Ordner"));
1781 : 0 : return;
1782 : : }
1783 : 2 : m_folderTree->selectFolder(m_previousFolder);
1784 : : });
1785 [ + - ]: 57 : addAction(prevFolderAction);
1786 [ + - ]: 57 : m_normalModeActions.append(prevFolderAction);
1787 : :
1788 : : // --- T-291: Ctrl+Shift+N = Neuer Unterordner ---
1789 [ + - + - : 57 : auto *newFolderAction = new QAction(this);
- + - - ]
1790 [ + - + - ]: 57 : newFolderAction->setShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_N));
1791 [ + - ]: 57 : connect(newFolderAction, &QAction::triggered, this, [this]() {
1792 [ + - + - ]: 1 : m_folderOps->createFolder(m_folderTree->selectedFolderPath());
1793 : 1 : });
1794 [ + - ]: 57 : addAction(newFolderAction);
1795 [ + - ]: 57 : m_normalModeActions.append(newFolderAction);
1796 : :
1797 : : // --- T-291: F2 = Ordner umbenennen ---
1798 [ + - + - : 57 : auto *renameFolderAction = new QAction(this);
- + - - ]
1799 [ + - + - ]: 57 : renameFolderAction->setShortcut(QKeySequence(Qt::Key_F2));
1800 [ + - ]: 57 : connect(renameFolderAction, &QAction::triggered, this, [this]() {
1801 [ + - ]: 2 : auto path = m_folderTree->selectedFolderPath();
1802 [ - + ]: 2 : if (path.isEmpty()) return;
1803 [ + - + - ]: 2 : if (m_folderOps->isProtectedFolderPath(path)) {
1804 [ + - ]: 4 : setStatus(QStringLiteral("folder"),
1805 : 4 : QStringLiteral("Spezialordner koennen nicht umbenannt werden"), 3000);
1806 : 2 : return;
1807 : : }
1808 [ # # ]: 0 : m_folderOps->renameFolder(path);
1809 [ - + ]: 2 : });
1810 [ + - ]: 57 : addAction(renameFolderAction);
1811 [ + - ]: 57 : m_normalModeActions.append(renameFolderAction);
1812 : :
1813 : : // --- T-291: Shift+D = Ordner loeschen ---
1814 [ + - + - : 57 : auto *deleteFolderAction = new QAction(this);
- + - - ]
1815 [ + - + - ]: 57 : deleteFolderAction->setShortcut(QKeySequence(Qt::SHIFT | Qt::Key_D));
1816 [ + - ]: 57 : connect(deleteFolderAction, &QAction::triggered, this, [this]() {
1817 [ + - ]: 2 : auto path = m_folderTree->selectedFolderPath();
1818 [ - + ]: 2 : if (path.isEmpty()) return;
1819 [ + - + - ]: 2 : if (m_folderOps->isProtectedFolderPath(path)) {
1820 [ + - ]: 4 : setStatus(QStringLiteral("folder"),
1821 : 4 : QStringLiteral("Spezialordner koennen nicht geloescht werden"), 3000);
1822 : 2 : return;
1823 : : }
1824 [ # # ]: 0 : m_folderOps->deleteFolder(path);
1825 [ - + ]: 2 : });
1826 [ + - ]: 57 : addAction(deleteFolderAction);
1827 [ + - ]: 57 : m_normalModeActions.append(deleteFolderAction);
1828 : :
1829 : : // --- T-291: V = Ordner verschieben ---
1830 [ + - + - : 57 : auto *moveFolderAction = new QAction(this);
- + - - ]
1831 [ + - + - ]: 57 : moveFolderAction->setShortcut(QKeySequence(Qt::Key_V));
1832 [ + - ]: 57 : connect(moveFolderAction, &QAction::triggered, this, [this]() {
1833 [ + - ]: 2 : auto path = m_folderTree->selectedFolderPath();
1834 [ - + ]: 2 : if (path.isEmpty()) return;
1835 [ + - + - ]: 2 : if (m_folderOps->isProtectedFolderPath(path)) {
1836 [ + - ]: 4 : setStatus(QStringLiteral("folder"),
1837 : 4 : QStringLiteral("Spezialordner koennen nicht verschoben werden"), 3000);
1838 : 2 : return;
1839 : : }
1840 [ # # ]: 0 : m_folderOps->moveFolder(path);
1841 [ - + ]: 2 : });
1842 [ + - ]: 57 : addAction(moveFolderAction);
1843 [ + - ]: 57 : m_normalModeActions.append(moveFolderAction);
1844 : :
1845 : : // --- T-144: s = CommandBar (Move to folder mode) ---
1846 [ + - + - : 57 : auto *moveAction = new QAction(this);
- + - - ]
1847 [ + - + - ]: 57 : moveAction->setShortcut(QKeySequence(Qt::SHIFT | Qt::Key_S));
1848 [ + - ]: 57 : connect(moveAction, &QAction::triggered, this, [this]() {
1849 : 1 : m_commandBar->activate(CommandBar::MoveToFolder);
1850 : 1 : });
1851 [ + - ]: 57 : addAction(moveAction);
1852 [ + - ]: 57 : m_normalModeActions.append(moveAction);
1853 : :
1854 : : // --- T-180: f = Globale Suche (CommandBar Search mode) ---
1855 [ + - + - : 57 : auto *globalSearchAction = new QAction(this);
- + - - ]
1856 [ + - + - ]: 57 : globalSearchAction->setShortcut(QKeySequence(Qt::Key_F));
1857 [ + - ]: 57 : connect(globalSearchAction, &QAction::triggered, this, [this]() {
1858 : 1 : m_commandBar->activate(CommandBar::Search);
1859 : 1 : });
1860 [ + - ]: 57 : addAction(globalSearchAction);
1861 [ + - ]: 57 : m_normalModeActions.append(globalSearchAction);
1862 : :
1863 : : // --- T-145: j = Move down, k = Move up ---
1864 [ + - + - : 57 : auto *moveDownAction = new QAction(this);
- + - - ]
1865 [ + - + - ]: 57 : moveDownAction->setShortcut(QKeySequence(Qt::Key_J));
1866 [ + - ]: 57 : connect(moveDownAction, &QAction::triggered, this, [this]() {
1867 : 3 : moveMailSelection(+1);
1868 : 3 : });
1869 [ + - ]: 57 : addAction(moveDownAction);
1870 [ + - ]: 57 : m_normalModeActions.append(moveDownAction);
1871 : :
1872 [ + - + - : 57 : auto *moveUpAction = new QAction(this);
- + - - ]
1873 [ + - + - ]: 57 : moveUpAction->setShortcut(QKeySequence(Qt::Key_K));
1874 [ + - ]: 57 : connect(moveUpAction, &QAction::triggered, this, [this]() {
1875 : 2 : moveMailSelection(-1);
1876 : 2 : });
1877 [ + - ]: 57 : addAction(moveUpAction);
1878 [ + - ]: 57 : m_normalModeActions.append(moveUpAction);
1879 : :
1880 : : // --- T-145: J = Page down, K = Page up ---
1881 [ + - + - : 57 : auto *pageDownAction = new QAction(this);
- + - - ]
1882 [ + - + - ]: 57 : pageDownAction->setShortcut(QKeySequence(Qt::SHIFT | Qt::Key_J));
1883 [ + - ]: 57 : connect(pageDownAction, &QAction::triggered, this, [this]() {
1884 : 1 : moveMailSelectionPage(+1);
1885 : 1 : });
1886 [ + - ]: 57 : addAction(pageDownAction);
1887 [ + - ]: 57 : m_normalModeActions.append(pageDownAction);
1888 : :
1889 [ + - + - : 57 : auto *pageUpAction = new QAction(this);
- + - - ]
1890 [ + - + - ]: 57 : pageUpAction->setShortcut(QKeySequence(Qt::SHIFT | Qt::Key_K));
1891 [ + - ]: 57 : connect(pageUpAction, &QAction::triggered, this, [this]() {
1892 : 1 : moveMailSelectionPage(-1);
1893 : 1 : });
1894 [ + - ]: 57 : addAction(pageUpAction);
1895 [ + - ]: 57 : m_normalModeActions.append(pageUpAction);
1896 : :
1897 : : // --- T-145: G = Go to last mail ---
1898 [ + - + - : 57 : auto *goLastAction = new QAction(this);
- + - - ]
1899 [ + - + - ]: 57 : goLastAction->setShortcut(QKeySequence(Qt::SHIFT | Qt::Key_G));
1900 [ + - ]: 57 : connect(goLastAction, &QAction::triggered, this, [this]() {
1901 : 1 : moveMailSelectionToEnd(false);
1902 : 1 : });
1903 [ + - ]: 57 : addAction(goLastAction);
1904 [ + - ]: 57 : m_normalModeActions.append(goLastAction);
1905 : :
1906 : : // --- T-145: g = first press of gg sequence ---
1907 [ + - + - : 57 : auto *gAction = new QAction(this);
- + - - ]
1908 [ + - + - ]: 57 : gAction->setShortcut(QKeySequence(Qt::Key_G));
1909 [ + - ]: 57 : connect(gAction, &QAction::triggered, this, [this]() {
1910 [ + + ]: 2 : if (m_gPending) {
1911 : : // Second g — go to first mail
1912 : 1 : m_gPending = false;
1913 : 1 : m_ggTimer->stop();
1914 : 1 : moveMailSelectionToEnd(true);
1915 : : } else {
1916 : : // First g — start timer
1917 : 1 : m_gPending = true;
1918 : 1 : m_ggTimer->start();
1919 : : }
1920 : 2 : });
1921 [ + - ]: 57 : addAction(gAction);
1922 [ + - ]: 57 : m_normalModeActions.append(gAction);
1923 : :
1924 : : // --- T-145: o = Open mail (explicit trigger) ---
1925 [ + - + - : 57 : auto *openMailAction = new QAction(this);
- + - - ]
1926 [ + - + - ]: 57 : openMailAction->setShortcut(QKeySequence(Qt::Key_O));
1927 [ + - ]: 57 : connect(openMailAction, &QAction::triggered, this, [this]() {
1928 [ + - ]: 1 : auto id = currentMailId();
1929 [ - + ]: 1 : if (!id.isValid()) return;
1930 [ - + - - : 1 : if (isSearchMode() && id.hasFolderId())
- + ]
1931 [ # # ]: 0 : m_controller->onMailSelectedInFolder(id.uid, id.folderId);
1932 : : else
1933 [ + - ]: 1 : m_controller->onMailSelected(id.uid);
1934 [ + - ]: 1 : });
1935 [ + - ]: 57 : addAction(openMailAction);
1936 [ + - ]: 57 : m_normalModeActions.append(openMailAction);
1937 : :
1938 : : // --- T-147: d = Delete (move to Trash) ---
1939 [ + - + - : 57 : auto *deleteAction = new QAction(this);
- + - - ]
1940 [ + - + - ]: 57 : deleteAction->setShortcut(QKeySequence(Qt::Key_D));
1941 [ + - ]: 57 : connect(deleteAction, &QAction::triggered, this, [this]() {
1942 [ + + ]: 3 : if (m_trashFolder.isEmpty()) {
1943 [ + - ]: 2 : setStatus(QStringLiteral("Kein Trash-Ordner gefunden"));
1944 : 1 : return;
1945 : : }
1946 : : // T-407: Use MailId for search-mode safety
1947 [ + - ]: 2 : auto mailIds = getSelectedMailIds();
1948 [ - + ]: 2 : if (mailIds.isEmpty()) return;
1949 : 2 : QList<qint64> uids;
1950 [ + - + - : 4 : for (const auto &mid : mailIds) uids.append(mid.uid);
+ - + + ]
1951 [ + - ]: 2 : copyTabCacheToFolder(uids, m_trashFolder);
1952 [ + - ]: 2 : selectNextAfterMove();
1953 [ + + ]: 2 : if (isSearchMode()) {
1954 : : // Group by source folder for cross-folder move
1955 : 1 : QMap<qint64, QList<qint64>> byFolder;
1956 : 1 : QMap<qint64, QString> folderPaths;
1957 [ + - + - : 2 : for (const auto &mid : mailIds) {
+ + ]
1958 [ + - + - ]: 1 : byFolder[mid.folderId].append(mid.uid);
1959 [ + - ]: 1 : folderPaths[mid.folderId] = mid.folderPath;
1960 : : }
1961 [ + - + - : 2 : for (auto it = byFolder.constBegin(); it != byFolder.constEnd(); ++it) {
+ + ]
1962 [ + - ]: 1 : m_controller->moveMailsToFolderFrom(
1963 [ + - ]: 1 : it.value(), it.key(), folderPaths[it.key()], m_trashFolder);
1964 : : }
1965 : 1 : } else {
1966 [ + - ]: 1 : m_controller->moveMailsToFolder(uids, m_trashFolder);
1967 : : }
1968 [ + - ]: 4 : setStatus(QStringLiteral("move"),
1969 [ + - ]: 6 : QStringLiteral("Gelöscht → %1").arg(m_trashFolder), 3000);
1970 [ + - ]: 2 : });
1971 [ + - ]: 57 : addAction(deleteAction);
1972 [ + - ]: 57 : m_normalModeActions.append(deleteAction);
1973 : :
1974 : : // --- T-169: Shift+S = Quick-move to suggested folder ---
1975 [ + - + - : 57 : auto *quickMoveAction = new QAction(this);
- + - - ]
1976 [ + - + - ]: 57 : quickMoveAction->setShortcut(QKeySequence(Qt::Key_S));
1977 : 57 : connect(quickMoveAction, &QAction::triggered, this,
1978 [ + - ]: 57 : &MainWindow::quickMoveToSuggestion);
1979 [ + - ]: 57 : addAction(quickMoveAction);
1980 [ + - ]: 57 : m_normalModeActions.append(quickMoveAction);
1981 : :
1982 : : // --- Space = Jump to next unread mail ---
1983 [ + - + - : 57 : auto *nextUnreadAction = new QAction(this);
- + - - ]
1984 [ + - + - ]: 57 : nextUnreadAction->setShortcut(QKeySequence(Qt::Key_Space));
1985 : 57 : connect(nextUnreadAction, &QAction::triggered, this,
1986 [ + - ]: 57 : &MainWindow::jumpToNextUnread);
1987 [ + - ]: 57 : addAction(nextUnreadAction);
1988 [ + - ]: 57 : m_normalModeActions.append(nextUnreadAction);
1989 : :
1990 : : // --- T-147: a = Archive ---
1991 [ + - + - : 57 : auto *archiveAction = new QAction(this);
- + - - ]
1992 [ + - + - ]: 57 : archiveAction->setShortcut(QKeySequence(Qt::Key_A));
1993 [ + - ]: 57 : connect(archiveAction, &QAction::triggered, this, [this]() {
1994 [ + + ]: 2 : if (m_archiveFolder.isEmpty()) {
1995 [ + - ]: 2 : setStatus(QStringLiteral("Kein Archiv-Ordner gefunden"));
1996 : 1 : return;
1997 : : }
1998 [ + - ]: 1 : auto mailIds = getSelectedMailIds();
1999 [ - + ]: 1 : if (mailIds.isEmpty()) return;
2000 : 1 : QList<qint64> uids;
2001 [ + - + - : 2 : for (const auto &mid : mailIds) uids.append(mid.uid);
+ - + + ]
2002 [ + - ]: 1 : copyTabCacheToFolder(uids, m_archiveFolder);
2003 [ + - ]: 1 : selectNextAfterMove();
2004 [ - + ]: 1 : if (isSearchMode()) {
2005 : 0 : QMap<qint64, QList<qint64>> byFolder;
2006 : 0 : QMap<qint64, QString> folderPaths;
2007 [ # # # # : 0 : for (const auto &mid : mailIds) {
# # ]
2008 [ # # # # ]: 0 : byFolder[mid.folderId].append(mid.uid);
2009 [ # # ]: 0 : folderPaths[mid.folderId] = mid.folderPath;
2010 : : }
2011 [ # # # # : 0 : for (auto it = byFolder.constBegin(); it != byFolder.constEnd(); ++it) {
# # ]
2012 [ # # ]: 0 : m_controller->moveMailsToFolderFrom(
2013 [ # # ]: 0 : it.value(), it.key(), folderPaths[it.key()], m_archiveFolder);
2014 : : }
2015 : 0 : } else {
2016 [ + - ]: 1 : m_controller->moveMailsToFolder(uids, m_archiveFolder);
2017 : : }
2018 [ + - ]: 2 : setStatus(QStringLiteral("move"),
2019 [ + - ]: 3 : QStringLiteral("Archiviert → %1").arg(m_archiveFolder), 3000);
2020 [ + - ]: 1 : });
2021 [ + - ]: 57 : addAction(archiveAction);
2022 [ + - ]: 57 : m_normalModeActions.append(archiveAction);
2023 : :
2024 : : // --- Junk/Spam: x = Mark as Junk + move to Junk folder ---
2025 [ + - + - : 57 : auto *junkAction = new QAction(this);
- + - - ]
2026 [ + - + - ]: 57 : junkAction->setShortcut(QKeySequence(Qt::Key_X));
2027 [ + - ]: 57 : connect(junkAction, &QAction::triggered, this, [this]() {
2028 [ + + ]: 3 : if (m_junkFolder.isEmpty()) {
2029 [ + - ]: 2 : setStatus(QStringLiteral("Kein Junk-Ordner gefunden"));
2030 : 1 : return;
2031 : : }
2032 [ + - ]: 2 : auto mailIds = getSelectedMailIds();
2033 [ - + ]: 2 : if (mailIds.isEmpty()) return;
2034 : 2 : QList<qint64> uids;
2035 [ + - + - : 4 : for (const auto &mid : mailIds) {
+ + ]
2036 [ + - ]: 2 : uids.append(mid.uid);
2037 : : // T-407: Label with correct folder context
2038 [ - + - - : 2 : if (isSearchMode() && mid.hasFolderId())
- + ]
2039 [ # # ]: 0 : m_controller->addLabelInFolder(mid.uid, mid.folderId,
2040 : 0 : QStringLiteral("$Junk"));
2041 : : else
2042 [ + - ]: 4 : m_controller->addLabel(mid.uid, QStringLiteral("$Junk"));
2043 : : }
2044 [ + - ]: 2 : copyTabCacheToFolder(uids, m_junkFolder);
2045 [ + - ]: 2 : selectNextAfterMove();
2046 [ - + ]: 2 : if (isSearchMode()) {
2047 : 0 : QMap<qint64, QList<qint64>> byFolder;
2048 : 0 : QMap<qint64, QString> folderPaths;
2049 [ # # # # : 0 : for (const auto &mid : mailIds) {
# # ]
2050 [ # # # # ]: 0 : byFolder[mid.folderId].append(mid.uid);
2051 [ # # ]: 0 : folderPaths[mid.folderId] = mid.folderPath;
2052 : : }
2053 [ # # # # : 0 : for (auto it = byFolder.constBegin(); it != byFolder.constEnd(); ++it) {
# # ]
2054 [ # # ]: 0 : m_controller->moveMailsToFolderFrom(
2055 [ # # ]: 0 : it.value(), it.key(), folderPaths[it.key()], m_junkFolder);
2056 : : }
2057 : 0 : } else {
2058 [ + - ]: 2 : m_controller->moveMailsToFolder(uids, m_junkFolder);
2059 : : }
2060 [ + - ]: 4 : setStatus(QStringLiteral("move"),
2061 [ + - ]: 6 : QStringLiteral("Als Junk markiert → %1").arg(m_junkFolder), 3000);
2062 [ + - ]: 2 : });
2063 [ + - ]: 57 : addAction(junkAction);
2064 [ + - ]: 57 : m_normalModeActions.append(junkAction);
2065 : :
2066 : : // --- T-232: Ctrl+S = Toggle suggestion column for all mails ---
2067 [ + - + - : 57 : auto *suggOverlayShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_S), this);
+ - - + -
- ]
2068 [ + - ]: 57 : connect(suggOverlayShortcut, &QShortcut::activated, this, [this]() {
2069 [ + + ]: 2 : if (m_suggestionColumnVisible) {
2070 : : // Hide column and clear suggestions
2071 : 1 : m_mailList->setColumnHidden(MailListModel::Suggestion, true);
2072 : 1 : m_suggestionColumnVisible = false;
2073 : : // Cancel any running worker batch and stop debounce timer
2074 [ + - ]: 1 : if (m_suggestionWorker)
2075 : 1 : m_suggestionWorker->cancel();
2076 [ + - ]: 1 : if (m_scrollDebounce)
2077 : 1 : m_scrollDebounce->stop();
2078 : 1 : m_mailListModel->clearSuggestions();
2079 : 1 : m_mailThreadModel->clearSuggestions();
2080 : 1 : m_suggestedUids.clear();
2081 [ + - + - : 1 : } else if (m_folderPredictor && m_folderPredictor->isOpen()) {
+ - ]
2082 : : // Show column and start lazy prediction
2083 : 1 : m_mailList->setColumnHidden(MailListModel::Suggestion, false);
2084 : 1 : m_mailList->setColumnWidth(MailListModel::Suggestion, 140);
2085 : 1 : m_suggestionColumnVisible = true;
2086 : 1 : m_suggestedUids.clear();
2087 : 1 : computeVisibleSuggestions();
2088 : : }
2089 : 2 : });
2090 : :
2091 : : // --- T-234: e = Toggle alternate folder suggestion ---
2092 [ + - + - : 57 : auto *altToggleAction = new QAction(this);
- + - - ]
2093 [ + - + - ]: 57 : altToggleAction->setShortcut(QKeySequence(Qt::Key_E));
2094 [ + - ]: 57 : connect(altToggleAction, &QAction::triggered, this, [this]() {
2095 [ + - ]: 4 : auto uids = getSelectedUids();
2096 [ - + ]: 4 : if (uids.isEmpty())
2097 : 0 : return;
2098 [ + - ]: 4 : qint64 uid = uids.first();
2099 : : // Only toggle if an alternate suggestion actually exists
2100 [ + + ]: 4 : if (m_currentAltSuggestion.isEmpty()) {
2101 [ + - ]: 6 : setStatus(QStringLiteral("alt"),
2102 : 6 : QStringLiteral("Kein Alternativ-Vorschlag verfügbar"), 2000);
2103 : 3 : return;
2104 : : }
2105 : : // Toggle alternate mode for this UID
2106 [ - + ]: 1 : if (m_alternateUids.contains(uid)) {
2107 [ # # ]: 0 : m_alternateUids.remove(uid);
2108 : : } else {
2109 [ + - ]: 1 : m_alternateUids.insert(uid);
2110 : : }
2111 : : // Update statusbar suggestion
2112 [ + - ]: 1 : updateSuggestion();
2113 : :
2114 : : // T-232: Also update suggestion column if visible
2115 [ + - ]: 1 : if (m_suggestionColumnVisible) {
2116 : 1 : bool useAlt = m_alternateUids.contains(uid);
2117 [ + - ]: 1 : QString folder = useAlt ? m_currentAltSuggestion : m_currentSuggestion;
2118 [ + - ]: 1 : double conf = useAlt ? m_currentAltConfidence : m_currentSuggestionConfidence;
2119 : :
2120 [ - + - - : 1 : if (!folder.isEmpty() && conf >= 0.01) {
- + ]
2121 : 0 : int pct = static_cast<int>(conf * 100);
2122 : 0 : QString shortName = folder;
2123 : 0 : int lastSep = qMax(folder.lastIndexOf(QLatin1Char('.')),
2124 : 0 : folder.lastIndexOf(QLatin1Char('/')));
2125 [ # # ]: 0 : if (lastSep >= 0)
2126 [ # # ]: 0 : shortName = folder.mid(lastSep + 1);
2127 [ # # ]: 0 : shortName = ImapResponseParser::decodeMailboxName(shortName);
2128 [ # # # # ]: 0 : QString text = QStringLiteral("→ %1 %2%").arg(shortName).arg(pct);
2129 : :
2130 [ # # ]: 0 : if (m_threadViewActive) {
2131 [ # # ]: 0 : m_mailThreadModel->setSuggestion(uid, m_controller->currentFolderId(), text, conf);
2132 : : } else {
2133 [ # # ]: 0 : m_mailListModel->setSuggestion(uid, m_controller->currentFolderId(), text, conf);
2134 : : }
2135 : 0 : }
2136 : 1 : }
2137 [ + + ]: 4 : });
2138 [ + - ]: 57 : addAction(altToggleAction);
2139 [ + - ]: 57 : m_normalModeActions.append(altToggleAction);
2140 : :
2141 : : // --- T-148: ? = Shortcut help overlay ---
2142 [ + - + - : 57 : auto *helpAction = new QAction(this);
- + - - ]
2143 [ + - + - ]: 57 : helpAction->setShortcut(QKeySequence(Qt::Key_Question));
2144 [ + - ]: 57 : connect(helpAction, &QAction::triggered, this, [this]() {
2145 : 1 : showShortcutHelp();
2146 : 1 : });
2147 [ + - ]: 57 : addAction(helpAction);
2148 [ + - ]: 57 : m_normalModeActions.append(helpAction);
2149 : :
2150 : : // --- T-211: Ctrl+Z = Undo (global, works even in CommandBar) ---
2151 [ + - + - : 57 : auto *undoCtrlZ = new QAction(this);
- + - - ]
2152 [ + - + - ]: 114 : undoCtrlZ->setShortcut(QKeySequence(QStringLiteral("Ctrl+Z")));
2153 [ + - ]: 57 : connect(undoCtrlZ, &QAction::triggered, this, [this]() {
2154 : 1 : m_undoManager->undo();
2155 : 1 : });
2156 [ + - ]: 57 : addAction(undoCtrlZ);
2157 : : // Note: NOT added to m_normalModeActions — always active
2158 : :
2159 : : // --- T-211: u = Undo (normal mode, Vim-style) ---
2160 [ + - + - : 57 : auto *undoU = new QAction(this);
- + - - ]
2161 [ + - + - ]: 57 : undoU->setShortcut(QKeySequence(Qt::Key_U));
2162 [ + - ]: 57 : connect(undoU, &QAction::triggered, this, [this]() {
2163 : 1 : m_undoManager->undo();
2164 : 1 : });
2165 [ + - ]: 57 : addAction(undoU);
2166 [ + - ]: 57 : m_normalModeActions.append(undoU);
2167 : :
2168 : : // --- T-213: Doppelklick → Mail in Tab öffnen ---
2169 : 57 : connect(m_mailList, &QTreeView::doubleClicked, this,
2170 [ + - ]: 57 : [this](const QModelIndex &idx) {
2171 : 1 : qint64 uid = uidFromViewIndex(idx);
2172 [ - + ]: 1 : if (uid < 0) return;
2173 : :
2174 : : // T-177: If draft → open in ComposeWindow for editing
2175 : 1 : int row = m_mailListModel->rowForUid(uid, m_controller->currentFolderId());
2176 : 1 : auto *header = m_mailListModel->headerAt(row);
2177 [ + - - + : 1 : if (header && header->isDraft()) {
- + ]
2178 : 0 : openDraftInCompose(uid, *header);
2179 : 0 : return;
2180 : : }
2181 : :
2182 : 1 : openMailInTab(uid);
2183 : : });
2184 : :
2185 : : // --- T-213: Shift+T = Mail in Tab öffnen (Vim-style)
2186 : : // T-625/FUNC-15: Changed from Key_T to Shift+T to resolve conflict
2187 : : // with Sprint 39 AddTask shortcut (also Key_T)
2188 [ + - + - : 57 : auto *tabOpenAction = new QAction(this);
- + - - ]
2189 [ + - + - ]: 57 : tabOpenAction->setShortcut(QKeySequence(Qt::SHIFT | Qt::Key_T));
2190 [ + - ]: 57 : connect(tabOpenAction, &QAction::triggered, this, [this, currentUid]() {
2191 : 2 : qint64 uid = currentUid();
2192 [ + - ]: 2 : if (uid >= 0) openMailInTab(uid);
2193 : 2 : });
2194 [ + - ]: 57 : addAction(tabOpenAction);
2195 [ + - ]: 57 : m_normalModeActions.append(tabOpenAction);
2196 : :
2197 : : // --- T-216: Ctrl+W = Aktuellen Tab schließen (global) ---
2198 [ + - + - : 57 : auto *tabCloseAction = new QAction(this);
- + - - ]
2199 [ + - + - ]: 114 : tabCloseAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+W")));
2200 [ + - ]: 57 : connect(tabCloseAction, &QAction::triggered, this, [this]() {
2201 [ + - ]: 2 : if (m_tabManager) m_tabManager->closeCurrentTab();
2202 : 2 : });
2203 [ + - ]: 57 : addAction(tabCloseAction);
2204 : :
2205 : : // --- T-216: Ctrl+Tab = Nächster Tab (global) ---
2206 [ + - + - : 57 : auto *tabNextAction = new QAction(this);
- + - - ]
2207 [ + - + - ]: 114 : tabNextAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Tab")));
2208 [ + - ]: 57 : connect(tabNextAction, &QAction::triggered, this, [this]() {
2209 [ + - ]: 1 : if (m_tabManager) m_tabManager->switchToNextTab();
2210 : 1 : });
2211 [ + - ]: 57 : addAction(tabNextAction);
2212 : :
2213 : : // --- T-216: Ctrl+Shift+Tab = Vorheriger Tab (global) ---
2214 [ + - + - : 57 : auto *tabPrevAction = new QAction(this);
- + - - ]
2215 [ + - + - ]: 114 : tabPrevAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Shift+Tab")));
2216 [ + - ]: 57 : connect(tabPrevAction, &QAction::triggered, this, [this]() {
2217 [ + - ]: 1 : if (m_tabManager) m_tabManager->switchToPreviousTab();
2218 : 1 : });
2219 [ + - ]: 57 : addAction(tabPrevAction);
2220 : :
2221 : : // --- T-216: Ctrl+0 = Hauptansicht (global) ---
2222 [ + - + - : 57 : auto *tabMainAction = new QAction(this);
- + - - ]
2223 [ + - + - ]: 114 : tabMainAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+0")));
2224 [ + - ]: 57 : connect(tabMainAction, &QAction::triggered, this, [this]() {
2225 [ + - ]: 1 : if (m_tabManager) m_tabManager->switchToMainView();
2226 : 1 : });
2227 [ + - ]: 57 : addAction(tabMainAction);
2228 : :
2229 : : // --- T-216: Ctrl+1..9 = Direkt zu Tab n (global) ---
2230 [ + + ]: 570 : for (int n = 1; n <= 9; ++n) {
2231 [ + - + - : 513 : auto *tabNAction = new QAction(this);
- + - - ]
2232 [ + - + - : 1539 : tabNAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+%1").arg(n)));
+ - ]
2233 [ + - ]: 513 : connect(tabNAction, &QAction::triggered, this, [this, n]() {
2234 [ + - ]: 2 : if (m_tabManager) m_tabManager->switchToTab(n - 1);
2235 : 2 : });
2236 [ + - ]: 513 : addAction(tabNAction);
2237 : : }
2238 : : // --- T-088: L shortcut → show label menu at cursor ---
2239 [ + - + - : 57 : auto *labelAction = new QAction(this);
- + - - ]
2240 [ + - + - ]: 57 : labelAction->setShortcut(QKeySequence(Qt::Key_L));
2241 [ + - ]: 57 : connect(labelAction, &QAction::triggered, this, [this]() {
2242 [ + - ]: 1 : auto mail = currentMailId();
2243 [ + - - + : 1 : if (!mail.isValid() || !mail.hasFolderId()) return;
- + ]
2244 [ + - ]: 1 : auto *header = m_mailListModel->headerAt(
2245 [ + - ]: 1 : m_mailListModel->rowForUid(mail.uid, mail.folderId));
2246 [ - + ]: 1 : if (!header) return;
2247 : 1 : const bool crossFolder = isSearchMode();
2248 : :
2249 [ + - ]: 1 : QMenu menu(this);
2250 : : struct LabelDef { QString id; QString name; };
2251 : : QList<LabelDef> labels = {
2252 : : {"$label1", "Wichtig"}, {"$label2", "Arbeit"},
2253 : : {"$label3", "Persönlich"}, {"$label4", "To Do"},
2254 : : {"$label5", "Später"}, {"$Important", "Important"},
2255 [ + + - - ]: 7 : };
2256 [ + - + - : 7 : for (const auto &ld : labels) {
+ + ]
2257 : 6 : bool has = header->labels.contains(ld.id);
2258 [ + + + - : 7 : QString text = (has ? QStringLiteral("✓ ") : QString()) + ld.name;
+ + - - ]
2259 [ + - - - : 6 : menu.addAction(text, [this, mail, id = ld.id, has, crossFolder]() {
- - ]
2260 [ + + ]: 2 : if (has) {
2261 [ - + ]: 1 : if (crossFolder)
2262 : 0 : m_controller->removeLabelInFolder(mail.uid, mail.folderId, id);
2263 : : else
2264 : 1 : m_controller->removeLabel(mail.uid, id);
2265 : : } else {
2266 [ - + ]: 1 : if (crossFolder)
2267 : 0 : m_controller->addLabelInFolder(mail.uid, mail.folderId, id);
2268 : : else
2269 : 1 : m_controller->addLabel(mail.uid, id);
2270 : : }
2271 : 2 : });
2272 : 6 : }
2273 [ + - + - ]: 1 : menu.exec(QCursor::pos());
2274 [ + - + - : 2 : });
+ - + - +
- + - + -
+ - + - +
- + - + -
+ - + - -
- - - - -
- - - - -
- - - -
- ]
2275 [ + - ]: 57 : addAction(labelAction);
2276 [ + - ]: 57 : m_normalModeActions.append(labelAction);
2277 : :
2278 : : // --- Number keys 1-5 toggle $label1-$label5 (Thunderbird-style) ---
2279 [ + + ]: 342 : for (int i = 1; i <= 5; ++i) {
2280 [ + - + - : 285 : auto *numAction = new QAction(this);
- + - - ]
2281 [ + - + - ]: 285 : numAction->setShortcut(QKeySequence(Qt::Key_0 + i));
2282 [ + - ]: 285 : connect(numAction, &QAction::triggered, this, [this, i]() {
2283 [ + - ]: 6 : auto mail = currentMailId();
2284 [ + + - + : 6 : if (!mail.isValid() || !mail.hasFolderId()) return;
+ + ]
2285 [ + - ]: 1 : auto *header = m_mailListModel->headerAt(
2286 [ + - ]: 1 : m_mailListModel->rowForUid(mail.uid, mail.folderId));
2287 [ - + ]: 1 : if (!header) return;
2288 [ + - ]: 2 : QString labelId = QStringLiteral("$label%1").arg(i);
2289 : 1 : const bool crossFolder = isSearchMode();
2290 [ - + ]: 1 : if (header->labels.contains(labelId)) {
2291 [ # # ]: 0 : if (crossFolder)
2292 [ # # ]: 0 : m_controller->removeLabelInFolder(mail.uid, mail.folderId, labelId);
2293 : : else
2294 [ # # ]: 0 : m_controller->removeLabel(mail.uid, labelId);
2295 : : } else {
2296 [ - + ]: 1 : if (crossFolder)
2297 [ # # ]: 0 : m_controller->addLabelInFolder(mail.uid, mail.folderId, labelId);
2298 : : else
2299 [ + - ]: 1 : m_controller->addLabel(mail.uid, labelId);
2300 : : }
2301 [ + + ]: 6 : });
2302 [ + - ]: 285 : addAction(numAction);
2303 [ + - ]: 285 : m_normalModeActions.append(numAction);
2304 : : }
2305 : :
2306 : : // --- 0 = remove all labels ---
2307 [ + - + - : 57 : auto *clearLabelsAction = new QAction(this);
- + - - ]
2308 [ + - + - ]: 57 : clearLabelsAction->setShortcut(QKeySequence(Qt::Key_0));
2309 [ + - ]: 57 : connect(clearLabelsAction, &QAction::triggered, this, [this]() {
2310 [ + - ]: 2 : auto mail = currentMailId();
2311 [ + + - + : 2 : if (!mail.isValid() || !mail.hasFolderId()) return;
+ + ]
2312 [ + - ]: 1 : auto *header = m_mailListModel->headerAt(
2313 [ + - ]: 1 : m_mailListModel->rowForUid(mail.uid, mail.folderId));
2314 [ - + ]: 1 : if (!header) return;
2315 : 1 : QStringList labelsToRemove = header->labels;
2316 [ + - + - : 2 : for (const auto &label : labelsToRemove) {
+ + ]
2317 [ + - - + ]: 1 : if (ImapResponseParser::isInternalKeyword(label))
2318 : 0 : continue;
2319 [ - + ]: 1 : if (isSearchMode())
2320 [ # # ]: 0 : m_controller->removeLabelInFolder(mail.uid, mail.folderId, label);
2321 : : else
2322 [ + - ]: 1 : m_controller->removeLabel(mail.uid, label);
2323 : : }
2324 [ + + ]: 2 : });
2325 [ + - ]: 57 : addAction(clearLabelsAction);
2326 [ + - ]: 57 : m_normalModeActions.append(clearLabelsAction);
2327 : :
2328 : : // --- T-143: Reply → Ctrl+R (remapped from R) ---
2329 [ + - + - : 57 : auto *replyAction = new QAction(this);
- + - - ]
2330 [ + - + - ]: 114 : replyAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+R")));
2331 [ + - ]: 57 : connect(replyAction, &QAction::triggered, this, [this, currentUid]() {
2332 : 1 : qint64 uid = currentUid();
2333 [ - + ]: 1 : if (uid >= 0) openReply(uid, false);
2334 : 1 : });
2335 [ + - ]: 57 : addAction(replyAction);
2336 : :
2337 : : // --- T-143: Reply All → Ctrl+Shift+R (remapped from Shift+R) ---
2338 [ + - + - : 57 : auto *replyAllAction = new QAction(this);
- + - - ]
2339 [ + - + - ]: 114 : replyAllAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Shift+R")));
2340 [ + - ]: 57 : connect(replyAllAction, &QAction::triggered, this, [this, currentUid]() {
2341 : 1 : qint64 uid = currentUid();
2342 [ - + ]: 1 : if (uid >= 0) openReply(uid, true);
2343 : 1 : });
2344 [ + - ]: 57 : addAction(replyAllAction);
2345 : :
2346 : : // --- T-143: Forward → Ctrl+Shift+F (remapped from F) ---
2347 [ + - + - : 57 : auto *fwdAction = new QAction(this);
- + - - ]
2348 [ + - + - ]: 114 : fwdAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Shift+F")));
2349 [ + - ]: 57 : connect(fwdAction, &QAction::triggered, this, [this, currentUid]() {
2350 : 1 : qint64 uid = currentUid();
2351 [ - + ]: 1 : if (uid >= 0) openForward(uid);
2352 : 1 : });
2353 [ + - ]: 57 : addAction(fwdAction);
2354 : :
2355 : : // T-084: Click on Star column toggles starred status
2356 : 57 : connect(m_mailList, &QTreeView::clicked, this,
2357 [ + - ]: 57 : [this](const QModelIndex &proxyIdx) {
2358 [ - + ]: 2 : if (!proxyIdx.isValid())
2359 : 0 : return;
2360 [ + - ]: 2 : if (proxyIdx.column() == MailListModel::Star) {
2361 [ + - ]: 2 : auto mail = mailIdFromViewIndex(proxyIdx);
2362 [ - + ]: 2 : if (!mail.isValid())
2363 : 0 : return;
2364 [ - + - - : 2 : if (isSearchMode() && mail.hasFolderId())
- + ]
2365 [ # # ]: 0 : m_controller->toggleStarredInFolder(mail.uid, mail.folderId);
2366 : : else
2367 [ + - ]: 2 : m_controller->toggleStarred(mail.uid);
2368 [ + - ]: 2 : }
2369 : : });
2370 : :
2371 : : // ═══════════════════════════════════════════════════════
2372 : : // T-142: CommandBar signal wiring
2373 : : // ═══════════════════════════════════════════════════════
2374 : :
2375 : 57 : connect(m_commandBar, &CommandBar::commandSubmitted, this,
2376 [ + - ]: 57 : &MainWindow::executeCommand);
2377 : :
2378 : 57 : connect(m_commandBar, &CommandBar::filterTextChanged, this,
2379 [ + - ]: 57 : [this](const QString &text) {
2380 : : // Sprint 37 T-458: Route filter to TaskListWidget if active
2381 [ - + - - : 13 : if (m_taskListWidget && m_taskListWidget->isVisible()) {
- + ]
2382 : 0 : m_taskListWidget->setFilterText(text);
2383 : 0 : return;
2384 : : }
2385 : 13 : m_mailListProxy->setFilterText(text);
2386 : : });
2387 : :
2388 : 57 : connect(m_commandBar, &CommandBar::folderSelected, this,
2389 [ + - ]: 57 : [this](CommandBar::Mode mode, const QString &folder) {
2390 [ + + ]: 4 : if (mode == CommandBar::FolderSwitch) {
2391 : 2 : m_folderTree->selectFolder(folder); // triggers folderSelected signal
2392 [ + + ]: 2 : } else if (mode == CommandBar::MoveToFolder) {
2393 [ + - ]: 1 : auto mailIds = getSelectedMailIds();
2394 [ + - ]: 1 : if (!mailIds.isEmpty()) {
2395 : 1 : QList<qint64> uids;
2396 [ + - + - : 2 : for (const auto &mid : mailIds) uids.append(mid.uid);
+ - + + ]
2397 : : // T-170: Train (with untrain if suggestion was wrong)
2398 [ + - + - ]: 2 : if (!m_currentSuggestion.isEmpty() &&
2399 [ + - ]: 1 : m_currentSuggestion != folder) {
2400 [ + - + - : 2 : for (const auto &mid : mailIds) {
+ + ]
2401 [ + - ]: 1 : auto h = m_cache->header(mid.folderId, mid.uid);
2402 [ + - ]: 1 : if (h) {
2403 : 2 : m_folderPredictor->untrain(
2404 [ + - ]: 1 : h->from, h->subject, h->to, m_currentSuggestion);
2405 : : }
2406 : 1 : }
2407 : : }
2408 [ + - ]: 1 : trainAfterMove(mailIds, folder);
2409 [ + - ]: 1 : copyTabCacheToFolder(uids, folder);
2410 [ + - ]: 1 : selectNextAfterMove();
2411 : : // T-407: Group by source folder for search-mode moves
2412 [ + - ]: 1 : if (isSearchMode()) {
2413 : 1 : QMap<qint64, QList<qint64>> byFolder;
2414 : 1 : QMap<qint64, QString> folderPaths;
2415 [ + - + - : 2 : for (const auto &mid : mailIds) {
+ + ]
2416 [ + - + - ]: 1 : byFolder[mid.folderId].append(mid.uid);
2417 [ + - ]: 1 : folderPaths[mid.folderId] = mid.folderPath;
2418 : : }
2419 [ + - + - : 2 : for (auto it = byFolder.constBegin(); it != byFolder.constEnd(); ++it) {
+ + ]
2420 [ + - ]: 1 : m_controller->moveMailsToFolderFrom(
2421 [ + - ]: 1 : it.value(), it.key(), folderPaths[it.key()], folder);
2422 : : }
2423 : 1 : } else {
2424 [ # # ]: 0 : m_controller->moveMailsToFolder(uids, folder);
2425 : : }
2426 : 1 : }
2427 : 1 : }
2428 : 4 : });
2429 : :
2430 [ + - ]: 57 : connect(m_commandBar, &CommandBar::cancelled, this, [this]() {
2431 : : // Clear active filter when closing the bar with Esc
2432 [ + - ]: 2 : m_mailListProxy->setFilterText({});
2433 : : // T-188: If in search mode, restore previous folder
2434 : 2 : m_search->onCommandBarCancelled();
2435 : 2 : m_mailList->setFocus();
2436 : 2 : });
2437 : :
2438 : : // Arrow keys in Filter mode → navigate mail list
2439 : 57 : connect(m_commandBar, &CommandBar::navigateMailList, this,
2440 [ + - ]: 58 : [this](int delta) { moveMailSelection(delta); });
2441 : :
2442 : 57 : connect(m_commandBar, &CommandBar::searchQueryChanged, this,
2443 [ + - ]: 57 : [this](const QString &query) {
2444 : : // Sprint 37 T-459: Route search to TaskListWidget if active
2445 [ - + - - : 11 : if (m_taskListWidget && m_taskListWidget->isVisible()) {
- + ]
2446 [ # # # # ]: 0 : if (query.trimmed().isEmpty()) {
2447 [ # # ]: 0 : m_taskListWidget->reload();
2448 : 0 : return;
2449 : : }
2450 : 0 : auto results = m_calendarStore->searchTasks(
2451 [ # # ]: 0 : query, m_taskListWidget->showCompleted());
2452 [ # # ]: 0 : m_taskListWidget->setSearchResults(results);
2453 : 0 : return;
2454 : 0 : }
2455 : 11 : m_search->updateQuickResults(query);
2456 : : });
2457 : :
2458 : : // T-180: Navigate to search result
2459 : 57 : connect(m_commandBar, &CommandBar::searchResultSelected, this,
2460 [ + - ]: 57 : [this](int index) {
2461 [ - + - - : 1 : if (index < 0 || index >= m_search->quickResultCount())
+ - ]
2462 : 1 : return;
2463 [ # # ]: 0 : auto r = m_search->quickResultAt(index);
2464 : 0 : m_pendingRestoreUid = r.uid;
2465 [ # # # # ]: 0 : if (m_folderTree->selectedFolderPath() == r.folderPath) {
2466 [ # # ]: 0 : restoreSessionMail();
2467 : : } else {
2468 [ # # ]: 0 : m_folderTree->selectFolder(r.folderPath);
2469 : : }
2470 [ # # ]: 0 : setStatus(QStringLiteral("Navigiere zu '%1' in %2")
2471 [ # # # # ]: 0 : .arg(r.subject.left(40), r.folderPath));
2472 : 0 : });
2473 : :
2474 : : // CommandBar search submit, SearchPanel (explicit run / live debounce /
2475 : : // reset) and the server-search result handlers are wired inside
2476 : : // SearchCoordinator (Sprint 65 P2.1).
2477 : :
2478 : : // Sprint 39 – T-537: CommandBar AddTask mode
2479 : 57 : connect(m_commandBar, &CommandBar::taskSubmitted, this,
2480 [ + - ]: 57 : [this](const QString &title, const QString &calPath,
2481 : : const QDateTime &due, int priority) {
2482 [ + - + - ]: 1 : if (!m_calendarStore) initCalendarSync();
2483 : 1 : CalendarTask task;
2484 [ + - + - ]: 1 : task.uid = QUuid::createUuid().toString(QUuid::WithoutBraces);
2485 : 1 : task.summary = title;
2486 : 1 : task.calendarPath = calPath;
2487 : 1 : task.due = due;
2488 : 1 : task.priority = priority;
2489 : 1 : task.status = QStringLiteral("NEEDS-ACTION");
2490 [ + - ]: 1 : task.created = QDateTime::currentDateTimeUtc();
2491 : 1 : task.lastModified = task.created;
2492 : : // If no calendar path, use first available
2493 [ + - ]: 1 : if (task.calendarPath.isEmpty()) {
2494 [ + - ]: 1 : auto cals = m_calendarStore->allCalendars();
2495 [ + - ]: 1 : if (!cals.isEmpty())
2496 [ + - ]: 1 : task.calendarPath = cals.first().path;
2497 : 1 : }
2498 [ + - ]: 1 : onTaskSaved(task, true);
2499 [ + - + - ]: 2 : setStatus(QStringLiteral("task"), tr("New task"), 3000);
2500 : 1 : });
2501 : :
2502 : : // T-151: Modal guard — disable normal-mode shortcuts when CommandBar is active
2503 : 57 : connect(m_commandBar, &CommandBar::activeChanged, this,
2504 [ + - ]: 57 : &MainWindow::setNormalMode);
2505 : 57 : }
2506 : :
2507 : 58 : void MainWindow::loadAccounts() {
2508 : : // T-163: Initialize contact store
2509 [ + + ]: 58 : if (!m_contactStore) {
2510 [ + - + - : 57 : m_contactStore = new ContactStore(this);
- + - - ]
2511 : : QString configDir =
2512 [ + - ]: 114 : QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)
2513 [ + - ]: 171 : + QStringLiteral("/mailjd");
2514 [ + - + - ]: 114 : m_contactStore->open(configDir + QStringLiteral("/contacts.db"));
2515 : 57 : }
2516 : :
2517 : : // T-171: Initialize FolderPredictor
2518 [ + + ]: 58 : if (!m_folderPredictor) {
2519 [ + - + - : 57 : m_folderPredictor = new FolderPredictor(this);
- + - - ]
2520 : : QString configDir =
2521 [ + - ]: 114 : QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)
2522 [ + - ]: 171 : + QStringLiteral("/mailjd");
2523 [ + - ]: 114 : if (!m_folderPredictor->open(
2524 [ + - - + ]: 114 : configDir + QStringLiteral("/folder_suggestions.db"))) {
2525 [ # # # # : 0 : qCWarning(lcMainWindow) << "Failed to open FolderPredictor database";
# # # # ]
2526 : : }
2527 [ + - ]: 57 : m_predictorDbPath = configDir + QStringLiteral("/folder_suggestions.db");
2528 : 57 : }
2529 : :
2530 : : // SEC-01/02: Use shared_ptr so async keyring callback can safely access accounts
2531 : : auto accounts = std::make_shared<std::vector<AccountConfig>>(
2532 [ + - + - ]: 58 : AccountConfigLoader::loadAll());
2533 : :
2534 [ - + ]: 58 : if (accounts->empty()) {
2535 : 0 : m_hasPrimaryAccount = false;
2536 [ # # # # ]: 0 : setStatus(tr("No accounts configured"));
2537 [ # # # # : 0 : qCWarning(lcMainWindow) << "No accounts found in config directory";
# # # # ]
2538 [ # # ]: 0 : showSetupWizard();
2539 : 0 : return;
2540 : : }
2541 : :
2542 : 58 : const auto &account = accounts->front();
2543 [ + - + - : 116 : qCInfo(lcMainWindow) << "Connecting to account:" << account.name;
+ - + - +
+ ]
2544 [ + - + - : 116 : setStatus(tr("Connecting to %1...").arg(account.name));
+ - ]
2545 : :
2546 : : // Set account for MailController
2547 [ + - ]: 58 : m_controller->setAccount(account.name);
2548 : :
2549 : : // T-167: Pre-train FolderPredictor from cache on first launch
2550 [ + - ]: 58 : if (m_folderPredictor->isOpen() &&
2551 [ + - + - : 58 : m_folderPredictor->totalDocuments() == 0 && m_cache->isOpen()) {
+ + + - +
- + + ]
2552 [ + - + - ]: 40 : setStatus(tr("Training folder suggestions…"));
2553 : 40 : QString accountName = account.name;
2554 : 40 : QString cacheDbPath = m_cache->databasePath();
2555 : 40 : QString predictorDbPath = m_predictorDbPath;
2556 : :
2557 [ + - - - : 40 : auto *worker = QThread::create([accountName, cacheDbPath, predictorDbPath]() {
- - ]
2558 [ + - ]: 40 : MailCache threadCache;
2559 [ + - ]: 40 : threadCache.open(cacheDbPath);
2560 [ + - ]: 40 : FolderPredictor threadPredictor;
2561 [ + - - + ]: 40 : if (!threadPredictor.open(predictorDbPath)) {
2562 [ # # ]: 0 : threadCache.close();
2563 : 0 : return;
2564 : : }
2565 : 0 : QStringList exclude = {QStringLiteral("INBOX"),
2566 : 40 : QStringLiteral("Sent"),
2567 : 40 : QStringLiteral("Trash"),
2568 : 40 : QStringLiteral("Drafts"),
2569 : 40 : QStringLiteral("Archive"),
2570 : 40 : QStringLiteral("Junk"),
2571 [ + + - - ]: 320 : QStringLiteral("Spam")};
2572 [ + - ]: 40 : threadPredictor.trainFromCache(&threadCache, accountName, exclude);
2573 [ + - ]: 40 : threadPredictor.close();
2574 [ + - ]: 40 : threadCache.close();
2575 [ + - + - : 360 : });
+ - - - -
- ]
2576 [ + - ]: 40 : connect(worker, &QThread::finished, this, [this]() {
2577 : 39 : int n = m_folderPredictor->totalDocuments();
2578 [ + + ]: 39 : if (n > 0) {
2579 [ + - + - : 2 : setStatus(tr("Training completed (%1 mails)").arg(n));
+ - ]
2580 : : }
2581 : 39 : });
2582 [ + - ]: 40 : connect(worker, &QThread::finished, worker, &QObject::deleteLater);
2583 [ + - ]: 40 : worker->start();
2584 : 40 : }
2585 : :
2586 : : // SEC-01/02: Resolve passwords from OS keyring before connecting.
2587 : : // save() stores passwords in keyring and clears them from JSON,
2588 : : // so loadAll() reads empty passwords. resolvePasswords() fills them back.
2589 [ + - + - : 58 : auto *credStore = new CredentialStore(this);
- + - - ]
2590 [ + - ]: 58 : AccountConfigLoader::resolvePasswords(
2591 : 58 : *accounts, credStore,
2592 [ + - - - ]: 116 : [this, accounts, credStore]() {
2593 : 58 : credStore->deleteLater();
2594 : :
2595 : 58 : const auto &acc = accounts->front();
2596 : 58 : m_primaryAccount = acc;
2597 : 58 : m_hasPrimaryAccount = true;
2598 : :
2599 [ - + ]: 58 : if (acc.imap.password.isEmpty()) {
2600 [ # # # # : 0 : qCWarning(lcMainWindow)
# # ]
2601 [ # # ]: 0 : << "IMAP password empty after keyring resolve for"
2602 [ # # ]: 0 : << acc.name
2603 [ # # ]: 0 : << "— check keyring or re-enter password in settings";
2604 : : }
2605 : :
2606 : 58 : m_controller->setImapConfig(acc.imap);
2607 : 58 : m_reconnectImapConfig = acc.imap;
2608 : : // T-720: Install the reconnect config + activate the health monitor
2609 : : // so dead-socket detection and backoff reconnect cover the main
2610 : : // connection. The monitor was created in the constructor (the
2611 : : // stateChanged lambda above depends on it being wired early).
2612 [ + - ]: 58 : if (m_imapHealth) {
2613 : 58 : m_imapHealth->setReconnectConfig(acc.imap);
2614 : 58 : m_imapHealth->setActive(true);
2615 : : }
2616 : 58 : m_imapService->connectToServer(acc.imap);
2617 : :
2618 : : // T-316: Initialize settings sync (needs IMAP config)
2619 : 58 : initSettingsSync();
2620 : 58 : });
2621 [ + - ]: 58 : }
2622 : :
2623 : 1 : void MainWindow::reloadAccounts() {
2624 : : // T-720: Deactivate the health monitor BEFORE disconnecting so a
2625 : : // reconnect is not scheduled with stale account data during reload.
2626 : : // loadAccounts() reactivates it once the new config is installed.
2627 [ + - ]: 1 : if (m_imapHealth)
2628 : 1 : m_imapHealth->setActive(false);
2629 : : // Disconnect current connection
2630 : 1 : m_imapService->disconnect();
2631 : 1 : m_folderTree->clear();
2632 : 1 : m_mailListModel->clear();
2633 : 1 : m_mailView->clear();
2634 [ + - + - ]: 1 : setStatus(tr("Reloading accounts..."));
2635 : :
2636 : : // Re-load accounts from disk
2637 : 1 : loadAccounts();
2638 : 1 : }
2639 : :
2640 : 4 : void MainWindow::showSubscriptionDialog() {
2641 : : QString configDir =
2642 [ + - ]: 8 : QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)
2643 [ + - ]: 4 : + "/mailjd";
2644 : : QStringList current =
2645 [ + - ]: 4 : FolderSubscriptionDialog::loadSubscriptions(configDir);
2646 [ + + ]: 4 : if (current.isEmpty()) {
2647 : 2 : current = m_allFolderPaths;
2648 : : }
2649 : :
2650 : : // T-077: Load hidden folders for the dialog
2651 [ + - ]: 4 : QStringList hidden = FolderSubscriptionDialog::loadHidden(configDir);
2652 : :
2653 : : auto *dialog =
2654 [ + - + - : 4 : new FolderSubscriptionDialog(m_allFolderPaths, current, hidden, this);
- + - - ]
2655 [ + - + + ]: 4 : if (m_runDialog(dialog) == QDialog::Accepted) {
2656 [ + - ]: 2 : QStringList selected = dialog->subscribedFolders();
2657 [ + - ]: 2 : FolderSubscriptionDialog::saveSubscriptions(configDir, selected);
2658 [ + - ]: 2 : m_controller->setSubscribedFolders(selected);
2659 [ + - + - : 4 : qCInfo(lcMainWindow) << "Subscriptions updated:" << selected.size()
+ - + - +
+ ]
2660 [ + - ]: 2 : << "folders";
2661 : :
2662 : : // T-077: Save updated hidden folders and refresh tree if changed
2663 : 2 : QStringList newHidden = dialog->hiddenFolders();
2664 [ + - + - ]: 2 : if (newHidden != hidden) {
2665 [ + - ]: 2 : FolderSubscriptionDialog::saveHidden(configDir, newHidden);
2666 : 2 : m_folderTree->setHiddenFolders(newHidden);
2667 [ + + ]: 2 : if (!m_lastFolderList.isEmpty()) {
2668 [ + - ]: 1 : auto expanded = m_folderTree->expandedFolderPaths();
2669 [ + - ]: 1 : auto sel = m_folderTree->selectedFolderPath();
2670 [ + - ]: 1 : m_folderTree->setFolders(m_lastFolderList);
2671 [ + - ]: 1 : m_folderTree->restoreExpandedFolders(expanded);
2672 [ - + ]: 1 : if (!sel.isEmpty()) {
2673 [ # # ]: 0 : m_folderTree->selectFolder(sel);
2674 : : }
2675 : 1 : }
2676 [ + - + - : 4 : qCInfo(lcMainWindow) << "Hidden folders updated:" << newHidden.size();
+ - + - +
+ ]
2677 [ + - ]: 2 : triggerSettingsUpload(); // Trigger E: subscription dialog
2678 : : }
2679 : 2 : }
2680 [ + - ]: 4 : dialog->deleteLater();
2681 : 4 : }
2682 : :
2683 : 4 : void MainWindow::showSettings() {
2684 [ + - - + : 4 : auto *dialog = new SettingsDialog(this);
- - ]
2685 : 4 : dialog->setCache(m_cache); // T-122: whitelist tab
2686 : 4 : dialog->setContactStore(m_contactStore); // Fix C: Kontakte tab
2687 : 4 : dialog->setCalendarStore(m_calendarStore);
2688 : :
2689 : : // T-306: Live language switching
2690 : 4 : connect(dialog, &SettingsDialog::languageChangeRequested, this,
2691 [ + - ]: 4 : [this](const QString &locale) {
2692 : : // Remove old translator
2693 : : static QTranslator *translator = nullptr;
2694 [ + + ]: 2 : if (translator) {
2695 [ + - ]: 1 : QCoreApplication::removeTranslator(translator);
2696 [ + - ]: 1 : delete translator;
2697 : 1 : translator = nullptr;
2698 : : }
2699 : : // Determine locale to load
2700 : 2 : QString lang = locale;
2701 [ + + ]: 2 : if (lang == "auto")
2702 [ + - + - : 1 : lang = QLocale::system().name().left(2);
+ - ]
2703 : : // Don't load translator for English (source language)
2704 [ + - ]: 2 : if (lang != "en") {
2705 [ + - + - : 2 : translator = new QTranslator();
- + - - ]
2706 [ + - ]: 4 : QString tsFile = QStringLiteral("mailjd_%1").arg(lang);
2707 [ + - + - : 2 : if (translator->load(tsFile, ":/translations") ||
+ + - - -
- - - ]
2708 [ + - + - : 6 : translator->load(tsFile,
+ - + - -
- - - ]
2709 [ + - + - : 4 : QCoreApplication::applicationDirPath() +
+ - - - -
- ]
2710 [ + - + - : 6 : "/../share/mailjd/translations") ||
+ - + - ]
2711 [ + - + + : 6 : translator->load(tsFile,
+ - + - -
- - - ]
2712 [ + - + - : 4 : QCoreApplication::applicationDirPath() +
+ - + - -
- - - ]
2713 [ + - ]: 2 : "/../../translations")) {
2714 [ + - ]: 1 : QCoreApplication::installTranslator(translator);
2715 [ + - + - : 2 : qCInfo(lcMainWindow) << "Loaded translation:" << tsFile;
+ - + - +
+ ]
2716 : : } else {
2717 [ + - + - : 2 : qCWarning(lcMainWindow) << "Could not load translation:" << tsFile;
+ - + - +
+ ]
2718 [ + - ]: 1 : delete translator;
2719 : 1 : translator = nullptr;
2720 : : }
2721 : 2 : }
2722 : : // Force immediate delivery of LanguageChange events
2723 : : // (installTranslator only posts them — inside a modal dialog
2724 : : // they would never reach MainWindow otherwise)
2725 [ + - ]: 2 : QApplication::sendPostedEvents(nullptr, QEvent::LanguageChange);
2726 : : // T-76.B3: models do not receive LanguageChange; refresh their
2727 : : // translated headers explicitly and repaint the mail list so the
2728 : : // LabelDelegate (which paints tr() per-paint) picks up the new
2729 : : // language too.
2730 [ + - + - ]: 2 : if (m_mailListModel) m_mailListModel->retranslateUi();
2731 [ + - + - ]: 2 : if (m_mailThreadModel) m_mailThreadModel->retranslateUi();
2732 [ + - + - : 2 : if (m_mailList && m_mailList->viewport())
+ - + - ]
2733 [ + - + - ]: 2 : m_mailList->viewport()->update();
2734 : 2 : });
2735 : :
2736 : : // T-316: Wire sync signals
2737 : 4 : connect(dialog, &SettingsDialog::syncSettingsChanged, this,
2738 [ + - ]: 4 : [this]() {
2739 [ + - + - : 2 : qCInfo(lcMainWindow) << "Sync settings changed, reinitializing";
+ - + + ]
2740 : 1 : initSettingsSync();
2741 : 1 : });
2742 : 4 : connect(dialog, &SettingsDialog::syncRequested, this,
2743 [ + - ]: 4 : [this]() {
2744 [ + - + - : 2 : qCInfo(lcMainWindow) << "Manual sync requested";
+ - + + ]
2745 : 1 : triggerSettingsUpload();
2746 : 1 : });
2747 : 4 : connect(dialog, &SettingsDialog::calendarSyncRequested, this,
2748 [ + - ]: 4 : [this]() {
2749 [ + - + - : 2 : qCInfo(lcMainWindow) << "Manual calendar sync requested";
+ - + + ]
2750 : 1 : triggerCalDavSync();
2751 : 1 : });
2752 : :
2753 [ + + ]: 4 : if (m_runDialog(dialog) == QDialog::Accepted) {
2754 [ - + ]: 1 : if (dialog->accountsChanged()) {
2755 [ # # # # : 0 : qCInfo(lcMainWindow) << "Account settings changed, reloading";
# # # # ]
2756 : 0 : reloadAccounts();
2757 : : } else {
2758 [ + - + - : 2 : qCInfo(lcMainWindow) << "Only view settings changed, no reconnect";
+ - + + ]
2759 : : }
2760 : 1 : triggerSettingsUpload(); // Trigger B: settings dialog (general/CardDAV)
2761 : : // Refresh calendar to pick up color changes from Settings
2762 [ - + ]: 1 : if (m_calendarWidget)
2763 [ # # ]: 0 : m_calendarWidget->navigateToDate(m_calendarWidget->selectedDate());
2764 : : }
2765 : 4 : dialog->deleteLater();
2766 : 4 : }
2767 : :
2768 : : // ═══════════════════════════════════════════════════════
2769 : : // T-316: Settings Synchronisation Integration
2770 : : // ═══════════════════════════════════════════════════════
2771 : :
2772 : 61 : void MainWindow::initSettingsSync() {
2773 : : // Cancel any pending debounced upload – the connection may be
2774 : : // about to be destroyed and recreated.
2775 [ + + + - ]: 61 : if (m_syncDebounce) m_syncDebounce->stop();
2776 : :
2777 [ + - ]: 61 : QSettings s;
2778 [ + - + - ]: 122 : bool enabled = s.value(QStringLiteral("sync/enabled"), false).toBool();
2779 : : m_syncFolderPath =
2780 [ + - ]: 183 : s.value(QStringLiteral("sync/folder"),
2781 : 122 : QStringLiteral("MailJD-Settings"))
2782 [ + - ]: 61 : .toString();
2783 : :
2784 [ + + ]: 61 : if (!enabled) {
2785 : : // Disable and clean up
2786 [ + + ]: 60 : if (m_syncService) {
2787 [ + - ]: 1 : m_syncService->shutdown();
2788 [ + - ]: 1 : m_syncService->deleteLater();
2789 : 1 : m_syncService = nullptr;
2790 : : }
2791 [ + - + - : 120 : qCInfo(lcMainWindow) << "Settings sync disabled";
+ - + + ]
2792 : 60 : return;
2793 : : }
2794 : :
2795 : : // Generate clientId on first use
2796 [ + - + - : 1 : if (s.value(QStringLiteral("sync/clientId")).toString().isEmpty()) {
- + ]
2797 [ # # ]: 0 : s.setValue(QStringLiteral("sync/clientId"),
2798 [ # # # # ]: 0 : QUuid::createUuid().toString(QUuid::WithoutBraces));
2799 : : }
2800 : :
2801 : : // Debounce timer: bundles rapid changes into one upload
2802 [ + - ]: 1 : if (!m_syncDebounce) {
2803 [ + - + - : 1 : m_syncDebounce = new QTimer(this);
- + - - ]
2804 [ + - ]: 1 : m_syncDebounce->setSingleShot(true);
2805 [ + - ]: 1 : m_syncDebounce->setInterval(500);
2806 : 1 : connect(m_syncDebounce, &QTimer::timeout, this,
2807 [ + - ]: 2 : &MainWindow::doSettingsUpload);
2808 : : }
2809 : :
2810 : : // Create or reconfigure sync service
2811 [ + - ]: 1 : if (!m_syncService) {
2812 [ + - + - : 1 : m_syncService = new SettingsSyncService(this);
- + - - ]
2813 : 1 : connect(m_syncService, &SettingsSyncService::settingsReceived, this,
2814 [ + - ]: 1 : &MainWindow::onRemoteSettingsReceived);
2815 : 1 : connect(m_syncService, &SettingsSyncService::uploadComplete, this,
2816 [ + - ]: 1 : [this]() {
2817 [ + - + - : 2 : qCInfo(lcMainWindow) << "Settings upload complete";
+ - + + ]
2818 [ + - ]: 1 : QSettings s;
2819 [ + - ]: 2 : s.setValue(QStringLiteral("sync/lastSync"),
2820 [ + - + - ]: 2 : QDateTime::currentDateTimeUtc().toString(Qt::ISODate));
2821 [ + - ]: 1 : setStatus(QStringLiteral("sync"),
2822 [ + - ]: 2 : tr("⚙ Settings synchronized"), 5000);
2823 : 1 : });
2824 : 1 : connect(m_syncService, &SettingsSyncService::syncError, this,
2825 [ + - ]: 2 : [this](const QString &error) {
2826 [ + - + - : 4 : qCWarning(lcMainWindow) << "Sync error:" << error;
+ - + - +
+ ]
2827 [ + - ]: 2 : setStatus(QStringLiteral("sync"),
2828 [ + - + - ]: 6 : tr("⚙ Sync error: %1").arg(error), 10000);
2829 : 2 : });
2830 : : }
2831 : :
2832 : : // Only reconfigure if config actually changed (avoid destroying
2833 : : // an established IMAP connection for unrelated settings changes)
2834 [ - + - - ]: 1 : if (m_syncService->isEnabled() &&
2835 [ - + - + ]: 1 : m_syncService->syncFolder() == m_syncFolderPath) {
2836 [ # # # # : 0 : qCInfo(lcMainWindow) << "Sync config unchanged, keeping connection";
# # # # ]
2837 : : } else {
2838 [ + - ]: 1 : m_syncService->configure(m_reconnectImapConfig, m_syncFolderPath);
2839 [ + - ]: 1 : m_syncService->setEnabled(true);
2840 : : }
2841 : :
2842 : : // Hide the sync folder from the folder tree
2843 [ + - + - : 1 : if (m_folderTree && !m_syncFolderPath.isEmpty()) {
+ - ]
2844 : : // Read current hidden folders from config file
2845 : : QString configDir =
2846 [ + - ]: 2 : QStandardPaths::writableLocation(QStandardPaths::ConfigLocation) +
2847 [ + - ]: 3 : QStringLiteral("/mailjd");
2848 : : QStringList hidden =
2849 [ + - ]: 1 : FolderSubscriptionDialog::loadHidden(configDir);
2850 [ + - ]: 1 : if (!hidden.contains(m_syncFolderPath)) {
2851 [ + - ]: 1 : hidden.append(m_syncFolderPath);
2852 [ + - ]: 1 : FolderSubscriptionDialog::saveHidden(configDir, hidden);
2853 : 1 : m_folderTree->setHiddenFolders(hidden);
2854 [ - + ]: 1 : if (!m_lastFolderList.isEmpty())
2855 [ # # ]: 0 : refreshTreeWithBadges();
2856 : : }
2857 : 1 : }
2858 : :
2859 [ + - + - : 2 : qCInfo(lcMainWindow) << "Settings sync initialized, folder:"
+ - + + ]
2860 [ + - ]: 1 : << m_syncFolderPath;
2861 [ + + ]: 61 : }
2862 : :
2863 : 9 : void MainWindow::triggerSettingsUpload() {
2864 [ + + - + : 9 : if (!m_syncService || !m_syncService->isEnabled())
+ + ]
2865 : 8 : return;
2866 : : // (Re)start debounce timer — bundles rapid changes into one upload
2867 [ + - ]: 1 : if (m_syncDebounce)
2868 : 1 : m_syncDebounce->start();
2869 : : }
2870 : :
2871 : 1 : void MainWindow::doSettingsUpload() {
2872 [ + - - + : 1 : if (!m_syncService || !m_syncService->isEnabled())
- + ]
2873 : 0 : return;
2874 : :
2875 : : QString configDir =
2876 [ + - ]: 2 : QStandardPaths::writableLocation(QStandardPaths::ConfigLocation) +
2877 [ + - ]: 3 : QStringLiteral("/mailjd");
2878 : : SyncPayload payload =
2879 [ + - ]: 1 : SettingsCollector::collectLocal(m_cache, configDir, m_calendarStore);
2880 : :
2881 [ + - ]: 1 : m_syncService->uploadSettings(payload);
2882 [ + - ]: 1 : setStatus(QStringLiteral("sync"),
2883 [ + - ]: 2 : tr("⚙ Uploading settings…"), 5000);
2884 : 1 : }
2885 : :
2886 : 3 : void MainWindow::onRemoteSettingsReceived(const SyncPayload &payload) {
2887 [ + - ]: 3 : QSettings s;
2888 : : QString localClientId =
2889 [ + - + - ]: 3 : s.value(QStringLiteral("sync/clientId")).toString();
2890 : :
2891 : : // T-317: Echo suppression — don't apply our own changes
2892 [ - + ]: 3 : if (payload.clientId == localClientId) {
2893 [ # # # # : 0 : qCInfo(lcMainWindow) << "Ignoring own settings echo";
# # # # ]
2894 : 0 : return;
2895 : : }
2896 : :
2897 : : // T-317: Last-write-wins — check timestamps
2898 : : QString lastSync =
2899 [ + - + - ]: 3 : s.value(QStringLiteral("sync/lastSync")).toString();
2900 [ + + ]: 3 : if (!lastSync.isEmpty()) {
2901 : : QDateTime localTime =
2902 [ + - ]: 2 : QDateTime::fromString(lastSync, Qt::ISODate);
2903 [ + - + + : 3 : if (payload.lastModified.isValid() && localTime.isValid() &&
+ - + - -
+ - + ]
2904 [ + - ]: 1 : payload.lastModified <= localTime) {
2905 [ # # # # : 0 : qCInfo(lcMainWindow) << "Remote settings older than local, ignoring";
# # # # ]
2906 : 0 : return;
2907 : : }
2908 [ + - ]: 2 : }
2909 : :
2910 [ + - + - : 9 : qCInfo(lcMainWindow) << "Applying remote settings from client:"
+ - + + ]
2911 [ + - ]: 3 : << payload.clientId
2912 [ + - + - : 3 : << "icons:" << payload.folderIcons.size()
+ - ]
2913 [ + - + - : 3 : << "colors:" << payload.folderColors.size()
+ - ]
2914 [ + - + - : 3 : << "calColors:" << payload.calendarColors.size()
+ - ]
2915 [ + - + - ]: 3 : << "hidden:" << payload.hiddenFolders.size()
2916 [ + - + - ]: 6 : << "categories:" << payload.enabledCategories;
2917 : :
2918 : : QString configDir =
2919 [ + - ]: 6 : QStandardPaths::writableLocation(QStandardPaths::ConfigLocation) +
2920 [ + - ]: 9 : QStringLiteral("/mailjd");
2921 [ + - ]: 3 : SettingsCollector::applyRemote(payload, m_cache, configDir, m_calendarStore);
2922 : :
2923 : : // T-544: Reload interceptor whitelist if external content was synced
2924 : 6 : if (payload.enabledCategories.contains(
2925 [ - + ]: 6 : QStringLiteral("externalContentWhitelist"))) {
2926 [ # # ]: 0 : m_mailView->reloadWhitelist();
2927 : : }
2928 : :
2929 : : // Refresh UI to reflect new settings
2930 [ + - - + : 3 : if (m_folderTree && !m_lastFolderList.isEmpty()) {
- + ]
2931 : : // Reload hidden folders from disk (applyRemote may have changed them)
2932 [ # # ]: 0 : QStringList hidden = FolderSubscriptionDialog::loadHidden(configDir);
2933 : 0 : m_folderTree->setHiddenFolders(hidden);
2934 [ # # ]: 0 : refreshTreeWithBadges();
2935 [ # # # # : 0 : qCInfo(lcMainWindow) << "Tree refreshed after remote settings apply";
# # # # ]
2936 : 0 : } else {
2937 [ + - + - : 6 : qCWarning(lcMainWindow) << "Cannot refresh tree: folderTree="
+ - + + ]
2938 [ + - ]: 3 : << (m_folderTree != nullptr)
2939 [ + - + - ]: 3 : << "lastFolderList=" << m_lastFolderList.size();
2940 : : }
2941 : :
2942 : : // Refresh calendar if open (calendar colors may have changed)
2943 [ - + - - : 3 : if (m_calendarWidget && !payload.calendarColors.isEmpty()) {
- - - + ]
2944 [ # # ]: 0 : m_calendarWidget->navigateToDate(m_calendarWidget->selectedDate());
2945 [ # # # # : 0 : qCInfo(lcMainWindow) << "Calendar refreshed after remote color sync";
# # # # ]
2946 : : }
2947 : :
2948 [ + - ]: 3 : setStatus(QStringLiteral("sync"),
2949 [ + - ]: 6 : tr("⚙ Settings received from another client"), 5000);
2950 [ + - + - : 3 : }
+ - ]
2951 : :
2952 : 1 : void MainWindow::showSetupWizard() {
2953 [ + - - + : 1 : auto *wizard = new SetupWizard(this);
- - ]
2954 [ - + ]: 1 : if (m_runDialog(wizard) == QDialog::Accepted) {
2955 [ # # # # : 0 : qCInfo(lcMainWindow) << "Setup wizard completed, loading accounts";
# # # # ]
2956 : 0 : reloadAccounts();
2957 : : } else {
2958 : : // User cancelled the wizard – show info if still no accounts
2959 [ + - ]: 1 : const auto accounts = AccountConfigLoader::loadAll();
2960 [ - + ]: 1 : if (accounts.empty()) {
2961 [ # # # # ]: 0 : setStatus(tr("No accounts configured. Use Ctrl+, to add one."));
2962 : : }
2963 : 1 : }
2964 : 1 : wizard->deleteLater();
2965 : 1 : }
2966 : :
2967 : 58 : void MainWindow::restoreLayout() {
2968 : : // Window geometry
2969 [ + - + + ]: 116 : if (m_settings.contains("geometry")) {
2970 [ + - + - : 98 : restoreGeometry(m_settings.value("geometry").toByteArray());
+ - ]
2971 : : } else {
2972 : 9 : resize(1200, 800);
2973 : : }
2974 : :
2975 : : // Splitter positions
2976 [ + - + + ]: 116 : if (m_settings.contains("horizontalSplitter")) {
2977 [ + - ]: 49 : m_horizontalSplitter->restoreState(
2978 [ + - + - ]: 147 : m_settings.value("horizontalSplitter").toByteArray());
2979 : : }
2980 [ + - + + ]: 116 : if (m_settings.contains("verticalSplitter")) {
2981 [ + - ]: 49 : m_verticalSplitter->restoreState(
2982 [ + - + - ]: 147 : m_settings.value("verticalSplitter").toByteArray());
2983 : : }
2984 : :
2985 : : // MailList column widths
2986 : : // T-137: Version guard — discard saved header state when column count changes
2987 : : // (e.g. after adding the Answered column). The version counter should be
2988 : : // incremented whenever the Column enum changes.
2989 : 58 : constexpr int kHeaderVersion = 4; // bumped 3→4: added Attachment column
2990 [ + - + - ]: 116 : int savedVersion = m_settings.value("mailList/headerVersion", 1).toInt();
2991 [ + + ]: 107 : if (savedVersion == kHeaderVersion &&
2992 [ + - + - : 156 : m_settings.contains("mailList/headerState")) {
+ + ]
2993 [ + - ]: 98 : m_mailList->header()->restoreState(
2994 [ + - + - ]: 147 : m_settings.value("mailList/headerState").toByteArray());
2995 : : } else {
2996 : : // First run with new columns: discard old state, use defaults
2997 [ + - ]: 18 : m_settings.remove("mailList/headerState");
2998 [ + - ]: 18 : m_settings.setValue("mailList/headerVersion", kHeaderVersion);
2999 : : }
3000 : :
3001 : : // Re-apply fixed-width icon columns after restore — restoreState can
3002 : : // override constructor sizes when the saved state predates a layout change.
3003 : 58 : m_mailList->header()->setMinimumSectionSize(20);
3004 : 58 : m_mailList->header()->resizeSection(MailListModel::Star, 24);
3005 : 58 : m_mailList->header()->setSectionResizeMode(MailListModel::Star,
3006 : : QHeaderView::Fixed);
3007 : 58 : m_mailList->header()->resizeSection(MailListModel::Attachment, 22);
3008 : 58 : m_mailList->header()->setSectionResizeMode(MailListModel::Attachment,
3009 : : QHeaderView::Interactive);
3010 : :
3011 : : // MailList sort order
3012 [ + - + + ]: 116 : if (m_settings.contains("mailList/sortColumn")) {
3013 [ + - + - ]: 98 : int sortCol = m_settings.value("mailList/sortColumn").toInt();
3014 : : auto sortOrder = static_cast<Qt::SortOrder>(
3015 [ + - + - ]: 98 : m_settings.value("mailList/sortOrder", 0).toInt());
3016 : 49 : m_mailList->sortByColumn(sortCol, sortOrder);
3017 : : }
3018 : :
3019 : : // Load pending session restore values (applied after IMAP connects)
3020 : : m_pendingRestoreFolder =
3021 [ + - + - ]: 116 : m_settings.value("session/selectedFolder").toString();
3022 : 58 : m_pendingRestoreUid =
3023 [ + - + - ]: 116 : m_settings.value("session/selectedMailUid", -1).toLongLong();
3024 : : m_pendingExpandedFolders =
3025 [ + - + - ]: 116 : m_settings.value("session/expandedFolders").toStringList();
3026 : :
3027 : : // T-127: Restore thread view state
3028 : 58 : m_pendingThreadView =
3029 [ + - + - ]: 116 : m_settings.value("view/threadView", false).toBool();
3030 : :
3031 [ + - + - : 116 : qCInfo(lcMainWindow) << "Layout restored. Pending folder:"
+ - + + ]
3032 [ + - ]: 58 : << m_pendingRestoreFolder
3033 [ + - + - ]: 58 : << "Pending UID:" << m_pendingRestoreUid
3034 [ + - + - ]: 58 : << "Pending thread view:" << m_pendingThreadView;
3035 : :
3036 : : // T-215: Restore tab state (tabs will be materialized after cache init)
3037 [ + - + - ]: 116 : m_pendingTabState = m_settings.value("tabs/openTabs").toList();
3038 : 58 : }
3039 : :
3040 : 5 : void MainWindow::saveLayout() {
3041 : : // Window geometry + splitters
3042 [ + - + - ]: 10 : m_settings.setValue("geometry", saveGeometry());
3043 [ + - + - ]: 10 : m_settings.setValue("horizontalSplitter", m_horizontalSplitter->saveState());
3044 [ + - + - ]: 10 : m_settings.setValue("verticalSplitter", m_verticalSplitter->saveState());
3045 : :
3046 : : // MailList column widths + sort order
3047 [ + - ]: 10 : m_settings.setValue("mailList/headerState",
3048 [ + - + - ]: 10 : m_mailList->header()->saveState());
3049 [ + - + - ]: 10 : m_settings.setValue("mailList/sortColumn",
3050 [ + - ]: 5 : m_mailList->header()->sortIndicatorSection());
3051 [ + - ]: 5 : m_settings.setValue(
3052 : : "mailList/sortOrder",
3053 [ + - + - ]: 5 : static_cast<int>(m_mailList->header()->sortIndicatorOrder()));
3054 : :
3055 : : // Selected folder
3056 [ + - ]: 10 : m_settings.setValue("session/selectedFolder",
3057 [ + - ]: 10 : m_folderTree->selectedFolderPath());
3058 : :
3059 : : // Selected mail UID
3060 [ + - + - ]: 5 : auto currentIdx = m_mailList->selectionModel()->currentIndex();
3061 [ + + ]: 5 : if (currentIdx.isValid()) {
3062 [ + - ]: 2 : auto srcIdx = m_mailListProxy->mapToSource(currentIdx);
3063 [ + - + - ]: 4 : m_settings.setValue("session/selectedMailUid",
3064 : 2 : m_mailListModel->uidAt(srcIdx.row()));
3065 : : } else {
3066 [ + - ]: 6 : m_settings.remove("session/selectedMailUid");
3067 : : }
3068 : :
3069 : : // Expanded folder paths
3070 [ + - ]: 10 : m_settings.setValue("session/expandedFolders",
3071 [ + - ]: 10 : m_folderTree->expandedFolderPaths());
3072 : :
3073 : : // T-215: Tab state persistence
3074 [ + - ]: 5 : if (m_tabManager) {
3075 [ + - + - ]: 10 : m_settings.setValue("tabs/openTabs", m_tabManager->saveState());
3076 : : }
3077 : :
3078 [ + - + - : 10 : qCInfo(lcMainWindow) << "Layout and session state saved";
+ - + + ]
3079 : 5 : }
3080 : :
3081 : 70 : void MainWindow::persistTabState() {
3082 : : // Don't persist while the saved tabs are still being restored, and not before
3083 : : // restore has happened at all — that would clobber the saved state.
3084 [ + + - + ]: 70 : if (!m_tabRestoreDone || !m_tabManager)
3085 : 46 : return;
3086 [ + - + - ]: 48 : m_settings.setValue("tabs/openTabs", m_tabManager->saveState());
3087 : : }
3088 : :
3089 : 7 : void MainWindow::restoreSessionFolder() {
3090 : : // T-546: Restore expanded folders with 3-level priority
3091 [ - + ]: 7 : if (!m_pendingExpandedFolders.isEmpty()) {
3092 : : // First connect: restore saved QSettings state
3093 : 0 : m_folderTree->restoreExpandedFolders(m_pendingExpandedFolders);
3094 [ + + ]: 7 : } else if (!m_reconnectExpandedFolders.isEmpty()) {
3095 : : // Reconnect: restore state saved before setFolders()
3096 : 3 : m_folderTree->restoreExpandedFolders(m_reconnectExpandedFolders);
3097 : 3 : m_reconnectExpandedFolders.clear();
3098 : : } else {
3099 : : // First run or no saved state — expand all as default
3100 : 4 : m_folderTree->expandAll();
3101 : : }
3102 : 7 : m_pendingExpandedFolders.clear();
3103 : :
3104 : : // Restore selected folder
3105 [ - + ]: 7 : if (!m_pendingRestoreFolder.isEmpty()) {
3106 [ # # # # : 0 : qCInfo(lcMainWindow) << "Restoring folder:" << m_pendingRestoreFolder;
# # # # #
# ]
3107 : 0 : m_folderTree->selectFolder(m_pendingRestoreFolder);
3108 : 0 : m_pendingRestoreFolder.clear();
3109 : : // selectFolder triggers folderSelected signal → MailController loads
3110 : : // headers → modelReset → restoreSessionMail() is called
3111 [ + + ]: 7 : } else if (!m_controller->currentFolder().isEmpty()) {
3112 : : // Reconnect: m_pendingRestoreFolder was already consumed on first connect,
3113 : : // but the controller still knows which folder was active. Re-select it
3114 : : // to ensure the IMAP connection SELECTs the folder after reconnect.
3115 [ + - ]: 4 : const MailId selectedMail = currentMailId();
3116 [ + + ]: 4 : if (selectedMail.isValid()) {
3117 : 1 : m_pendingRestoreUid = selectedMail.uid;
3118 [ + - + - : 2 : qCInfo(lcMainWindow) << "Reconnect: preserving selected UID"
+ - + + ]
3119 [ + - ]: 1 : << m_pendingRestoreUid;
3120 : : }
3121 [ + - + - : 8 : qCInfo(lcMainWindow) << "Reconnect: re-selecting current folder:"
+ - + + ]
3122 [ + - ]: 4 : << m_controller->currentFolder();
3123 [ + - ]: 4 : m_controller->onFolderSelected(m_controller->currentFolder());
3124 : 4 : }
3125 : :
3126 : : // T-215: Restore open tabs
3127 [ + - + + : 7 : if (m_tabManager && !m_pendingTabState.isEmpty()) {
+ + ]
3128 : 2 : m_tabManager->restoreState(m_pendingTabState);
3129 : 2 : m_pendingTabState.clear();
3130 : : }
3131 : : // Allow tab-state persistence only after restore (incl. the deferred
3132 : : // switchToTab that restoreState queues with singleShot(0)) has settled.
3133 [ + - ]: 7 : QTimer::singleShot(0, this, [this]() { m_tabRestoreDone = true; });
3134 : 7 : }
3135 : :
3136 : 113 : void MainWindow::restoreSessionMail() {
3137 [ + + ]: 113 : if (m_pendingRestoreUid <= 0)
3138 : 110 : return;
3139 : :
3140 : : // 67.A1: shared select+scroll path (flat/thread aware, ClearAndSelect)
3141 [ + - ]: 3 : if (trySelectMailInView(m_pendingRestoreUid)) {
3142 [ + - + - : 6 : qCInfo(lcMainWindow) << "Restored mail selection for UID"
+ - + + ]
3143 [ + - ]: 3 : << m_pendingRestoreUid
3144 [ + - + - : 3 : << "(thread:" << m_threadViewActive << ")";
+ - ]
3145 : 3 : m_pendingRestoreUid = -1;
3146 : : } else {
3147 [ # # # # : 0 : qCInfo(lcMainWindow) << "Could not find UID" << m_pendingRestoreUid
# # # # #
# ]
3148 [ # # ]: 0 : << "in model for restore — will retry on next reset";
3149 : : }
3150 : : }
3151 : :
3152 : 439 : void MainWindow::setStatus(const QString &message) {
3153 : : // Backward-compatible: single-arg setStatus uses "general" key
3154 [ + - ]: 439 : setStatus(QStringLiteral("general"), message);
3155 : 439 : }
3156 : :
3157 : 946 : void MainWindow::setStatus(const QString &key, const QString &message,
3158 : : int timeoutMs) {
3159 [ + + ]: 946 : if (message.isEmpty()) {
3160 : 1 : clearStatus(key);
3161 : 1 : return;
3162 : : }
3163 : 945 : m_statusMessages[key] = message;
3164 : :
3165 : : // Handle auto-clear timeout
3166 [ + + ]: 945 : if (timeoutMs > 0) {
3167 [ + + ]: 113 : if (!m_statusTimers.contains(key)) {
3168 [ + - - + : 44 : auto *timer = new QTimer(this);
- - ]
3169 : 44 : timer->setSingleShot(true);
3170 : 44 : m_statusTimers[key] = timer;
3171 [ + - ]: 66 : connect(timer, &QTimer::timeout, this, [this, key]() { clearStatus(key); });
3172 : : }
3173 : 113 : m_statusTimers[key]->start(timeoutMs);
3174 [ + + ]: 832 : } else if (m_statusTimers.contains(key)) {
3175 : 37 : m_statusTimers[key]->stop();
3176 : : }
3177 : :
3178 : 945 : renderStatusBar();
3179 : : }
3180 : :
3181 : 48 : void MainWindow::clearStatus(const QString &key) {
3182 : 48 : m_statusMessages.remove(key);
3183 [ + + ]: 48 : if (m_statusTimers.contains(key)) {
3184 : 33 : m_statusTimers[key]->stop();
3185 : : }
3186 : 48 : renderStatusBar();
3187 : 48 : }
3188 : :
3189 : 994 : void MainWindow::renderStatusBar() {
3190 : 994 : QStringList parts;
3191 : : // Render in priority order: folder, search, body, general, error
3192 : : static const QStringList order = {
3193 : 9 : QStringLiteral("folder"), QStringLiteral("search"),
3194 : 9 : QStringLiteral("body"), QStringLiteral("general"),
3195 [ + + + - : 1057 : QStringLiteral("error")};
+ + - - -
- ]
3196 [ + + ]: 5964 : for (const QString &key : order) {
3197 [ + - + + ]: 4970 : if (m_statusMessages.contains(key))
3198 [ + - + - ]: 1432 : parts << m_statusMessages[key];
3199 : : }
3200 : : // Any keys not in the predefined order
3201 [ + - ]: 994 : for (auto it = m_statusMessages.constBegin();
3202 [ + - + + ]: 3003 : it != m_statusMessages.constEnd(); ++it) {
3203 [ + + ]: 2009 : if (!order.contains(it.key()))
3204 [ + - ]: 577 : parts << it.value();
3205 : : }
3206 [ + - + - ]: 1988 : m_statusLabel->setText(parts.join(QStringLiteral(" · ")));
3207 [ + - - - : 1048 : }
- - ]
3208 : :
3209 : 3 : void MainWindow::closeEvent(QCloseEvent *event) {
3210 [ + - + - ]: 6 : bool closeToTray = m_settings.value("tray/closeToTray", true).toBool();
3211 : : // Legacy key fallback
3212 [ + - + + ]: 6 : if (!m_settings.contains("tray/closeToTray"))
3213 [ + - + - ]: 4 : closeToTray = m_settings.value("tray/minimizeToTray", true).toBool();
3214 : :
3215 : 3 : bool hasTray = false;
3216 : : #ifdef MAILJD_KDE_INTEGRATION
3217 : : hasTray = m_sni != nullptr;
3218 : : #else
3219 [ - + - - ]: 3 : hasTray = m_trayIcon && m_trayIcon->isVisible();
3220 : : #endif
3221 : :
3222 [ + - + + : 3 : if (!m_reallyQuit && closeToTray && hasTray) {
- + ]
3223 : 0 : hide();
3224 [ # # ]: 0 : if (!m_trayNotificationShown) {
3225 : : #ifndef MAILJD_KDE_INTEGRATION
3226 [ # # ]: 0 : m_trayIcon->showMessage(QStringLiteral("MailJD"),
3227 [ # # ]: 0 : tr("MailJD is running in the background."),
3228 : : QSystemTrayIcon::Information, 3000);
3229 : : #endif
3230 : 0 : m_trayNotificationShown = true;
3231 : : }
3232 : 0 : event->ignore();
3233 : 0 : return;
3234 : : }
3235 : 3 : quitApp();
3236 : 3 : event->accept();
3237 : : }
3238 : :
3239 : : // T-181: Handle clicks on the suggestion label
3240 : 418 : bool MainWindow::eventFilter(QObject *obj, QEvent *event) {
3241 [ + - ]: 418 : if (obj == m_suggestionLabel) {
3242 [ + + ]: 418 : if (event->type() == QEvent::MouseButtonPress) {
3243 : 1 : auto *me = static_cast<QMouseEvent *>(event);
3244 [ - + ]: 1 : if (me->button() == Qt::LeftButton) {
3245 : 0 : quickMoveToSuggestion();
3246 : 0 : return true;
3247 : : }
3248 [ + - ]: 1 : if (me->button() == Qt::RightButton) {
3249 : : // Context menu
3250 [ + - ]: 1 : QMenu menu;
3251 [ + - ]: 1 : menu.addAction(
3252 [ + - ]: 2 : tr("→ Move to %1").arg(m_currentSuggestion),
3253 [ + - ]: 1 : this, &MainWindow::quickMoveToSuggestion);
3254 [ + - + - ]: 1 : menu.addAction(tr("Choose another folder…"), this, [this]() {
3255 : 1 : m_commandBar->activate(CommandBar::MoveToFolder);
3256 : 1 : });
3257 [ + - ]: 1 : menu.addSeparator();
3258 [ + - + - ]: 1 : menu.addAction(tr("Ignore suggestion"), this, [this]() {
3259 : 1 : m_suggestionLabel->hide();
3260 : 1 : m_currentSuggestion.clear();
3261 : 1 : });
3262 [ + - + - ]: 1 : menu.exec(me->globalPosition().toPoint());
3263 : 1 : return true;
3264 : 1 : }
3265 : : }
3266 : : }
3267 : 417 : return QMainWindow::eventFilter(obj, event);
3268 : : }
3269 : :
3270 : 6 : void MainWindow::refreshTreeWithBadges() {
3271 [ + - ]: 6 : if (m_lastFolderList.isEmpty())
3272 : 6 : return;
3273 [ # # ]: 0 : auto expanded = m_folderTree->expandedFolderPaths();
3274 [ # # ]: 0 : auto sel = m_folderTree->selectedFolderPath();
3275 [ # # ]: 0 : m_folderTree->setFolders(m_lastFolderList);
3276 [ # # ]: 0 : m_folderTree->restoreExpandedFolders(expanded);
3277 [ # # ]: 0 : if (!sel.isEmpty())
3278 [ # # ]: 0 : m_folderTree->selectFolder(sel);
3279 : : // Restore badges from controller's cached unread counts
3280 [ # # ]: 0 : for (auto it = m_controller->lastPolledUnread().cbegin();
3281 [ # # # # ]: 0 : it != m_controller->lastPolledUnread().cend(); ++it) {
3282 [ # # ]: 0 : m_folderTree->setUnreadCount(it.key(), it.value());
3283 : : }
3284 : 0 : }
3285 : :
3286 : 12 : QString MainWindow::messageIdHeaderValue(const QString &messageId) {
3287 [ + - ]: 12 : QString value = messageId.trimmed();
3288 [ + - ]: 12 : value.remove(QLatin1Char('\r'));
3289 [ + - ]: 12 : value.remove(QLatin1Char('\n'));
3290 [ + + ]: 12 : if (value.isEmpty())
3291 : 2 : return {};
3292 [ + - + + : 10 : if (value.startsWith(QLatin1Char('<')) && value.endsWith(QLatin1Char('>')))
+ - + - +
+ ]
3293 : 6 : return value;
3294 [ + - ]: 8 : return QStringLiteral("<%1>").arg(value);
3295 : 12 : }
3296 : :
3297 : 4 : QStringList MainWindow::replyReferencesForHeader(const MailHeader &header) {
3298 : 4 : QStringList references;
3299 : 4 : QSet<QString> seen;
3300 : 7 : auto append = [&references, &seen](const QString &messageId) {
3301 [ + - ]: 7 : const QString value = messageIdHeaderValue(messageId);
3302 [ + + + + : 7 : if (value.isEmpty() || seen.contains(value))
+ + ]
3303 : 3 : return;
3304 [ + - ]: 4 : seen.insert(value);
3305 [ + - ]: 4 : references.append(value);
3306 [ + + ]: 7 : };
3307 : :
3308 [ + + ]: 7 : for (const auto &ref : header.references)
3309 [ + - ]: 3 : append(ref);
3310 [ + - ]: 4 : append(header.messageId);
3311 : 4 : return references;
3312 : 4 : }
3313 : :
3314 : : // T-124: System tray setup
3315 : 57 : void MainWindow::setupTray() {
3316 [ + - - + : 57 : m_trayMenu = new QMenu(this);
- - ]
3317 : 57 : rebuildTrayMenu();
3318 : :
3319 : : #ifdef MAILJD_KDE_INTEGRATION
3320 : : m_sni = new KStatusNotifierItem(QStringLiteral("mailjd"), this);
3321 : : m_sni->setTitle(QStringLiteral("MailJD"));
3322 : : m_sni->setIconByName(QStringLiteral("mailjd"));
3323 : : m_sni->setToolTip(QStringLiteral("mailjd"), QStringLiteral("MailJD"), QString());
3324 : : m_sni->setStatus(KStatusNotifierItem::Active);
3325 : : m_sni->setContextMenu(m_trayMenu);
3326 : :
3327 : : connect(m_sni, &KStatusNotifierItem::activateRequested, this, [this]() {
3328 : : if (isVisible() && !isMinimized())
3329 : : hide();
3330 : : else {
3331 : : bringToFront();
3332 : : }
3333 : : });
3334 : :
3335 : : connect(m_sni, &KStatusNotifierItem::secondaryActivateRequested, this,
3336 : : [this]() {
3337 : : bringToFront();
3338 : : openComposeNew();
3339 : : });
3340 : :
3341 : : qCInfo(lcMainWindow) << "KDE KStatusNotifierItem initialized";
3342 : : return;
3343 : : #endif
3344 : :
3345 [ + - ]: 57 : if (!QSystemTrayIcon::isSystemTrayAvailable())
3346 : 57 : return;
3347 : :
3348 [ # # # # : 0 : m_trayIcon = new QSystemTrayIcon(QIcon(":/icons/mailjd.svg"), this);
# # # # #
# ]
3349 : 0 : m_trayIcon->setContextMenu(m_trayMenu);
3350 : :
3351 : 0 : connect(m_trayIcon, &QSystemTrayIcon::activated, this,
3352 [ # # ]: 0 : [this](QSystemTrayIcon::ActivationReason reason) {
3353 [ # # ]: 0 : if (reason == QSystemTrayIcon::MiddleClick) {
3354 : 0 : bringToFront();
3355 : 0 : openComposeNew();
3356 [ # # # # ]: 0 : } else if (reason == QSystemTrayIcon::DoubleClick ||
3357 : : reason == QSystemTrayIcon::Trigger) {
3358 [ # # # # : 0 : if (isVisible() && !isMinimized())
# # ]
3359 : 0 : hide();
3360 : : else {
3361 : 0 : bringToFront();
3362 : : }
3363 : : }
3364 : 0 : });
3365 : :
3366 : 0 : m_trayIcon->show();
3367 [ # # # # : 0 : qCInfo(lcMainWindow) << "System tray icon initialized";
# # # # ]
3368 : : }
3369 : :
3370 : 121 : void MainWindow::updateTrayIcon(int unreadCount) {
3371 : : #ifdef MAILJD_KDE_INTEGRATION
3372 : : if (m_sni) {
3373 : : if (unreadCount <= 0) {
3374 : : m_sni->setIconByName(QStringLiteral("mailjd"));
3375 : : m_sni->setToolTip(QStringLiteral("mailjd"), QStringLiteral("MailJD"),
3376 : : QString());
3377 : : m_sni->setStatus(KStatusNotifierItem::Passive);
3378 : : return;
3379 : : }
3380 : :
3381 : : m_sni->setStatus(KStatusNotifierItem::NeedsAttention);
3382 : :
3383 : : QPixmap base = QIcon(":/icons/mailjd.svg").pixmap(256, 256);
3384 : : QPainter p(&base);
3385 : : p.setRenderHint(QPainter::Antialiasing);
3386 : :
3387 : : const int badgeSize = 154;
3388 : : QRect badgeRect(base.width() - badgeSize, base.height() - badgeSize,
3389 : : badgeSize, badgeSize);
3390 : :
3391 : : p.setPen(QPen(Qt::white, 6));
3392 : : p.setBrush(QColor(
3393 : : ThemeManager::instance().color(QStringLiteral("@danger"))));
3394 : : p.drawEllipse(badgeRect);
3395 : :
3396 : : QFont badgeFont(QStringLiteral("Arial"));
3397 : : badgeFont.setPixelSize(70);
3398 : : badgeFont.setBold(true);
3399 : : p.setFont(badgeFont);
3400 : : p.setPen(Qt::white);
3401 : :
3402 : : QString badgeText = unreadCount > 99
3403 : : ? QStringLiteral("99+")
3404 : : : QString::number(unreadCount);
3405 : : p.drawText(badgeRect, Qt::AlignCenter, badgeText);
3406 : : p.end();
3407 : :
3408 : : m_sni->setIconByPixmap(QIcon(base));
3409 : : m_sni->setToolTip(QStringLiteral("mailjd"), QStringLiteral("MailJD"),
3410 : : tr("MailJD – %1 unread").arg(unreadCount));
3411 : : return;
3412 : : }
3413 : : #endif
3414 : :
3415 [ + - ]: 121 : if (!m_trayIcon)
3416 : 121 : return;
3417 : :
3418 [ # # ]: 0 : if (unreadCount <= 0) {
3419 [ # # # # : 0 : m_trayIcon->setIcon(QIcon(":/icons/mailjd.svg"));
# # ]
3420 [ # # ]: 0 : m_trayIcon->setToolTip(QStringLiteral("MailJD"));
3421 : 0 : return;
3422 : : }
3423 : :
3424 [ # # # # : 0 : QPixmap base = QIcon(":/icons/mailjd.svg").pixmap(256, 256);
# # ]
3425 [ # # ]: 0 : QPainter p(&base);
3426 [ # # ]: 0 : p.setRenderHint(QPainter::Antialiasing);
3427 : :
3428 : 0 : const int badgeSize = 154;
3429 [ # # ]: 0 : QRect badgeRect(base.width() - badgeSize, base.height() - badgeSize,
3430 [ # # ]: 0 : badgeSize, badgeSize);
3431 : :
3432 [ # # # # : 0 : p.setPen(QPen(Qt::white, 6));
# # ]
3433 [ # # # # ]: 0 : p.setBrush(QColor(
3434 [ # # # # ]: 0 : ThemeManager::instance().color(QStringLiteral("@danger"))));
3435 [ # # ]: 0 : p.drawEllipse(badgeRect);
3436 : :
3437 [ # # ]: 0 : QFont badgeFont(QStringLiteral("Arial"));
3438 [ # # ]: 0 : badgeFont.setPixelSize(70);
3439 [ # # ]: 0 : badgeFont.setBold(true);
3440 [ # # ]: 0 : p.setFont(badgeFont);
3441 [ # # ]: 0 : p.setPen(Qt::white);
3442 : :
3443 : : QString badgeText = unreadCount > 99
3444 [ # # # # ]: 0 : ? QStringLiteral("99+")
3445 [ # # # # ]: 0 : : QString::number(unreadCount);
3446 [ # # ]: 0 : p.drawText(badgeRect, Qt::AlignCenter, badgeText);
3447 [ # # ]: 0 : p.end();
3448 : :
3449 [ # # # # ]: 0 : m_trayIcon->setIcon(QIcon(base));
3450 [ # # ]: 0 : m_trayIcon->setToolTip(
3451 [ # # # # ]: 0 : tr("MailJD – %1 unread message(s)").arg(unreadCount));
3452 : 0 : }
3453 : :
3454 : 3 : void MainWindow::quitApp() {
3455 : 3 : m_reallyQuit = true;
3456 : : // T-720: Stop the health monitor's reconnect + probe timers so a
3457 : : // pending reconnect cannot fire during teardown.
3458 [ + - ]: 3 : if (m_imapHealth)
3459 : 3 : m_imapHealth->setActive(false);
3460 : 3 : saveLayout();
3461 : 3 : m_imapService->disconnect();
3462 : 3 : m_cache->close();
3463 : : // T-163: Close contact store
3464 [ + - ]: 3 : if (m_contactStore)
3465 : 3 : m_contactStore->close();
3466 : : // T-171: Close FolderPredictor
3467 [ + - ]: 3 : if (m_folderPredictor)
3468 : 3 : m_folderPredictor->close();
3469 : : // T-270: Stop background threads before quitting
3470 [ - + ]: 3 : if (m_suggestionThread) {
3471 : 0 : m_suggestionThread->quit();
3472 : 0 : m_suggestionThread->wait(2000);
3473 : : }
3474 : : // T-270: Ensure the event loop terminates (close() alone may not suffice
3475 : : // when the window is hidden to tray)
3476 : 3 : QApplication::quit();
3477 : 3 : }
3478 : :
3479 : 144 : void MainWindow::rebuildTrayMenu() {
3480 : 144 : m_trayMenu->clear();
3481 [ + - + - ]: 144 : m_trayMenu->addAction(tr("Open MailJD"), this, [this]() {
3482 : 0 : bringToFront();
3483 : 0 : });
3484 [ + - + - ]: 144 : m_trayMenu->addAction(tr("Inbox"), this, [this]() {
3485 : 0 : bringToFront();
3486 [ # # ]: 0 : m_folderTree->selectFolder(QStringLiteral("INBOX"));
3487 : 0 : });
3488 : 144 : m_trayMenu->addSeparator();
3489 [ + - + - ]: 144 : m_trayMenu->addAction(tr("Check Mail"), this, [this]() {
3490 : 0 : triggerPollNow();
3491 : 0 : });
3492 [ + - + - ]: 144 : m_trayMenu->addAction(tr("New Message"), this, [this]() {
3493 : 0 : bringToFront();
3494 : 0 : openComposeNew();
3495 : 0 : });
3496 : 144 : m_trayMenu->addSeparator();
3497 [ + - + - ]: 144 : m_trayMenu->addAction(tr("Quit"), this, &MainWindow::quitApp);
3498 : 144 : }
3499 : :
3500 : 6 : void MainWindow::openComposeNew() {
3501 [ + - - + : 6 : auto *compose = new ComposeWindow(this);
- - ]
3502 : 6 : configureComposeWindow(compose);
3503 : 6 : setupComposeTracking(compose);
3504 : 6 : compose->setAttribute(Qt::WA_DeleteOnClose);
3505 : 6 : compose->show();
3506 : 6 : }
3507 : :
3508 : 3 : bool MainWindow::openMailtoUrl(const QString &url) {
3509 [ + - ]: 3 : const auto request = MailtoRequest::parse(url);
3510 [ + + ]: 3 : if (!request)
3511 : 1 : return false;
3512 : :
3513 [ + - + - : 2 : auto *compose = new ComposeWindow(this);
- + - - ]
3514 [ + - ]: 2 : configureComposeWindow(compose);
3515 [ + - ]: 2 : setupComposeTracking(compose);
3516 [ + - ]: 2 : compose->setTo(request->to);
3517 [ + - ]: 2 : compose->setSubject(request->subject);
3518 [ + - ]: 2 : compose->setBody(request->body);
3519 [ + - ]: 2 : compose->setAttribute(Qt::WA_DeleteOnClose);
3520 [ + - ]: 2 : compose->show();
3521 : 2 : return true;
3522 : 3 : }
3523 : :
3524 : 3 : void MainWindow::triggerPollNow() {
3525 [ + - ]: 3 : if (m_controller)
3526 : 3 : m_controller->triggerPollNow();
3527 : 3 : }
3528 : :
3529 : 3 : void MainWindow::notifyNewMail(const QString &from, const QString &subject,
3530 : : qint64 uid, qint64 folderId) {
3531 [ + - + - : 6 : if (!m_settings.value("notifications/enabled", true).toBool())
- + ]
3532 : 2 : return;
3533 [ + - + - : 3 : if (isVisible() && isActiveWindow() && !isMinimized())
+ - + + +
- + - +
+ ]
3534 : 2 : return;
3535 : :
3536 [ + - ]: 1 : uint id = m_desktopNotifier->notify(from, subject);
3537 [ - + ]: 1 : if (id > 0)
3538 [ # # # # ]: 0 : m_notificationUids.insert(id, qMakePair(folderId, uid));
3539 : : }
3540 : :
3541 : : // 67.A2: One summary popup for a clustered burst of new mails. While a
3542 : : // summary is still on screen, the next one replaces it in place
3543 : : // (replaces_id) instead of stacking another popup.
3544 : 2 : void MainWindow::notifySummaryPopup(const QString &title, const QString &body,
3545 : : int count, qint64 folderId,
3546 : : qint64 newestUid) {
3547 : : Q_UNUSED(count)
3548 [ + - + - : 4 : if (!m_settings.value("notifications/enabled", true).toBool())
- + ]
3549 : 2 : return;
3550 [ + - + - : 2 : if (isVisible() && isActiveWindow() && !isMinimized())
+ - + - +
- + - +
- ]
3551 : 2 : return;
3552 : :
3553 : : uint id =
3554 [ # # ]: 0 : m_desktopNotifier->notifySummary(title, body, m_summaryNotificationId);
3555 [ # # ]: 0 : if (id == 0)
3556 : 0 : return;
3557 [ # # # # ]: 0 : if (m_summaryNotificationId != 0 && m_summaryNotificationId != id)
3558 [ # # ]: 0 : m_notificationUids.remove(m_summaryNotificationId);
3559 : 0 : m_summaryNotificationId = id;
3560 : : // "open" lands on the newest mail of the burst via the 67.A1 helper.
3561 [ # # # # ]: 0 : m_notificationUids.insert(id, qMakePair(folderId, newestUid));
3562 : : }
3563 : :
3564 : : // Sprint 76 (T-76.A1): central foreground-activation helper. Restores from
3565 : : // minimized/hidden, raises the window and requests compositor focus. With
3566 : : // MAILJD_HAVE_KWINDOWSYSTEM this uses KWindowSystem::requestActivateWindow
3567 : : // (Wayland xdg-activation-v1 / X11 _NET_ACTIVE_WINDOW); without it the Qt-only
3568 : : // raise()+activateWindow() fallback remains. Givens focus to the mail list
3569 : : // when already on the main view so keyboard input lands on a meaningful target.
3570 : : // (KWindowSystem API note: the stable method is activateWindow(QWindow*);
3571 : : // requestActivateWindow is preferred when a KF6 release provides it, detected
3572 : : // at CMake time via MAILJD_KWS_HAS_REQUEST_ACTIVATE.)
3573 : 14 : void MainWindow::bringToFront() {
3574 : : // 1. Restore from minimized / hidden, mark active.
3575 [ + - + - ]: 14 : setWindowState((windowState() & ~Qt::WindowMinimized) | Qt::WindowActive);
3576 : 14 : showNormal();
3577 : 14 : raise();
3578 : 14 : activateWindow();
3579 : :
3580 : : // 2. Platform-reliable activation (Wayland xdg-activation, X11 NETWM).
3581 : : // KWindowSystem's API was renamed across KF6 releases: prefer
3582 : : // requestActivateWindow (the xdg-activation request, KF6 ≥ 6.x) when the
3583 : : // feature check found it; otherwise fall back to activateWindow, which is
3584 : : // the stable API present in every KF6 release.
3585 : : #if defined(MAILJD_HAVE_KWINDOWSYSTEM)
3586 [ + - ]: 14 : if (auto *w = windowHandle()) {
3587 : : # if defined(MAILJD_KWS_HAS_REQUEST_ACTIVATE)
3588 : : KWindowSystem::requestActivateWindow(w);
3589 : : # else
3590 : 14 : KWindowSystem::activateWindow(w);
3591 : : # endif
3592 : : }
3593 : : #endif
3594 : : // 3. Give focus to a meaningful target when already on the mail view.
3595 [ + - + - : 14 : if (m_mailList && m_tabManager && m_tabManager->isMainView())
+ - + - ]
3596 : 14 : m_mailList->setFocus();
3597 : 14 : }
3598 : :
3599 : 9 : void MainWindow::onNotificationAction(uint id, const QString &action) {
3600 : : const auto target =
3601 [ + - + - ]: 9 : m_notificationUids.value(id, qMakePair(qint64(-1), qint64(-1)));
3602 [ + - ]: 9 : m_notificationUids.remove(id);
3603 : 9 : const qint64 folderId = target.first;
3604 : 9 : const qint64 uid = target.second;
3605 : :
3606 [ + + + - : 18 : if (action == QStringLiteral("open") && uid > 0) {
+ - + - +
+ ]
3607 : : // 67.A1: single reveal path — selection triggers currentRowChanged,
3608 : : // which loads the body (no duplicate onMailSelected here).
3609 [ + - ]: 6 : selectAndRevealMail(folderId, uid);
3610 : : // T-76.A2: raise + platform activation after revealing the mail.
3611 [ + - ]: 6 : bringToFront();
3612 [ + + + - : 6 : } else if (action == QStringLiteral("mark-read") && uid > 0) {
+ - + - +
+ ]
3613 [ + - ]: 1 : m_controller->markMailAsSeen(uid);
3614 : : }
3615 : 9 : }
3616 : :
3617 : : // 67.A1: Resolve the uid through whichever model currently drives the
3618 : : // proxy (flat vs. thread view), select it and scroll it to center.
3619 : : // folderId <= 0 means "the controller's current folder"; search-mode
3620 : : // restores (67.A3) pass the result header's own folderId instead.
3621 : 11 : bool MainWindow::trySelectMailInView(qint64 uid, qint64 folderId) {
3622 [ + + ]: 11 : if (folderId <= 0)
3623 : 9 : folderId = m_controller->currentFolderId();
3624 : 11 : QModelIndex srcIdx;
3625 [ + + ]: 11 : if (m_threadViewActive) {
3626 [ + - ]: 4 : srcIdx = m_mailThreadModel->indexForUid(uid, folderId);
3627 : : } else {
3628 [ + - ]: 7 : int row = m_mailListModel->rowForUid(uid, folderId);
3629 [ + + ]: 7 : if (row >= 0)
3630 [ + - ]: 6 : srcIdx = m_mailListModel->index(row, 0);
3631 : : }
3632 [ + + ]: 11 : if (!srcIdx.isValid())
3633 : 1 : return false;
3634 [ + - ]: 10 : auto idx = m_mailListProxy->mapFromSource(srcIdx);
3635 [ - + ]: 10 : if (!idx.isValid())
3636 : 0 : return false;
3637 [ + - + - ]: 10 : m_mailList->selectionModel()->setCurrentIndex(
3638 : : idx, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);
3639 [ + - ]: 10 : m_mailList->scrollTo(idx, QAbstractItemView::PositionAtCenter);
3640 : 10 : return true;
3641 : : }
3642 : :
3643 : 7 : void MainWindow::selectAndRevealMail(qint64 folderId, qint64 uid) {
3644 [ + - - + ]: 7 : if (uid <= 0 || !m_controller)
3645 : 0 : return;
3646 : :
3647 : : // T-76.A2: ensure the main mail view is visible — calendar/task tabs keep
3648 : : // the mail list hidden otherwise, so a notification "Open" from the calendar
3649 : : // tab would select the mail on an invisible tab. switchToMainView() only
3650 : : // flips the tab, so it is safe to call before the folder/selection logic.
3651 [ + - + + : 7 : if (m_tabManager && !m_tabManager->isMainView())
+ + ]
3652 : 3 : m_tabManager->switchToMainView();
3653 : :
3654 [ + + + + : 7 : if (folderId > 0 && folderId != m_controller->currentFolderId()) {
+ + ]
3655 : : // Mail lives in another folder — switch first, reveal once its
3656 : : // headers are in the model. The folder switch loads cached headers
3657 : : // synchronously (modelReset → restoreSessionMail consumes the
3658 : : // pending uid); headers still streaming from IMAP are picked up by
3659 : : // the next modelReset.
3660 [ + - + - ]: 1 : const QString path = m_cache ? m_cache->folderPath(folderId) : QString();
3661 [ - + ]: 1 : if (path.isEmpty()) {
3662 [ # # # # : 0 : qCWarning(lcMainWindow)
# # ]
3663 [ # # # # ]: 0 : << "selectAndRevealMail: unknown folderId" << folderId;
3664 : 0 : return;
3665 : : }
3666 : 1 : m_pendingRestoreUid = uid;
3667 [ + - ]: 1 : if (m_folderTree)
3668 [ + - ]: 1 : m_folderTree->selectFolder(path); // emits folderSelected if item exists
3669 [ + - ]: 1 : if (m_controller->currentFolderId() != folderId)
3670 [ + - ]: 1 : m_controller->onFolderSelected(path); // tree has no item for the path
3671 : 1 : return;
3672 : 1 : }
3673 : :
3674 [ + + ]: 6 : if (!trySelectMailInView(uid))
3675 : 1 : m_pendingRestoreUid = uid; // not in model yet — retry on next reset
3676 : : }
3677 : :
3678 : 7 : void MainWindow::openReply(qint64 uid, bool replyAll) {
3679 [ + - ]: 7 : int row = m_mailListModel->rowForUid(uid, m_controller->currentFolderId());
3680 [ + + ]: 7 : if (row < 0)
3681 : 4 : return;
3682 [ + - ]: 3 : auto *header = m_mailListModel->headerAt(row);
3683 [ - + ]: 3 : if (!header)
3684 : 0 : return;
3685 : :
3686 [ + - + - : 3 : auto *compose = new ComposeWindow(this);
- + - - ]
3687 : :
3688 [ + - ]: 3 : configureComposeWindow(compose);
3689 : :
3690 : : // To: original sender
3691 [ + - ]: 3 : compose->setTo(header->from);
3692 : :
3693 [ + + ]: 3 : if (replyAll) {
3694 : : // CC: all other recipients (minus self)
3695 [ + - ]: 1 : QString myEmail = m_hasPrimaryAccount ? m_primaryAccount.email : QString();
3696 : 1 : QStringList cc;
3697 [ + - + - : 3 : for (const auto &addr : header->to.split(',')) {
+ - + + ]
3698 [ + - ]: 2 : QString trimmed = addr.trimmed();
3699 [ + - + - : 2 : if (!trimmed.isEmpty() && !trimmed.contains(myEmail, Qt::CaseInsensitive)) {
+ - + - ]
3700 [ + - ]: 2 : cc.append(trimmed);
3701 : : }
3702 : 3 : }
3703 [ + - ]: 1 : if (!cc.isEmpty()) {
3704 [ + - + - ]: 2 : compose->setCc(cc.join(QStringLiteral(", ")));
3705 : : }
3706 : 1 : }
3707 : :
3708 : : // Subject: Re: prefix
3709 : 3 : QString subject = header->subject;
3710 [ + - + - ]: 3 : if (!subject.startsWith(QLatin1String("Re:"), Qt::CaseInsensitive)) {
3711 [ + - ]: 3 : subject = QStringLiteral("Re: ") + subject;
3712 : : }
3713 [ + - ]: 3 : compose->setSubject(subject);
3714 : :
3715 [ + - ]: 3 : const QString inReplyTo = messageIdHeaderValue(header->messageId);
3716 [ + + ]: 3 : if (!inReplyTo.isEmpty())
3717 : 2 : compose->setInReplyTo(inReplyTo);
3718 [ + - ]: 3 : const QStringList references = replyReferencesForHeader(*header);
3719 [ + + ]: 3 : if (!references.isEmpty())
3720 : 2 : compose->setReferences(references);
3721 : :
3722 : : // Body: quoted original
3723 : : // T-407: Use header's folderId for correct body lookup in search mode
3724 : 3 : qint64 bodyFolderId = header->folderId;
3725 [ + + ]: 3 : if (bodyFolderId <= 0)
3726 : 2 : bodyFolderId = m_controller->currentFolderId();
3727 [ + - ]: 3 : auto cachedBody = m_cache->body(bodyFolderId, header->uid);
3728 [ + + ]: 3 : if (cachedBody) {
3729 [ + - ]: 1 : QString quotedBody = tr("\n\n--- Original Message ---\n");
3730 [ + - + - : 1 : quotedBody += tr("From: ") + header->from + QStringLiteral("\n");
+ - + - ]
3731 [ + - + - : 1 : quotedBody += tr("Date: ") + header->date.toString(Qt::ISODate) + QStringLiteral("\n\n");
+ - + - +
- ]
3732 : :
3733 : 1 : QString originalText = cachedBody->textPlain;
3734 [ + - + - : 2 : for (const auto &line : originalText.split('\n')) {
+ - + + ]
3735 [ + - + - : 2 : quotedBody += QStringLiteral("> ") + line + QStringLiteral("\n");
+ - ]
3736 : 1 : }
3737 [ + - ]: 1 : compose->setBody(quotedBody);
3738 : 1 : }
3739 : :
3740 [ + - ]: 3 : setupComposeTracking(compose);
3741 [ + - ]: 3 : compose->setAttribute(Qt::WA_DeleteOnClose);
3742 [ + - ]: 3 : compose->show();
3743 : 3 : }
3744 : :
3745 : 4 : void MainWindow::openForward(qint64 uid) {
3746 [ + - ]: 4 : int row = m_mailListModel->rowForUid(uid, m_controller->currentFolderId());
3747 [ + + ]: 4 : if (row < 0)
3748 : 2 : return;
3749 [ + - ]: 2 : auto *header = m_mailListModel->headerAt(row);
3750 [ - + ]: 2 : if (!header)
3751 : 0 : return;
3752 : :
3753 [ + - + - : 2 : auto *compose = new ComposeWindow(this);
- + - - ]
3754 : :
3755 [ + - ]: 2 : configureComposeWindow(compose);
3756 : :
3757 : : // Subject: Fwd: prefix
3758 : 2 : QString subject = header->subject;
3759 [ + - + - ]: 2 : if (!subject.startsWith(QLatin1String("Fwd:"), Qt::CaseInsensitive)) {
3760 [ + - ]: 2 : subject = QStringLiteral("Fwd: ") + subject;
3761 : : }
3762 [ + - ]: 2 : compose->setSubject(subject);
3763 : :
3764 : : // Body: forwarded message
3765 : : // T-407: Use header's folderId for correct body lookup in search mode
3766 : 2 : qint64 bodyFolderId = header->folderId;
3767 [ + + ]: 2 : if (bodyFolderId <= 0)
3768 : 1 : bodyFolderId = m_controller->currentFolderId();
3769 [ + - ]: 2 : auto cachedBody = m_cache->body(bodyFolderId, header->uid);
3770 [ + + ]: 2 : if (cachedBody) {
3771 [ + - ]: 1 : QString fwdBody = tr("\n\n---------- Forwarded Message ----------\n");
3772 [ + - + - : 2 : fwdBody += QStringLiteral("Von: ") + header->from + QStringLiteral("\n");
+ - ]
3773 [ + - + - : 1 : fwdBody += tr("To: ") + header->to + QStringLiteral("\n");
+ - + - ]
3774 [ + - + - : 2 : fwdBody += QStringLiteral("Datum: ") + header->date.toString(Qt::ISODate) + QStringLiteral("\n");
+ - + - ]
3775 [ + - + - : 1 : fwdBody += tr("Subject: ") + header->subject + QStringLiteral("\n\n");
+ - + - ]
3776 [ + - ]: 1 : fwdBody += cachedBody->textPlain;
3777 [ + - ]: 1 : compose->setBody(fwdBody);
3778 : 1 : }
3779 : :
3780 : 2 : int restoredAttachments = 0;
3781 : 2 : int failedAttachments = 0;
3782 [ + - ]: 2 : const auto attachments = m_cache->attachments(bodyFolderId, header->uid);
3783 [ - + ]: 2 : for (const auto &attachment : attachments) {
3784 [ # # ]: 0 : const QByteArray data = m_cache->attachmentData(attachment.id);
3785 [ # # # # : 0 : if (data.isEmpty() && attachment.size > 0) {
# # ]
3786 : 0 : ++failedAttachments;
3787 : 0 : continue;
3788 : : }
3789 [ # # ]: 0 : if (compose->addAttachmentData(attachment.filename, data,
3790 [ # # # # ]: 0 : attachment.contentType.toUtf8())) {
3791 : 0 : ++restoredAttachments;
3792 : : } else {
3793 : 0 : ++failedAttachments;
3794 : : }
3795 [ # # ]: 0 : }
3796 [ - + ]: 2 : if (failedAttachments > 0) {
3797 [ # # ]: 0 : setStatus(QStringLiteral("compose"),
3798 [ # # ]: 0 : tr("%1 attachment(s) could not be restored")
3799 [ # # ]: 0 : .arg(failedAttachments),
3800 : : 6000);
3801 [ - + ]: 2 : } else if (restoredAttachments > 0) {
3802 [ # # ]: 0 : setStatus(QStringLiteral("compose"),
3803 [ # # ]: 0 : tr("%1 attachment(s) attached to the forward")
3804 [ # # ]: 0 : .arg(restoredAttachments),
3805 : : 4000);
3806 : : }
3807 : :
3808 [ + - ]: 2 : setupComposeTracking(compose);
3809 [ + - ]: 2 : compose->setAttribute(Qt::WA_DeleteOnClose);
3810 [ + - ]: 2 : compose->show();
3811 : 2 : }
3812 : :
3813 : 21 : void MainWindow::configureComposeWindow(ComposeWindow *compose) const {
3814 [ + - + + ]: 21 : if (!compose || !m_hasPrimaryAccount)
3815 : 10 : return;
3816 : :
3817 : 11 : compose->setFrom(m_primaryAccount.email);
3818 : 11 : compose->setSmtpConfig(m_primaryAccount.smtp);
3819 : :
3820 [ - + ]: 11 : if (m_primaryAccount.smtp.password.isEmpty()) {
3821 [ # # # # : 0 : qCWarning(lcMainWindow)
# # ]
3822 [ # # ]: 0 : << "SMTP password empty for compose account"
3823 [ # # ]: 0 : << m_primaryAccount.name
3824 [ # # ]: 0 : << "- check keyring or re-enter password in settings";
3825 : : }
3826 : : }
3827 : :
3828 : 21 : void MainWindow::setupComposeTracking(ComposeWindow *compose) {
3829 [ + - ]: 21 : if (m_contactStore) {
3830 : 21 : compose->setContactStore(m_contactStore);
3831 : 21 : connect(compose, &ComposeWindow::recipientsSent, this,
3832 [ + - ]: 42 : [this](const QStringList &to, const QStringList &cc,
3833 : : const QStringList &bcc) {
3834 : : static QRegularExpression emailRx(
3835 [ + + + - : 3 : QStringLiteral(R"(<([^>]+)>)"));
+ - - - ]
3836 [ + - + - : 5 : for (const auto &r : to + cc + bcc) {
+ - + - +
+ ]
3837 [ + - ]: 3 : auto m = emailRx.match(r);
3838 : : QString email =
3839 [ + - + + : 3 : m.hasMatch() ? m.captured(1) : r.trimmed();
+ - + - ]
3840 : : QString name =
3841 [ + - ]: 3 : m.hasMatch()
3842 [ + - + - : 4 : ? r.left(m.capturedStart()).trimmed()
+ + - - ]
3843 [ + + + - ]: 4 : : QString();
3844 [ + - ]: 3 : m_contactStore->recordUsage(email, name);
3845 : 5 : }
3846 : 2 : });
3847 : : }
3848 : :
3849 : : // T-177: Configure drafts folder and start auto-save
3850 : 21 : compose->setDraftsFolder(m_draftsFolder);
3851 [ + + + - ]: 21 : if (!m_draftsFolder.isEmpty() && compose->draftUid() < 0) {
3852 : : // Start auto-save timer (only for new compositions, not when loading draft)
3853 : : }
3854 : :
3855 : : // T-177: Draft save handling
3856 : 21 : connect(compose, &ComposeWindow::draftSaveRequested, this,
3857 [ + - ]: 21 : [this, compose](const QByteArray &msg) {
3858 [ + - ]: 2 : QPointer<ComposeWindow> composeGuard(compose);
3859 : 2 : qint64 oldUid = compose->draftUid();
3860 : :
3861 : : // 1. Append new draft
3862 [ + - + - ]: 2 : m_imapService->executeAfterIdle([this, msg]() {
3863 [ + - ]: 4 : m_imapService->appendMessage(m_draftsFolder, msg,
3864 : 4 : QStringLiteral("\\Seen \\Draft"));
3865 : 2 : });
3866 : :
3867 : : // 2. Wait for APPENDUID → track new draft UID
3868 [ + - ]: 2 : auto conn = std::make_shared<QMetaObject::Connection>();
3869 [ + - ]: 2 : auto errConn = std::make_shared<QMetaObject::Connection>();
3870 : 2 : const QString expectedFolder = m_draftsFolder;
3871 : 4 : *conn = connect(m_imapService, &ImapService::messageAppended,
3872 [ + - - - : 4 : this, [this, composeGuard, oldUid, conn, errConn,
- - - - ]
3873 : : expectedFolder](const QString &folder, qint64 newUid) {
3874 [ - + ]: 2 : if (folder != expectedFolder)
3875 : 0 : return;
3876 : 2 : QObject::disconnect(*conn);
3877 : 2 : QObject::disconnect(*errConn);
3878 [ + - ]: 2 : if (composeGuard) {
3879 [ + - ]: 2 : if (newUid > 0) {
3880 : 2 : composeGuard->setDraftUid(newUid);
3881 : : }
3882 : 2 : composeGuard->markDraftSaved();
3883 : : }
3884 : : // 3. Delete old draft (if exists)
3885 [ + + + - ]: 2 : if (oldUid > 0 && newUid > 0) {
3886 : 1 : deleteOldDraft(oldUid);
3887 : : }
3888 [ + - ]: 2 : setStatus(QStringLiteral("draft"),
3889 [ + - ]: 4 : tr("Draft saved"), 3000);
3890 : 2 : });
3891 : :
3892 : : // Error handling
3893 : 4 : *errConn = connect(m_imapService, &ImapService::appendError,
3894 [ + - - - : 4 : this, [this, composeGuard, conn, errConn](const QString &error) {
- - ]
3895 : 0 : QObject::disconnect(*conn);
3896 : 0 : QObject::disconnect(*errConn);
3897 [ # # ]: 0 : if (composeGuard) {
3898 : 0 : composeGuard->markDraftSaveFailed(error);
3899 : : }
3900 [ # # ]: 0 : setStatus(QStringLiteral("draft"),
3901 [ # # # # ]: 0 : tr("Could not save draft: ") + error,
3902 : : 5000);
3903 : 2 : });
3904 : 2 : });
3905 : :
3906 : : // T-177: Draft discard handling (when user clicks "Verwerfen" in close dialog)
3907 : 21 : connect(compose, &ComposeWindow::draftDiscarded, this,
3908 [ + - ]: 21 : [this](qint64 draftUid) {
3909 : 1 : deleteOldDraft(draftUid);
3910 : 1 : });
3911 : :
3912 : : // T-178: Copy sent message to Sent folder via IMAP APPEND
3913 : 21 : connect(compose, &ComposeWindow::messageSent, this,
3914 [ + - ]: 21 : [this, compose]() {
3915 : 4 : QByteArray msg = compose->lastBuiltMessage();
3916 [ + + ]: 4 : if (msg.isEmpty())
3917 : 1 : return;
3918 : :
3919 [ - + ]: 3 : if (m_sentFolder.isEmpty()) {
3920 [ # # # # : 0 : qCWarning(lcMainWindow) << "No Sent folder detected, skipping copy";
# # # # ]
3921 : 0 : return;
3922 : : }
3923 : :
3924 [ + - + - : 6 : qCInfo(lcMainWindow) << "T-178: Saving sent copy to" << m_sentFolder
+ - + - +
+ ]
3925 [ + - + - : 3 : << "(" << msg.size() << "bytes)";
+ - ]
3926 [ + - + - ]: 3 : m_imapService->executeAfterIdle([this, msg]() {
3927 [ + - ]: 6 : m_imapService->appendMessage(m_sentFolder, msg,
3928 : 6 : QStringLiteral("\\Seen"));
3929 : 3 : });
3930 : :
3931 : : // T-178: Error/success feedback via one-shot connections
3932 [ + - ]: 3 : auto sentErrConn = std::make_shared<QMetaObject::Connection>();
3933 [ + - ]: 3 : auto sentOkConn = std::make_shared<QMetaObject::Connection>();
3934 : 3 : const QString expectedFolder = m_sentFolder;
3935 : 6 : *sentErrConn = connect(m_imapService, &ImapService::appendError,
3936 [ + - - - ]: 6 : this, [this, sentErrConn, sentOkConn](const QString &error) {
3937 : 2 : QObject::disconnect(*sentOkConn);
3938 : 2 : QObject::disconnect(*sentErrConn);
3939 [ + - ]: 2 : setStatus(QStringLiteral("sent"),
3940 [ + - + - ]: 4 : tr("⚠ Sent copy failed: ") + error,
3941 : : 8000);
3942 : 5 : });
3943 : :
3944 : 6 : *sentOkConn = connect(m_imapService, &ImapService::messageAppended,
3945 [ + - - - : 6 : this, [this, sentOkConn, sentErrConn,
- - ]
3946 : : expectedFolder](const QString &folder, qint64) {
3947 [ - + ]: 1 : if (folder != expectedFolder)
3948 : 0 : return;
3949 : 1 : QObject::disconnect(*sentOkConn);
3950 : 1 : QObject::disconnect(*sentErrConn);
3951 [ + - ]: 1 : setStatus(QStringLiteral("sent"),
3952 [ + - ]: 2 : tr("✓ Saved to Sent folder"), 3000);
3953 : 3 : });
3954 : :
3955 : : // T-177: Delete draft after successful send
3956 : 3 : qint64 draftUid = compose->draftUid();
3957 [ - + ]: 3 : if (draftUid > 0) {
3958 [ # # ]: 0 : deleteOldDraft(draftUid);
3959 : 0 : compose->setDraftUid(-1);
3960 : : }
3961 [ + + ]: 4 : });
3962 : 21 : }
3963 : :
3964 : 7 : QString MainWindow::detectSpecialFolder(const QString &kind) const {
3965 : : // Match common IMAP folder names for the given kind (Sent, Drafts, Trash, etc.)
3966 : : // Try exact match first, then case-insensitive, then prefixed variants
3967 [ + + ]: 19 : for (const QString &path : m_allFolderPaths) {
3968 [ + + ]: 16 : if (path.compare(kind, Qt::CaseInsensitive) == 0)
3969 : 4 : return path;
3970 : : }
3971 : : // Try "INBOX.Sent" or "INBOX/Sent" patterns
3972 [ + + ]: 9 : for (const QString &path : m_allFolderPaths) {
3973 [ + - + - : 18 : if (path.endsWith(QLatin1Char('.') + kind, Qt::CaseInsensitive) ||
+ - - + -
- ]
3974 [ + - + - : 12 : path.endsWith(QLatin1Char('/') + kind, Qt::CaseInsensitive))
- + + - +
- - - ]
3975 : 0 : return path;
3976 : : }
3977 : : // Try localized names (e.g. "Sent Messages", "Gesendete Objekte")
3978 [ + + ]: 3 : if (kind == QStringLiteral("Sent")) {
3979 [ + - ]: 1 : for (const QString &path : m_allFolderPaths) {
3980 [ + - + - ]: 2 : QString base = path.section(QLatin1Char('.'), -1).section(QLatin1Char('/'), -1);
3981 [ - - + - : 2 : if (base.compare(QStringLiteral("Sent Messages"), Qt::CaseInsensitive) == 0 ||
+ - ]
3982 [ - + - - : 3 : base.compare(QStringLiteral("Sent Items"), Qt::CaseInsensitive) == 0 ||
- + + - ]
3983 [ - + - + : 1 : base.compare(QStringLiteral("Gesendete Objekte"), Qt::CaseInsensitive) == 0)
- + ]
3984 : 1 : return path;
3985 [ - + ]: 1 : }
3986 : : }
3987 [ + - ]: 2 : if (kind == QStringLiteral("Drafts")) {
3988 [ + + ]: 5 : for (const QString &path : m_allFolderPaths) {
3989 [ + - + - ]: 8 : QString base = path.section(QLatin1Char('.'), -1).section(QLatin1Char('/'), -1);
3990 [ + + ]: 4 : if (base.compare(QStringLiteral("Entwürfe"), Qt::CaseInsensitive) == 0)
3991 : 1 : return path;
3992 [ + + ]: 4 : }
3993 : : }
3994 : 1 : return {};
3995 : : }
3996 : :
3997 : : // ═══════════════════════════════════════════════════════
3998 : : // T-177: Draft deletion and opening
3999 : : // ═══════════════════════════════════════════════════════
4000 : :
4001 : 5 : void MainWindow::deleteOldDraft(qint64 draftUid) {
4002 [ + + + + : 5 : if (draftUid <= 0 || m_draftsFolder.isEmpty()) return;
+ + ]
4003 : :
4004 [ + - + - : 6 : qCInfo(lcMainWindow) << "T-177: Deleting old draft UID" << draftUid;
+ - + - +
+ ]
4005 : :
4006 [ + - ]: 3 : m_imapService->executeAfterIdle([this, draftUid]() {
4007 : 3 : QString currentFolder = m_imapService->selectedFolder();
4008 : 3 : bool needReselect = (currentFolder != m_draftsFolder);
4009 : :
4010 : 1 : auto reselectOriginal = [this, currentFolder, needReselect]() {
4011 [ + - - + : 1 : if (needReselect && !currentFolder.isEmpty())
- + ]
4012 : 0 : m_imapService->selectFolder(currentFolder);
4013 : 3 : };
4014 : :
4015 [ + - ]: 3 : auto doExpunge = std::make_shared<std::function<void()>>();
4016 : 6 : *doExpunge = [this, reselectOriginal]() {
4017 [ + - ]: 1 : auto expungeConn = std::make_shared<QMetaObject::Connection>();
4018 : 2 : *expungeConn = connect(
4019 : 1 : m_imapService, &ImapService::expungeComplete, this,
4020 [ + - - - ]: 2 : [reselectOriginal, expungeConn]() {
4021 : 1 : QObject::disconnect(*expungeConn);
4022 : 1 : reselectOriginal();
4023 : 1 : });
4024 [ + - ]: 1 : m_imapService->expunge();
4025 [ + - ]: 4 : };
4026 : :
4027 [ + - ]: 3 : auto doStoreDelete = std::make_shared<std::function<void()>>();
4028 : 6 : *doStoreDelete = [this, draftUid, doExpunge]() {
4029 [ + - ]: 3 : auto storeConn = std::make_shared<QMetaObject::Connection>();
4030 : 6 : *storeConn = connect(
4031 : 3 : m_imapService, &ImapService::storeComplete, this,
4032 [ + - - - ]: 6 : [doExpunge, storeConn]() {
4033 : 1 : QObject::disconnect(*storeConn);
4034 : 1 : (*doExpunge)();
4035 : 3 : });
4036 [ + - ]: 6 : m_imapService->storeFlag(draftUid, QStringLiteral("\\Deleted"), true);
4037 [ + - ]: 6 : };
4038 : :
4039 [ + + ]: 3 : if (needReselect) {
4040 [ + - ]: 2 : auto conn = std::make_shared<QMetaObject::Connection>();
4041 : 4 : *conn = connect(m_imapService, &ImapService::folderSelected,
4042 [ + - - - ]: 4 : this, [this, doStoreDelete, conn](const QString &folder, int,
4043 : : quint32, quint64) {
4044 [ - + ]: 2 : if (folder != m_draftsFolder)
4045 : 0 : return;
4046 : 2 : QObject::disconnect(*conn);
4047 : 2 : (*doStoreDelete)();
4048 : 2 : });
4049 [ + - ]: 2 : m_imapService->selectFolder(m_draftsFolder);
4050 : 2 : } else {
4051 [ + - ]: 1 : (*doStoreDelete)();
4052 : : }
4053 : 3 : });
4054 : : }
4055 : :
4056 : 4 : void MainWindow::openDraftInCompose(qint64 uid, const MailHeader &header) {
4057 : : const qint64 draftFolderId =
4058 [ + + ]: 4 : header.folderId > 0 ? header.folderId : m_controller->currentFolderId();
4059 [ + - ]: 4 : auto cachedBody = m_cache->body(draftFolderId, uid);
4060 [ + + ]: 4 : if (!cachedBody) {
4061 [ + - ]: 2 : setStatus(QStringLiteral("draft"),
4062 : 4 : QStringLiteral("Draft-Body wird geladen…"), 3000);
4063 : : // Request body fetch, then retry after a short delay
4064 [ + - ]: 2 : m_controller->onMailSelected(uid);
4065 [ + - - - ]: 2 : QTimer::singleShot(500, this, [this, uid, header, draftFolderId]() {
4066 [ + - ]: 2 : auto body = m_cache->body(draftFolderId, uid);
4067 [ + + ]: 2 : if (body) {
4068 [ + - ]: 1 : openDraftInCompose(uid, header);
4069 : : } else {
4070 [ + - ]: 2 : setStatus(QStringLiteral("draft"),
4071 : 2 : QStringLiteral("Draft-Body konnte nicht geladen werden"), 3000);
4072 : : }
4073 : 2 : });
4074 : 2 : return;
4075 : : }
4076 : :
4077 [ + - + - : 2 : auto *compose = new ComposeWindow(this);
- + - - ]
4078 : :
4079 [ + - ]: 2 : configureComposeWindow(compose);
4080 : :
4081 : : // Pre-fill header fields from the draft
4082 [ + - ]: 2 : compose->setTo(header.to);
4083 [ + - ]: 2 : compose->setSubject(header.subject);
4084 [ + - ]: 2 : compose->setBody(cachedBody->textPlain);
4085 : 2 : compose->setDraftUid(uid);
4086 : :
4087 : : // CC/BCC from raw source (not stored in MailHeader)
4088 [ + + ]: 2 : if (!cachedBody->rawSource.isEmpty()) {
4089 [ + - ]: 1 : QString raw = QString::fromUtf8(cachedBody->rawSource);
4090 : : static QRegularExpression ccRx(
4091 [ + - + - : 1 : R"(^Cc:\s*(.+)$)", QRegularExpression::MultilineOption | QRegularExpression::CaseInsensitiveOption);
+ - + - -
- ]
4092 : : static QRegularExpression bccRx(
4093 [ + - + - : 1 : R"(^Bcc:\s*(.+)$)", QRegularExpression::MultilineOption | QRegularExpression::CaseInsensitiveOption);
+ - + - -
- ]
4094 [ + - ]: 1 : auto ccMatch = ccRx.match(raw);
4095 [ + - + - : 1 : if (ccMatch.hasMatch()) compose->setCc(ccMatch.captured(1).trimmed());
+ - + - +
- ]
4096 [ + - ]: 1 : auto bccMatch = bccRx.match(raw);
4097 [ + - + - : 1 : if (bccMatch.hasMatch()) compose->setBcc(bccMatch.captured(1).trimmed());
+ - + - +
- ]
4098 : 1 : }
4099 : :
4100 : 2 : int restoredAttachments = 0;
4101 : 2 : int failedAttachments = 0;
4102 [ + - ]: 2 : const auto attachments = m_cache->attachments(draftFolderId, uid);
4103 [ + + ]: 4 : for (const auto &attachment : attachments) {
4104 [ + - ]: 2 : const QByteArray data = m_cache->attachmentData(attachment.id);
4105 [ - + - - : 2 : if (data.isEmpty() && attachment.size > 0) {
- + ]
4106 : 0 : ++failedAttachments;
4107 : 0 : continue;
4108 : : }
4109 [ + - ]: 2 : if (compose->addAttachmentData(attachment.filename, data,
4110 [ + - + - ]: 4 : attachment.contentType.toUtf8())) {
4111 : 2 : ++restoredAttachments;
4112 : : } else {
4113 : 0 : ++failedAttachments;
4114 : : }
4115 [ + - ]: 2 : }
4116 [ - + ]: 2 : if (failedAttachments > 0) {
4117 [ # # ]: 0 : setStatus(QStringLiteral("draft"),
4118 [ # # ]: 0 : tr("%1 draft attachment(s) could not be restored")
4119 [ # # ]: 0 : .arg(failedAttachments),
4120 : : 6000);
4121 [ + - ]: 2 : } else if (restoredAttachments > 0) {
4122 [ + - ]: 2 : setStatus(QStringLiteral("draft"),
4123 [ + - ]: 2 : tr("%1 draft attachment(s) restored")
4124 [ + - ]: 4 : .arg(restoredAttachments),
4125 : : 4000);
4126 : : }
4127 [ + - ]: 2 : compose->markDraftSaved();
4128 : :
4129 : : // Restore threading fields
4130 [ - + ]: 2 : if (!header.inReplyTo.isEmpty()) compose->setInReplyTo(header.inReplyTo);
4131 [ - + ]: 2 : if (!header.references.isEmpty()) compose->setReferences(header.references);
4132 : :
4133 [ + - ]: 2 : setupComposeTracking(compose);
4134 [ + - ]: 2 : compose->setAttribute(Qt::WA_DeleteOnClose);
4135 [ + - ]: 2 : compose->show();
4136 : :
4137 [ + - + - : 4 : qCInfo(lcMainWindow) << "T-177: Opened draft UID" << uid << "in ComposeWindow";
+ - + - +
- + + ]
4138 [ + + ]: 4 : }
4139 : 3 : void MainWindow::showContactManager() {
4140 [ + - - + : 3 : auto *dialog = new ContactManagerDialog(m_contactStore, this);
- - ]
4141 : 3 : connect(dialog, &ContactManagerDialog::composeToContact, this,
4142 [ + - ]: 3 : [this](const QString &email, const QString &displayName) {
4143 [ + - + - : 2 : auto *compose = new ComposeWindow(this);
- + - - ]
4144 [ + - ]: 2 : configureComposeWindow(compose);
4145 [ + - ]: 2 : setupComposeTracking(compose);
4146 : 2 : QString to = displayName.isEmpty()
4147 [ + + + - : 3 : ? email : QStringLiteral("%1 <%2>").arg(displayName, email);
+ + + + -
- - - ]
4148 [ + - ]: 2 : compose->setTo(to);
4149 [ + - ]: 2 : compose->setAttribute(Qt::WA_DeleteOnClose);
4150 [ + - ]: 2 : compose->show();
4151 : 2 : });
4152 : 3 : dialog->setAttribute(Qt::WA_DeleteOnClose);
4153 : 3 : dialog->show();
4154 : 3 : }
4155 : :
4156 : : // ═══════════════════════════════════════════════════════
4157 : : // Thread View Toggle (T-099)
4158 : : // ═══════════════════════════════════════════════════════
4159 : :
4160 : 33 : void MainWindow::toggleThreadView(bool threaded) {
4161 : 33 : m_threadViewActive = threaded;
4162 [ + - ]: 66 : m_settings.setValue("view/threadView", threaded); // T-127
4163 : :
4164 [ + + ]: 33 : if (threaded) {
4165 : : // Populate thread model from flat model's current data
4166 : 27 : m_mailThreadModel->setHeaders(m_mailListModel->allHeaders());
4167 : :
4168 : : // Swap source model on proxy
4169 : 27 : m_mailListProxy->setSourceModel(m_mailThreadModel);
4170 : 27 : m_mailListProxy->setSortRole(MailThreadModel::SortRole);
4171 : 27 : m_mailList->setRootIsDecorated(true);
4172 : 27 : m_mailList->setIndentation(28); // T-433: increased for better thread hierarchy
4173 : :
4174 : : // Widen Star column so tree decoration fits at depth ≤ 3
4175 : : // (56px - 3*16px indent = 8px minimum for the ★ glyph)
4176 : 27 : m_mailList->header()->resizeSection(MailListModel::Star, 56);
4177 : 27 : m_mailList->header()->setSectionResizeMode(MailListModel::Star,
4178 : : QHeaderView::Fixed);
4179 : :
4180 : : // Re-connect selection model (proxy creates new one when model changes)
4181 : 27 : reconnectSelectionHandler();
4182 : :
4183 : : // Expand all root threads on first activation;
4184 : : // on subsequent activations, restoreExpandedState will be used by the
4185 : : // modelReset handler.
4186 : 27 : m_threadExpandedInitial = false; // reset for new folder
4187 : 27 : m_mailList->expandAll();
4188 : 27 : m_threadExpandedInitial = true;
4189 : :
4190 [ + - + - : 54 : qCInfo(lcMainWindow) << "Thread view activated:"
+ - + + ]
4191 [ + - + - : 27 : << m_mailThreadModel->rowCount() << "threads";
+ - ]
4192 : : } else {
4193 : : // Restore flat model
4194 : 6 : m_mailListProxy->setSourceModel(m_mailListModel);
4195 : 6 : m_mailListProxy->setSortRole(MailListModel::SortRole);
4196 : 6 : m_mailList->setRootIsDecorated(false);
4197 : 6 : m_mailList->setIndentation(0); // no indentation in flat view
4198 : :
4199 : : // Restore narrow Star column for flat view
4200 : 6 : m_mailList->header()->resizeSection(MailListModel::Star, 24);
4201 : 6 : m_mailList->header()->setSectionResizeMode(MailListModel::Star,
4202 : : QHeaderView::Fixed);
4203 : :
4204 : : // Re-connect selection model
4205 : 6 : reconnectSelectionHandler();
4206 : :
4207 [ + - + - : 12 : qCInfo(lcMainWindow) << "Flat view restored";
+ - + + ]
4208 : : }
4209 : 33 : }
4210 : :
4211 : : // ═══════════════════════════════════════════════════════
4212 : : // Thread Expand/Collapse State Persistence
4213 : : // ═══════════════════════════════════════════════════════
4214 : :
4215 : 26 : void MainWindow::saveExpandedState() {
4216 [ + + - + ]: 26 : if (!m_threadViewActive || !m_mailThreadModel)
4217 : 1 : return;
4218 : :
4219 : 25 : m_expandedThreadUids.clear();
4220 [ + - ]: 25 : int rows = m_mailThreadModel->rowCount();
4221 [ + + ]: 49 : for (int i = 0; i < rows; ++i) {
4222 [ + - ]: 24 : auto idx = m_mailThreadModel->index(i, 0);
4223 : : // Map through proxy to check expanded state
4224 [ + - ]: 24 : auto proxyIdx = m_mailListProxy->mapFromSource(idx);
4225 [ + - + - : 24 : if (proxyIdx.isValid() && m_mailList->isExpanded(proxyIdx)) {
+ + + + ]
4226 [ + - ]: 15 : auto *header = m_mailThreadModel->headerAt(idx);
4227 [ + - ]: 15 : if (header)
4228 [ + - ]: 15 : m_expandedThreadUids.insert(header->uid);
4229 : : }
4230 : : }
4231 : : }
4232 : :
4233 : 26 : void MainWindow::restoreExpandedState() {
4234 [ + + - + ]: 26 : if (!m_threadViewActive || !m_mailThreadModel)
4235 : 1 : return;
4236 : :
4237 [ + - ]: 25 : int rows = m_mailThreadModel->rowCount();
4238 [ + + ]: 68 : for (int i = 0; i < rows; ++i) {
4239 [ + - ]: 43 : auto idx = m_mailThreadModel->index(i, 0);
4240 [ + - ]: 43 : auto proxyIdx = m_mailListProxy->mapFromSource(idx);
4241 [ - + ]: 43 : if (!proxyIdx.isValid())
4242 : 0 : continue;
4243 : :
4244 [ + - ]: 43 : auto *header = m_mailThreadModel->headerAt(idx);
4245 [ + - + + : 43 : if (header && m_expandedThreadUids.contains(header->uid)) {
+ + ]
4246 [ + - ]: 11 : m_mailList->setExpanded(proxyIdx, true);
4247 : : } else {
4248 [ + - ]: 32 : m_mailList->setExpanded(proxyIdx, false);
4249 : : }
4250 : : }
4251 : : }
4252 : :
4253 : : // Search-mode logic (runSearch/exitSearch/pagination/dedup) moved to
4254 : : // SearchCoordinator in Sprint 65 (P2.1).
4255 : :
4256 : 20 : bool MainWindow::isSearchMode() const {
4257 [ + - + + ]: 20 : return m_search && m_search->isSearchMode();
4258 : : }
4259 : :
4260 : 207 : qint64 MainWindow::uidFromViewIndex(const QModelIndex &viewIdx) const {
4261 [ + - ]: 207 : auto srcIdx = m_mailListProxy->mapToSource(viewIdx);
4262 [ + + ]: 207 : if (m_threadViewActive) {
4263 [ + - ]: 42 : auto *header = m_mailThreadModel->headerAt(srcIdx);
4264 [ + - ]: 42 : return header ? header->uid : -1;
4265 : : }
4266 [ + - ]: 165 : auto *header = m_mailListModel->headerAt(srcIdx.row());
4267 [ + - ]: 165 : return header ? header->uid : -1;
4268 : : }
4269 : :
4270 : 90 : void MainWindow::reconnectSelectionHandler() {
4271 : 90 : disconnect(m_selectionConnection);
4272 : 90 : m_selectionConnection = connect(
4273 [ + - ]: 90 : m_mailList->selectionModel(),
4274 : : &QItemSelectionModel::currentRowChanged, this,
4275 [ + - ]: 90 : [this](const QModelIndex ¤t, const QModelIndex &) {
4276 [ + + ]: 86 : if (!current.isValid())
4277 : 3 : return;
4278 [ + - ]: 83 : auto srcIdx = m_mailListProxy->mapToSource(current);
4279 : :
4280 : : // T-197: Use the correct model for header lookup.
4281 : : // When threading is active, proxy source is m_mailThreadModel
4282 : : // (headerAt takes QModelIndex). Otherwise it's m_mailListModel
4283 : : // (headerAt takes int row).
4284 : 83 : const MailHeader *header = nullptr;
4285 [ + + + - ]: 83 : if (m_threadViewActive && m_mailThreadModel) {
4286 [ + - ]: 27 : header = m_mailThreadModel->headerAt(srcIdx);
4287 : : } else {
4288 [ + - ]: 56 : header = m_mailListModel->headerAt(srcIdx.row());
4289 : : }
4290 [ - + ]: 83 : if (!header)
4291 : 0 : return;
4292 : :
4293 [ + + + - : 83 : if (m_search->isSearchMode() && header->folderId != 0) {
+ + ]
4294 : : // Search mode: use header's actual folderId, not controller's
4295 : : // (avoids loading wrong body + marking wrong mail as seen)
4296 [ + - ]: 4 : m_controller->onMailSelectedInFolder(header->uid, header->folderId);
4297 : : } else {
4298 [ + - ]: 79 : m_controller->onMailSelected(header->uid);
4299 : : }
4300 [ + - ]: 83 : updateSuggestion(); // T-168
4301 : 90 : });
4302 : 90 : }
4303 : :
4304 : : // ═══════════════════════════════════════════════════════
4305 : : // T-142: CommandBar command dispatcher
4306 : : // ═══════════════════════════════════════════════════════
4307 : :
4308 : 51 : void MainWindow::executeCommand(const QString &cmd) {
4309 : 10 : auto currentUid = [this]() -> qint64 {
4310 : : // T-216: In a mail tab, use the tab's UID
4311 [ + - + - : 10 : if (m_tabManager && !m_tabManager->isMainView()) {
- + - + ]
4312 [ # # ]: 0 : return m_tabManager->currentTabInfo().mailUid;
4313 : : }
4314 [ + - + - ]: 10 : auto idx = m_mailList->selectionModel()->currentIndex();
4315 [ + + ]: 10 : if (!idx.isValid()) return -1;
4316 [ + - ]: 3 : return uidFromViewIndex(idx);
4317 : 51 : };
4318 : 0 : auto moveSelectedToFolder = [this](const QString &targetFolder,
4319 : : bool markJunk = false) {
4320 [ # # ]: 0 : auto mailIds = getSelectedMailIds();
4321 [ # # ]: 0 : if (mailIds.isEmpty())
4322 : 0 : return;
4323 : :
4324 : 0 : QList<qint64> uids;
4325 [ # # # # : 0 : for (const auto &mail : mailIds) {
# # ]
4326 [ # # ]: 0 : uids.append(mail.uid);
4327 [ # # ]: 0 : if (markJunk) {
4328 [ # # # # : 0 : if (isSearchMode() && mail.hasFolderId())
# # ]
4329 [ # # ]: 0 : m_controller->addLabelInFolder(mail.uid, mail.folderId,
4330 : 0 : QStringLiteral("$Junk"));
4331 : : else
4332 [ # # ]: 0 : m_controller->addLabel(mail.uid, QStringLiteral("$Junk"));
4333 : : }
4334 : : }
4335 : :
4336 [ # # ]: 0 : selectNextAfterMove();
4337 [ # # ]: 0 : if (isSearchMode()) {
4338 : 0 : QMap<qint64, QList<qint64>> byFolder;
4339 : 0 : QMap<qint64, QString> folderPaths;
4340 [ # # # # : 0 : for (const auto &mail : mailIds) {
# # ]
4341 [ # # ]: 0 : if (!mail.hasFolderId())
4342 : 0 : continue;
4343 [ # # # # ]: 0 : byFolder[mail.folderId].append(mail.uid);
4344 [ # # ]: 0 : folderPaths[mail.folderId] = mail.folderPath;
4345 : : }
4346 [ # # # # : 0 : for (auto it = byFolder.constBegin(); it != byFolder.constEnd(); ++it) {
# # ]
4347 [ # # ]: 0 : m_controller->moveMailsToFolderFrom(
4348 [ # # ]: 0 : it.value(), it.key(), folderPaths[it.key()], targetFolder);
4349 : : }
4350 : 0 : } else {
4351 [ # # ]: 0 : m_controller->moveMailsToFolder(uids, targetFolder);
4352 : : }
4353 [ # # ]: 0 : };
4354 : :
4355 : : // Normalize: lowercase, trimmed
4356 [ + - + - ]: 51 : QString c = cmd.trimmed().toLower();
4357 : :
4358 [ + + - + : 51 : if (c == QLatin1String("reply") || c == QLatin1String("r")) {
+ + ]
4359 [ + - ]: 2 : qint64 uid = currentUid();
4360 [ + + + - ]: 2 : if (uid >= 0) openReply(uid, false);
4361 [ + + + + : 49 : } else if (c == QLatin1String("reply-all") || c == QLatin1String("ra")) {
+ + ]
4362 [ + - ]: 2 : qint64 uid = currentUid();
4363 [ + + + - ]: 2 : if (uid >= 0) openReply(uid, true);
4364 [ + + + + : 47 : } else if (c == QLatin1String("forward") || c == QLatin1String("fwd")) {
+ + ]
4365 [ + - ]: 2 : qint64 uid = currentUid();
4366 [ + + + - ]: 2 : if (uid >= 0) openForward(uid);
4367 [ + + - + : 45 : } else if (c == QLatin1String("compose") || c == QLatin1String("new")) {
+ + ]
4368 [ + - + - : 2 : auto *compose = new ComposeWindow(this);
- + - - ]
4369 [ + - ]: 2 : configureComposeWindow(compose);
4370 [ + - ]: 2 : setupComposeTracking(compose);
4371 [ + - ]: 2 : compose->setAttribute(Qt::WA_DeleteOnClose);
4372 [ + - ]: 2 : compose->show();
4373 [ + + ]: 43 : } else if (c == QLatin1String("settings")) {
4374 [ + - ]: 2 : showSettings();
4375 [ + + ]: 41 : } else if (c == QLatin1String("subscriptions")) {
4376 [ + - ]: 1 : showSubscriptionDialog();
4377 [ + - - + : 40 : } else if (c == QLatin1String("quit") || c == QLatin1String("q")) {
- + ]
4378 [ # # ]: 0 : quitApp();
4379 [ + + ]: 40 : } else if (c == QLatin1String("contacts")) {
4380 [ + - ]: 1 : showContactManager();
4381 [ + - + + : 39 : } else if (c == QLatin1String("thread-view") || c == QLatin1String("tv")) {
+ + ]
4382 [ + - ]: 1 : if (m_threadViewAction)
4383 [ + - ]: 1 : m_threadViewAction->toggle();
4384 [ + + - + : 38 : } else if (c == QLatin1String("mark-read") || c == QLatin1String("mr")) {
+ + ]
4385 : : // T-519: Use dedicated setter (idempotent) instead of toggle
4386 [ + - ]: 1 : qint64 uid = currentUid();
4387 [ - + - - ]: 1 : if (uid >= 0) m_controller->markMailAsSeen(uid);
4388 [ + - + + : 37 : } else if (c == QLatin1String("mark-unread") || c == QLatin1String("mu")) {
+ + ]
4389 : : // T-519: Use dedicated setter (idempotent) instead of toggle
4390 [ + - ]: 1 : qint64 uid = currentUid();
4391 [ - + - - ]: 1 : if (uid >= 0) m_controller->markMailAsUnseen(uid);
4392 [ + + ]: 36 : } else if (c == QLatin1String("star")) {
4393 : : // T-519: Idempotent — only add flag, never toggle
4394 [ + - ]: 1 : qint64 uid = currentUid();
4395 [ - + - - ]: 1 : if (uid >= 0) m_controller->setStarred(uid, true);
4396 [ + + ]: 35 : } else if (c == QLatin1String("unstar")) {
4397 : : // T-519: Idempotent — only remove flag, never toggle
4398 [ + - ]: 1 : qint64 uid = currentUid();
4399 [ - + - - ]: 1 : if (uid >= 0) m_controller->setStarred(uid, false);
4400 [ + + ]: 34 : } else if (c == QLatin1String("archive")) {
4401 [ + - ]: 1 : if (m_archiveFolder.isEmpty()) {
4402 [ + - ]: 1 : setStatus(QStringLiteral("Kein Archiv-Ordner gefunden"));
4403 : 1 : return;
4404 : : }
4405 [ # # ]: 0 : moveSelectedToFolder(m_archiveFolder);
4406 [ + - + + : 33 : } else if (c == QLatin1String("delete") || c == QLatin1String("del")) {
+ + ]
4407 [ + - ]: 1 : if (m_trashFolder.isEmpty()) {
4408 [ + - ]: 1 : setStatus(QStringLiteral("Kein Trash-Ordner gefunden"));
4409 : 1 : return;
4410 : : }
4411 [ # # ]: 0 : moveSelectedToFolder(m_trashFolder);
4412 [ + + - + : 32 : } else if (c == QLatin1String("junk") || c == QLatin1String("spam")) {
+ + ]
4413 [ + - ]: 1 : if (m_junkFolder.isEmpty()) {
4414 [ + - ]: 1 : setStatus(QStringLiteral("Kein Junk-Ordner gefunden"));
4415 : 1 : return;
4416 : : }
4417 [ # # ]: 0 : moveSelectedToFolder(m_junkFolder, true);
4418 [ + - + + : 31 : } else if (c == QLatin1String("filter unread") || c == QLatin1String("fu")) {
+ + ]
4419 [ + - ]: 1 : m_mailListProxy->setShowUnreadOnly(!m_mailListProxy->showUnreadOnly());
4420 [ + - ]: 2 : setStatus(m_mailListProxy->showUnreadOnly()
4421 [ + - - - ]: 4 : ? QStringLiteral("Filter: Nur ungelesene")
4422 [ - + + - : 1 : : QStringLiteral("Filter: Alle Mails"));
- - ]
4423 [ + - + + : 30 : } else if (c == QLatin1String("filter starred") || c == QLatin1String("fs")) {
+ + ]
4424 [ + - ]: 1 : m_mailListProxy->setShowStarredOnly(!m_mailListProxy->showStarredOnly());
4425 [ + - ]: 2 : setStatus(m_mailListProxy->showStarredOnly()
4426 [ + - - - ]: 4 : ? QStringLiteral("Filter: Nur markierte")
4427 [ - + + - : 1 : : QStringLiteral("Filter: Alle Mails"));
- - ]
4428 [ + - + + : 29 : } else if (c == QLatin1String("filter clear") || c == QLatin1String("fc")) {
+ + ]
4429 [ + - ]: 1 : m_mailListProxy->setShowUnreadOnly(false);
4430 [ + - ]: 1 : m_mailListProxy->setShowStarredOnly(false);
4431 [ + - ]: 1 : m_mailListProxy->setShowWithAttachments(false);
4432 [ + - ]: 1 : m_mailListProxy->setFilterText({});
4433 [ + - ]: 1 : setStatus(QStringLiteral("Filter zurückgesetzt"));
4434 [ + - + + : 28 : } else if (c == QLatin1String("search-more") || c == QLatin1String("sm")) {
+ + ]
4435 [ + - ]: 1 : m_search->loadMoreLocalResults();
4436 [ + + ]: 27 : } else if (c == QLatin1String("help")) {
4437 [ + - ]: 2 : showShortcutHelp();
4438 [ + - + + ]: 25 : } else if (c.startsWith(QLatin1String("label "))) {
4439 [ + - + - ]: 1 : QString labelName = cmd.mid(6).trimmed();
4440 [ + - ]: 1 : auto mail = currentMailId();
4441 [ - + - - : 1 : if (mail.isValid() && !labelName.isEmpty()) {
- + ]
4442 [ # # # # : 0 : if (isSearchMode() && mail.hasFolderId())
# # ]
4443 [ # # ]: 0 : m_controller->addLabelInFolder(mail.uid, mail.folderId, labelName);
4444 : : else
4445 [ # # ]: 0 : m_controller->addLabel(mail.uid, labelName);
4446 : : }
4447 [ + - + + ]: 25 : } else if (c.startsWith(QLatin1String("unlabel "))) {
4448 [ + - + - ]: 1 : QString labelName = cmd.mid(8).trimmed();
4449 [ + - ]: 1 : auto mail = currentMailId();
4450 [ - + - - : 1 : if (mail.isValid() && !labelName.isEmpty()) {
- + ]
4451 [ # # # # : 0 : if (isSearchMode() && mail.hasFolderId())
# # ]
4452 [ # # ]: 0 : m_controller->removeLabelInFolder(mail.uid, mail.folderId, labelName);
4453 : : else
4454 [ # # ]: 0 : m_controller->removeLabel(mail.uid, labelName);
4455 : : }
4456 [ + - ]: 24 : } else if (c.startsWith(QLatin1String("create "))
4457 [ + + + - : 23 : || c.startsWith(QLatin1String("mkdir "))) {
+ + + + ]
4458 : : // T-291: :create <name> — create subfolder under current folder
4459 [ + - + - ]: 2 : QString name = cmd.mid(cmd.indexOf(' ') + 1).trimmed();
4460 [ + - ]: 2 : if (!name.isEmpty()) {
4461 [ + - ]: 2 : QString parent = m_folderTree->selectedFolderPath();
4462 [ + - ]: 4 : QString delimiter = m_imapDelimiter.isEmpty() ? QStringLiteral(".")
4463 [ + - ]: 4 : : m_imapDelimiter;
4464 [ + - - - : 2 : QString fullPath = parent.isEmpty() ? name : parent + delimiter + name;
- - - + -
- ]
4465 [ + - + - ]: 2 : m_imapService->executeAfterIdle([this, fullPath]() {
4466 : 2 : m_imapService->createFolder(fullPath);
4467 : 2 : });
4468 : 2 : }
4469 [ + - - + : 23 : } else if (c == QLatin1String("delete") || c == QLatin1String("rmdir")) {
- + ]
4470 : : // T-291: :delete — delete current folder (delegates to handler with dialog)
4471 [ # # ]: 0 : auto path = m_folderTree->selectedFolderPath();
4472 [ # # # # : 0 : if (!path.isEmpty() && !m_folderOps->isProtectedFolderPath(path))
# # # # ]
4473 [ # # ]: 0 : m_folderOps->deleteFolder(path);
4474 [ + - + + ]: 21 : } else if (c.startsWith(QLatin1String("rename "))) {
4475 : : // T-291: :rename <newname> — rename current folder
4476 [ + - + - ]: 1 : QString newName = cmd.mid(7).trimmed();
4477 [ + - ]: 1 : if (!newName.isEmpty()) {
4478 [ + - ]: 1 : auto path = m_folderTree->selectedFolderPath();
4479 [ - + - - : 1 : if (!path.isEmpty() && !m_folderOps->isProtectedFolderPath(path)) {
- - - + ]
4480 [ # # ]: 0 : QString delimiter = m_imapDelimiter.isEmpty() ? QStringLiteral(".")
4481 [ # # ]: 0 : : m_imapDelimiter;
4482 [ # # ]: 0 : int lastSep = path.lastIndexOf(delimiter);
4483 [ # # # # ]: 0 : QString parentPath = (lastSep >= 0) ? path.left(lastSep) : QString();
4484 : 0 : QString newPath = parentPath.isEmpty() ? newName
4485 [ # # # # : 0 : : parentPath + delimiter + newName;
# # # # #
# ]
4486 [ # # # # : 0 : m_imapService->executeAfterIdle([this, path, newPath]() {
# # ]
4487 : 0 : m_imapService->renameFolder(path, newPath);
4488 : 0 : });
4489 : 0 : }
4490 : 1 : }
4491 [ + + ]: 21 : } else if (c == QLatin1String("move")) {
4492 : : // T-291: :move — move current folder (opens folder picker)
4493 [ + - ]: 1 : auto path = m_folderTree->selectedFolderPath();
4494 [ - + - - : 1 : if (!path.isEmpty() && !m_folderOps->isProtectedFolderPath(path))
- - - + ]
4495 [ # # ]: 0 : m_folderOps->moveFolder(path);
4496 [ + + + + : 20 : } else if (c == QLatin1String("calendar") || c == QLatin1String("cal")) {
+ + ]
4497 [ + - ]: 2 : openCalendarTab();
4498 [ + + + + : 17 : } else if (c == QLatin1String("tasks") || c == QLatin1String("todo")) {
+ + ]
4499 [ + - ]: 2 : openTaskTab();
4500 [ + + ]: 15 : } else if (c == QLatin1String("today")) {
4501 [ + - ]: 1 : openCalendarTab();
4502 [ + - ]: 1 : if (m_calendarWidget)
4503 [ + - + - ]: 1 : m_calendarWidget->navigateToDate(QDate::currentDate());
4504 [ + + ]: 14 : } else if (c == QLatin1String("week")) {
4505 [ + - ]: 1 : openCalendarTab();
4506 [ + - ]: 1 : if (m_calendarWidget)
4507 [ + - ]: 1 : m_calendarWidget->setViewMode(CalendarWidget::WeekView);
4508 [ + + ]: 13 : } else if (c == QLatin1String("month")) {
4509 [ + - ]: 1 : openCalendarTab();
4510 [ + - ]: 1 : if (m_calendarWidget)
4511 [ + - ]: 1 : m_calendarWidget->setViewMode(CalendarWidget::MonthView);
4512 [ + + ]: 12 : } else if (c == QLatin1String("day")) {
4513 [ + - ]: 1 : openCalendarTab();
4514 [ + - ]: 1 : if (m_calendarWidget)
4515 [ + - ]: 1 : m_calendarWidget->setViewMode(CalendarWidget::DayView);
4516 [ + + ]: 11 : } else if (c == QLatin1String("year")) {
4517 [ + - ]: 1 : openCalendarTab();
4518 [ + - ]: 1 : if (m_calendarWidget)
4519 [ + - ]: 1 : m_calendarWidget->setViewMode(CalendarWidget::YearView);
4520 : : } else {
4521 [ + - ]: 10 : setStatus(QStringLiteral("cmd"),
4522 [ + - ]: 20 : QStringLiteral("Unbekannter Befehl: ") + cmd, 4000);
4523 : : }
4524 [ + + ]: 51 : }
4525 : :
4526 : : // ═══════════════════════════════════════════════════════
4527 : : // T-151: Modal guard
4528 : : // ═══════════════════════════════════════════════════════
4529 : :
4530 : 41 : void MainWindow::setNormalMode(bool active) {
4531 : : // In Filter mode, keep shortcuts enabled so d/a/x/s still work
4532 : : // while browsing filtered results. Only disable for
4533 : : // Command/FolderSwitch/MoveToFolder where text input is modal.
4534 [ + + + + : 41 : if (active && m_commandBar->currentMode() == CommandBar::Filter) {
+ + ]
4535 : 3 : return;
4536 : : }
4537 : : // activeChanged(true) means bar opened → disable shortcuts
4538 : : // activeChanged(false) means bar closed → re-enable shortcuts
4539 : 38 : bool enabled = !active;
4540 [ + - + - : 1482 : for (auto *action : m_normalModeActions) {
+ + ]
4541 [ + - ]: 1444 : action->setEnabled(enabled);
4542 : : }
4543 : : }
4544 : :
4545 : : // ═══════════════════════════════════════════════════════
4546 : : // T-145: Vim navigation helpers
4547 : : // ═══════════════════════════════════════════════════════
4548 : :
4549 : 12 : void MainWindow::moveMailSelection(int delta) {
4550 [ + - ]: 12 : auto *model = m_mailList->model();
4551 [ + - + - : 12 : if (!model || model->rowCount() == 0) return;
- + - + ]
4552 : :
4553 [ + - + - ]: 12 : auto current = m_mailList->selectionModel()->currentIndex();
4554 [ + + ]: 12 : int targetRow = current.isValid() ? current.row() + delta : 0;
4555 [ + - + - ]: 12 : targetRow = qBound(0, targetRow, model->rowCount() - 1);
4556 : :
4557 [ + - ]: 12 : auto idx = model->index(targetRow, 0);
4558 [ + - ]: 12 : m_mailList->setCurrentIndex(idx);
4559 [ + - ]: 12 : m_mailList->scrollTo(idx);
4560 : : }
4561 : :
4562 : 4 : void MainWindow::moveMailSelectionPage(int delta) {
4563 : : // Estimate visible rows from viewport height
4564 [ + - ]: 4 : int rowHeight = m_mailList->sizeHintForRow(0);
4565 [ - + ]: 4 : if (rowHeight <= 0) rowHeight = 24;
4566 [ + - ]: 4 : int pageSize = m_mailList->viewport()->height() / rowHeight;
4567 [ + - ]: 4 : moveMailSelection(delta * qMax(1, pageSize));
4568 : 4 : }
4569 : :
4570 : 4 : void MainWindow::moveMailSelectionToEnd(bool top) {
4571 [ + - ]: 4 : auto *model = m_mailList->model();
4572 [ + - + - : 4 : if (!model || model->rowCount() == 0) return;
- + - + ]
4573 : :
4574 [ + + + - ]: 4 : int targetRow = top ? 0 : model->rowCount() - 1;
4575 [ + - ]: 4 : auto idx = model->index(targetRow, 0);
4576 [ + - ]: 4 : m_mailList->setCurrentIndex(idx);
4577 [ + - ]: 4 : m_mailList->scrollTo(idx);
4578 : : }
4579 : :
4580 : : // ═══════════════════════════════════════════════════════
4581 : : // T-148: Shortcut help overlay
4582 : : // ═══════════════════════════════════════════════════════
4583 : :
4584 : 4 : void MainWindow::showShortcutHelp() {
4585 [ + - ]: 4 : if (m_helpOverlay) {
4586 : 4 : m_helpOverlay->showOverlay();
4587 : : }
4588 : 4 : }
4589 : :
4590 : : // ═══════════════════════════════════════════════════════
4591 : : // T-147: Select next mail after move/delete
4592 : : // Must be called BEFORE moveMailsToFolder() so the current
4593 : : // row is captured before the model removes the entry.
4594 : : // ═══════════════════════════════════════════════════════
4595 : :
4596 : 8 : void MainWindow::selectNextAfterMove() {
4597 : : // T-216: In a mail tab, don't change the background selection
4598 [ + - + - : 8 : if (m_tabManager && !m_tabManager->isMainView())
- + - + ]
4599 : 0 : return;
4600 : :
4601 : : // Capture the row position BEFORE the model removal happens.
4602 [ + - + - ]: 8 : auto idx = m_mailList->selectionModel()->currentIndex();
4603 [ + + ]: 8 : int targetRow = idx.isValid() ? idx.row() : 0;
4604 : :
4605 : : // After removal (synchronous in moveMailsToFolder), schedule
4606 : : // selection of the same row position (now the "next" mail).
4607 [ + - ]: 8 : QTimer::singleShot(50, this, [this, targetRow]() {
4608 [ + - ]: 8 : auto *model = m_mailList->model();
4609 [ + - + - : 8 : if (!model || model->rowCount() == 0)
+ + + + ]
4610 : 1 : return;
4611 [ + - ]: 7 : int row = qMin(targetRow, model->rowCount() - 1);
4612 [ + - ]: 7 : auto newIdx = model->index(row, 0);
4613 [ + - ]: 7 : m_mailList->setCurrentIndex(newIdx);
4614 [ + - ]: 7 : m_mailList->scrollTo(newIdx);
4615 : : });
4616 : : }
4617 : :
4618 : : // ═══════════════════════════════════════════════════════
4619 : : // Space: Jump to next unread mail (in-folder or cross-folder)
4620 : : // ═══════════════════════════════════════════════════════
4621 : :
4622 : 3 : void MainWindow::jumpToNextUnread() {
4623 : : // T-216: In a mail tab, don't change the background selection
4624 [ + - + - : 3 : if (m_tabManager && !m_tabManager->isMainView())
- + - + ]
4625 : 2 : return;
4626 : :
4627 [ + - ]: 3 : auto *model = m_mailList->model();
4628 [ + - + - : 3 : if (!model || model->rowCount() == 0) {
- + - + ]
4629 [ # # ]: 0 : jumpToNextUnreadFolder();
4630 : 0 : return;
4631 : : }
4632 : :
4633 : : // Start scanning from the row after the current selection
4634 : 3 : int currentRow = -1;
4635 [ + - + - ]: 3 : auto idx = m_mailList->selectionModel()->currentIndex();
4636 [ + + ]: 3 : if (idx.isValid())
4637 : 1 : currentRow = idx.row();
4638 : :
4639 [ + - ]: 3 : int rowCount = model->rowCount();
4640 : :
4641 : : // Scan forward from currentRow+1 to end, then wrap from 0 to currentRow
4642 [ + + ]: 8 : for (int i = 1; i <= rowCount; ++i) {
4643 : 7 : int row = (currentRow + i) % rowCount;
4644 [ + - ]: 7 : auto viewIdx = model->index(row, 0);
4645 [ + - ]: 7 : auto srcIdx = m_mailListProxy->mapToSource(viewIdx);
4646 : :
4647 : 7 : const MailHeader *header = nullptr;
4648 [ + - + - ]: 7 : if (m_threadViewActive && m_mailThreadModel) {
4649 [ + - ]: 7 : header = m_mailThreadModel->headerAt(srcIdx);
4650 : : } else {
4651 [ # # ]: 0 : header = m_mailListModel->headerAt(srcIdx.row());
4652 : : }
4653 : :
4654 [ + - + + : 7 : if (header && !header->isSeen()) {
+ + ]
4655 [ + - ]: 2 : m_mailList->setCurrentIndex(viewIdx);
4656 [ + - ]: 2 : m_mailList->scrollTo(viewIdx);
4657 : 2 : return;
4658 : : }
4659 : : }
4660 : :
4661 : : // No unread in this folder → try next folder
4662 [ + - ]: 1 : jumpToNextUnreadFolder();
4663 : : }
4664 : :
4665 : 6 : void MainWindow::jumpToNextUnreadFolder() {
4666 [ + - ]: 6 : const auto accs = AccountConfigLoader::loadAll();
4667 [ - + ]: 6 : if (accs.empty())
4668 : 0 : return;
4669 : :
4670 [ + - ]: 6 : auto badges = m_cache->loadAllBadges(accs.front().name);
4671 [ + - + + ]: 6 : if (badges.isEmpty()) {
4672 [ + - ]: 3 : setStatus(QStringLiteral("info"),
4673 : 6 : QStringLiteral("Keine ungelesenen Mails"), 2000);
4674 : 3 : return;
4675 : : }
4676 : :
4677 : 3 : QString currentFolder = m_controller->currentFolder();
4678 : :
4679 : : // Build ordered list: INBOX first, then alphabetical, skip current folder
4680 : 3 : QStringList candidates;
4681 [ + - + + : 11 : if (badges.contains(QStringLiteral("INBOX")) &&
+ - + + -
- - - ]
4682 [ + + + + : 5 : QStringLiteral("INBOX") != currentFolder) {
+ + + - -
- - - ]
4683 [ + - ]: 1 : candidates.append(QStringLiteral("INBOX"));
4684 : : }
4685 [ + - ]: 3 : QStringList sorted = badges.keys();
4686 [ + - ]: 3 : sorted.sort(Qt::CaseInsensitive);
4687 [ + - + - : 8 : for (const QString &folder : sorted) {
+ + ]
4688 [ + + ]: 5 : if (folder == currentFolder) continue;
4689 [ + + ]: 3 : if (folder == QStringLiteral("INBOX")) continue; // already added
4690 [ + - ]: 2 : candidates.append(folder);
4691 : : }
4692 : :
4693 [ - + ]: 3 : if (candidates.isEmpty()) {
4694 [ # # ]: 0 : setStatus(QStringLiteral("info"),
4695 : 0 : QStringLiteral("Keine ungelesenen Mails in anderen Ordnern"), 2000);
4696 : 0 : return;
4697 : : }
4698 : :
4699 [ + - ]: 3 : QString targetFolder = candidates.first();
4700 [ + - ]: 3 : setStatus(QStringLiteral("info"),
4701 : 6 : QStringLiteral("→ %1 (%2 ungelesen)")
4702 [ + - + - ]: 6 : .arg(ImapResponseParser::decodeMailboxName(targetFolder))
4703 [ + - + - ]: 6 : .arg(badges[targetFolder]),
4704 : : 3000);
4705 : :
4706 : : // Switch to that folder; after headers load, select first unread
4707 [ + - ]: 3 : auto conn = std::make_shared<QMetaObject::Connection>();
4708 [ + - ]: 6 : *conn = connect(m_mailListModel, &QAbstractItemModel::modelReset, this,
4709 : 6 : [this, conn]() {
4710 : 3 : disconnect(*conn);
4711 : : // Defer slightly to let proxy model update
4712 [ + - ]: 3 : QTimer::singleShot(50, this, [this]() {
4713 : 1 : auto *model = m_mailList->model();
4714 [ - + ]: 1 : if (!model) return;
4715 [ + - + - ]: 1 : for (int row = 0; row < model->rowCount(); ++row) {
4716 [ + - ]: 1 : auto viewIdx = model->index(row, 0);
4717 [ + - ]: 1 : auto srcIdx = m_mailListProxy->mapToSource(viewIdx);
4718 [ + - ]: 1 : const MailHeader *h = m_mailListModel->headerAt(srcIdx.row());
4719 [ + - + - : 1 : if (h && !h->isSeen()) {
+ - ]
4720 [ + - ]: 1 : m_mailList->setCurrentIndex(viewIdx);
4721 [ + - ]: 1 : m_mailList->scrollTo(viewIdx);
4722 : 1 : return;
4723 : : }
4724 : : }
4725 : : // Fallback: select first row
4726 [ # # # # ]: 0 : if (model->rowCount() > 0) {
4727 [ # # # # ]: 0 : m_mailList->setCurrentIndex(model->index(0, 0));
4728 : : }
4729 : : });
4730 : 6 : });
4731 : :
4732 [ + - ]: 3 : m_folderTree->selectFolder(targetFolder);
4733 [ + - + - : 9 : }
+ - + + +
+ ]
4734 : :
4735 : : // T-147: detectSpecialFolders — called from folderListReceived
4736 : 3 : void MainWindow::detectSpecialFolders() {
4737 : : // Already handled inline in folderListReceived handler
4738 : 3 : }
4739 : :
4740 : : // T-215: Copy header+body+attachments to target folder cache for tab persistence
4741 : 8 : void MainWindow::copyTabCacheToFolder(const QList<qint64> &uids,
4742 : : const QString &targetFolder) {
4743 [ - + ]: 8 : if (!m_tabManager) return;
4744 : 8 : qint64 srcFid = m_controller->currentFolderId();
4745 : 8 : qint64 tgtFid = m_controller->resolveFolderId(targetFolder);
4746 [ - + ]: 8 : if (tgtFid < 0) return;
4747 : :
4748 [ + + ]: 16 : for (qint64 uid : uids) {
4749 [ + - ]: 8 : int ti = m_tabManager->findTabByUid(uid);
4750 [ + + ]: 8 : if (ti < 0) continue;
4751 : :
4752 [ + - ]: 1 : auto hdr = m_cache->header(srcFid, uid);
4753 [ + - + - : 3 : if (hdr) m_cache->storeHeaders(tgtFid, {*hdr});
+ + - - ]
4754 [ + - ]: 1 : auto body = m_cache->body(srcFid, uid);
4755 [ + - + - ]: 1 : if (body) m_cache->storeBody(tgtFid, uid, *body);
4756 : :
4757 [ + - ]: 1 : m_tabManager->updateTabFolder(ti, tgtFid);
4758 : 1 : }
4759 [ + - - - : 1 : }
- - ]
4760 : :
4761 : : // ═══════════════════════════════════════════════════════
4762 : : // Get UIDs of selected mails (fallback: current index)
4763 : : // ═══════════════════════════════════════════════════════
4764 : :
4765 : 4 : QList<qint64> MainWindow::getSelectedUids() const {
4766 : : // T-216: In a mail tab, return the tab's UID
4767 [ + - + - : 4 : if (m_tabManager && !m_tabManager->isMainView()) {
- + - + ]
4768 [ # # ]: 0 : qint64 uid = m_tabManager->currentTabInfo().mailUid;
4769 [ # # # # ]: 0 : if (uid >= 0) return {uid};
4770 : 0 : return {};
4771 : : }
4772 : :
4773 [ + - + - ]: 4 : auto selected = m_mailList->selectionModel()->selectedRows();
4774 : : // Fallback: if no explicit selection, use the current index
4775 [ + + ]: 4 : if (selected.isEmpty()) {
4776 [ + - + - ]: 1 : auto current = m_mailList->selectionModel()->currentIndex();
4777 [ + - ]: 1 : if (current.isValid())
4778 [ + - ]: 1 : selected.append(current);
4779 : : }
4780 : 4 : QList<qint64> uids;
4781 [ + - + - : 8 : for (const auto &idx : selected) {
+ + ]
4782 [ + - ]: 4 : qint64 uid = uidFromViewIndex(idx);
4783 [ + - + - ]: 4 : if (uid >= 0) uids.append(uid);
4784 : : }
4785 : 4 : return uids;
4786 : 4 : }
4787 : :
4788 : : // T-407: Resolve mail identity with correct folderId for search mode
4789 : 38 : MainWindow::MailId MainWindow::currentMailId() const {
4790 : 38 : MailId id;
4791 : :
4792 : : // In tab view, use the tab's stored identifiers
4793 [ + - + - : 38 : if (m_tabManager && !m_tabManager->isMainView()) {
- + - + ]
4794 [ # # ]: 0 : auto info = m_tabManager->currentTabInfo();
4795 : 0 : id.uid = info.mailUid;
4796 : 0 : id.folderId = info.folderId;
4797 : 0 : return id;
4798 : 0 : }
4799 : :
4800 [ + - + - ]: 38 : auto idx = m_mailList->selectionModel()->currentIndex();
4801 [ + + ]: 38 : if (!idx.isValid())
4802 : 27 : return id;
4803 : :
4804 [ + - ]: 11 : id = mailIdFromViewIndex(idx);
4805 : :
4806 : : // Fallback: use controller's current folder
4807 [ - + ]: 11 : if (id.folderId <= 0) {
4808 : 0 : id.folderId = m_controller->currentFolderId();
4809 : 0 : id.folderPath = m_controller->currentFolder();
4810 : : }
4811 : :
4812 : 11 : return id;
4813 : 0 : }
4814 : :
4815 : 26 : MainWindow::MailId MainWindow::mailIdFromViewIndex(
4816 : : const QModelIndex &viewIdx) const {
4817 : 26 : MailId id;
4818 [ - + ]: 26 : if (!viewIdx.isValid())
4819 : 0 : return id;
4820 : :
4821 [ + - ]: 26 : auto srcIdx = m_mailListProxy->mapToSource(viewIdx);
4822 : 26 : const MailHeader *header = nullptr;
4823 [ + + + - ]: 26 : if (m_threadViewActive && m_mailThreadModel) {
4824 [ + - ]: 21 : header = m_mailThreadModel->headerAt(srcIdx);
4825 : : } else {
4826 [ + - ]: 5 : header = m_mailListModel->headerAt(srcIdx.row());
4827 : : }
4828 [ - + ]: 26 : if (!header)
4829 : 0 : return id;
4830 : :
4831 : 26 : id.uid = header->uid;
4832 : 26 : id.folderId = header->folderId;
4833 [ + - ]: 26 : if (id.folderId > 0)
4834 [ + - ]: 26 : id.folderPath = m_cache->folderPath(id.folderId);
4835 : 26 : return id;
4836 : 0 : }
4837 : :
4838 : 13 : QList<MainWindow::MailId> MainWindow::getSelectedMailIds() const {
4839 : 13 : QList<MailId> result;
4840 : :
4841 : : // In tab view, return the tab's identity
4842 [ + - + - : 13 : if (m_tabManager && !m_tabManager->isMainView()) {
+ + + + ]
4843 [ + - ]: 1 : auto info = m_tabManager->currentTabInfo();
4844 [ - + ]: 1 : if (info.mailUid >= 0) {
4845 : 0 : MailId id;
4846 : 0 : id.uid = info.mailUid;
4847 : 0 : id.folderId = info.folderId;
4848 [ # # ]: 0 : result.append(id);
4849 : 0 : }
4850 : 1 : return result;
4851 : 1 : }
4852 : :
4853 [ + - + - ]: 12 : auto selected = m_mailList->selectionModel()->selectedRows();
4854 [ - + ]: 12 : if (selected.isEmpty()) {
4855 [ # # # # ]: 0 : auto current = m_mailList->selectionModel()->currentIndex();
4856 [ # # ]: 0 : if (current.isValid())
4857 [ # # ]: 0 : selected.append(current);
4858 : : }
4859 : :
4860 [ + - + - : 24 : for (const auto &idx : selected) {
+ + ]
4861 [ + - ]: 12 : MailId id = mailIdFromViewIndex(idx);
4862 [ - + ]: 12 : if (id.uid < 0)
4863 : 0 : continue;
4864 : :
4865 [ - + ]: 12 : if (id.folderId <= 0) {
4866 : 0 : id.folderId = m_controller->currentFolderId();
4867 : 0 : id.folderPath = m_controller->currentFolder();
4868 : : }
4869 : :
4870 [ + - ]: 12 : result.append(id);
4871 [ + - ]: 12 : }
4872 : :
4873 : 12 : return result;
4874 : 12 : }
4875 : :
4876 : : // ═══════════════════════════════════════════════════════
4877 : : // T-168: Update folder suggestion label for current mail
4878 : : // ═══════════════════════════════════════════════════════
4879 : :
4880 : 86 : void MainWindow::updateSuggestion() {
4881 : 86 : m_currentSuggestion.clear();
4882 : 86 : m_currentSuggestionConfidence = 0.0;
4883 : 86 : m_currentAltSuggestion.clear();
4884 : 86 : m_currentAltConfidence = 0.0;
4885 : :
4886 [ + - + - : 86 : if (!m_folderPredictor || !m_folderPredictor->isOpen()) {
- + - + ]
4887 [ # # ]: 0 : m_suggestionLabel->hide();
4888 : 86 : return;
4889 : : }
4890 : :
4891 [ + - + - ]: 86 : auto idx = m_mailList->selectionModel()->currentIndex();
4892 [ + + ]: 86 : if (!idx.isValid()) {
4893 [ + - ]: 1 : m_suggestionLabel->hide();
4894 : 1 : return;
4895 : : }
4896 : :
4897 [ + - ]: 85 : qint64 uid = uidFromViewIndex(idx);
4898 [ - + ]: 85 : if (uid < 0) {
4899 [ # # ]: 0 : m_suggestionLabel->hide();
4900 : 0 : return;
4901 : : }
4902 : :
4903 [ + - ]: 85 : auto h = m_cache->header(m_controller->currentFolderId(), uid);
4904 [ + + ]: 85 : if (!h) {
4905 [ + - ]: 23 : m_suggestionLabel->hide();
4906 : 23 : return;
4907 : : }
4908 : :
4909 : : // T-234: Use predictTop(2) to get both primary and alternate suggestions
4910 [ + - ]: 62 : auto topN = m_folderPredictor->predictTop(h->from, h->subject, h->to, 2);
4911 [ + - ]: 62 : if (topN.isEmpty()) {
4912 [ + - ]: 62 : m_suggestionLabel->hide();
4913 : 62 : return;
4914 : : }
4915 : :
4916 [ # # ]: 0 : QString suggestion = topN[0].first;
4917 [ # # ]: 0 : double conf = topN[0].second;
4918 : :
4919 : : // T-231: X-Spam Override
4920 [ # # # # : 0 : if (h->isSpam && !m_junkFolder.isEmpty()) {
# # ]
4921 [ # # # # : 0 : if (suggestion.isEmpty() || conf < 0.98 || suggestion == m_junkFolder) {
# # # # ]
4922 : 0 : suggestion = m_junkFolder;
4923 : 0 : conf = qMax(conf, 0.95);
4924 : : }
4925 : : }
4926 : :
4927 : : // Store primary suggestion
4928 : 0 : m_currentSuggestion = suggestion;
4929 : 0 : m_currentSuggestionConfidence = conf;
4930 : :
4931 : : // Store alternate suggestion (T-234)
4932 [ # # ]: 0 : if (topN.size() >= 2) {
4933 [ # # ]: 0 : m_currentAltSuggestion = topN[1].first;
4934 [ # # ]: 0 : m_currentAltConfidence = topN[1].second;
4935 : : }
4936 : :
4937 : : // T-234: If alternate mode is active for this UID, swap suggestions
4938 : 0 : int suggIndex = 0;
4939 [ # # # # : 0 : if (m_alternateUids.contains(uid) && !m_currentAltSuggestion.isEmpty()) {
# # ]
4940 : 0 : suggIndex = 1;
4941 : : }
4942 : :
4943 : : QString displaySuggestion = (suggIndex == 0) ? m_currentSuggestion
4944 [ # # ]: 0 : : m_currentAltSuggestion;
4945 [ # # ]: 0 : double displayConf = (suggIndex == 0) ? m_currentSuggestionConfidence
4946 : : : m_currentAltConfidence;
4947 : :
4948 : : // Primary: 0.4 threshold; Alternate: 0.01 (user explicitly requested it)
4949 [ # # ]: 0 : double minConf = (suggIndex == 0) ? 0.4 : 0.01;
4950 [ # # # # : 0 : if (displaySuggestion.isEmpty() || displayConf < minConf ||
# # ]
4951 [ # # # # ]: 0 : displaySuggestion == m_controller->currentFolder()) {
4952 : : // T-234: If alternate fails, fall back to primary instead of hiding
4953 [ # # ]: 0 : if (suggIndex == 1) {
4954 [ # # ]: 0 : m_alternateUids.remove(uid);
4955 : 0 : suggIndex = 0;
4956 : 0 : displaySuggestion = m_currentSuggestion;
4957 : 0 : displayConf = m_currentSuggestionConfidence;
4958 : : // Re-check primary
4959 [ # # # # : 0 : if (displaySuggestion.isEmpty() || displayConf < 0.4 ||
# # ]
4960 [ # # # # ]: 0 : displaySuggestion == m_controller->currentFolder()) {
4961 [ # # ]: 0 : m_suggestionLabel->hide();
4962 : 0 : return;
4963 : : }
4964 : : } else {
4965 [ # # ]: 0 : m_suggestionLabel->hide();
4966 : 0 : return;
4967 : : }
4968 : : }
4969 : :
4970 : : // T-213: In a mail tab, hide suggestion label
4971 [ # # # # : 0 : if (m_tabManager && !m_tabManager->isMainView()) {
# # # # ]
4972 [ # # ]: 0 : m_suggestionLabel->hide();
4973 : 0 : return;
4974 : : }
4975 : :
4976 : : // Format: show last segment of folder path + confidence
4977 : 0 : QString shortName = displaySuggestion;
4978 : 0 : int lastDot = displaySuggestion.lastIndexOf(QLatin1Char('.'));
4979 [ # # ]: 0 : if (lastDot >= 0)
4980 [ # # ]: 0 : shortName = displaySuggestion.mid(lastDot + 1);
4981 : 0 : int lastSlash = shortName.lastIndexOf(QLatin1Char('/'));
4982 [ # # ]: 0 : if (lastSlash >= 0)
4983 [ # # ]: 0 : shortName = shortName.mid(lastSlash + 1);
4984 : : // T-420: Decode IMAP Modified UTF-7 (e.g. "Entw&APw-rfe" → "Entwürfe")
4985 [ # # ]: 0 : shortName = ImapResponseParser::decodeMailboxName(shortName);
4986 : :
4987 : 0 : int pct = static_cast<int>(displayConf * 100);
4988 : :
4989 : : // T-198: Color based on confidence — shared muted palette (67.B3)
4990 [ # # # # ]: 0 : const QString color = ThemeManager::mutedConfidenceColor(displayConf).name();
4991 : :
4992 [ # # ]: 0 : m_suggestionLabel->setStyleSheet(
4993 : 0 : QStringLiteral("QLabel { color: %1; font-size: 11px; "
4994 : : "font-style: italic; padding: 0 8px; }")
4995 [ # # ]: 0 : .arg(color));
4996 : :
4997 : : // T-234: Show [ALT] marker when displaying alternate suggestion
4998 [ # # ]: 0 : if (suggIndex == 1) {
4999 [ # # ]: 0 : m_suggestionLabel->setText(
5000 [ # # # # ]: 0 : QStringLiteral("↳ %1 (%2%) [ALT]").arg(shortName).arg(pct));
5001 [ # # ]: 0 : m_suggestionLabel->setToolTip(
5002 : 0 : QStringLiteral("Alternativ-Vorschlag: %1\nKonfidenz: %2%\nE = zurück, S = verschieben")
5003 [ # # # # ]: 0 : .arg(displaySuggestion).arg(pct));
5004 : : } else {
5005 [ # # ]: 0 : m_suggestionLabel->setText(
5006 [ # # # # ]: 0 : QStringLiteral("⤷ %1 (%2%) [S]").arg(shortName).arg(pct));
5007 [ # # ]: 0 : m_suggestionLabel->setToolTip(
5008 : 0 : QStringLiteral("Ordnervorschlag: %1\nKonfidenz: %2%\nS zum Verschieben")
5009 [ # # # # ]: 0 : .arg(displaySuggestion).arg(pct));
5010 : : }
5011 [ # # ]: 0 : m_suggestionLabel->show();
5012 [ - - - - : 147 : }
- + - + ]
5013 : :
5014 : : // ═══════════════════════════════════════════════════════
5015 : : // T-232: Viewport-based suggestion computation
5016 : : // ═══════════════════════════════════════════════════════
5017 : :
5018 : 3 : QList<MailHeader> MainWindow::getVisibleHeaders() const {
5019 : 3 : QList<MailHeader> result;
5020 [ + - + - : 3 : if (!m_mailList || !m_mailList->model())
- + - + ]
5021 : 0 : return result;
5022 : :
5023 [ + - ]: 3 : auto *model = m_mailList->model();
5024 [ + - ]: 3 : int rowCount = model->rowCount();
5025 [ - + ]: 3 : if (rowCount == 0)
5026 : 0 : return result;
5027 : :
5028 : : // Find first and last visible rows
5029 [ + - ]: 3 : QModelIndex topIdx = m_mailList->indexAt(QPoint(0, 0));
5030 [ + - ]: 3 : QModelIndex botIdx = m_mailList->indexAt(
5031 [ + - ]: 3 : QPoint(0, m_mailList->viewport()->height() - 1));
5032 : :
5033 [ + - ]: 3 : int first = topIdx.isValid() ? topIdx.row() : 0;
5034 [ - + ]: 3 : int last = botIdx.isValid() ? botIdx.row() : rowCount - 1;
5035 : :
5036 : : // Add buffer of 5 rows above/below
5037 : 3 : first = qMax(0, first - 5);
5038 : 3 : last = qMin(rowCount - 1, last + 5);
5039 : :
5040 : : // Map proxy rows to source headers, skip already-computed UIDs
5041 [ + + ]: 9 : for (int row = first; row <= last; ++row) {
5042 [ + - ]: 6 : QModelIndex proxyIdx = model->index(row, 0);
5043 [ - + ]: 6 : if (!proxyIdx.isValid())
5044 : 0 : continue;
5045 : :
5046 : 6 : QModelIndex srcIdx = proxyIdx;
5047 [ + - ]: 6 : auto *proxy = qobject_cast<const QSortFilterProxyModel*>(model);
5048 [ + - ]: 6 : if (proxy)
5049 [ + - ]: 6 : srcIdx = proxy->mapToSource(proxyIdx);
5050 [ - + ]: 6 : if (!srcIdx.isValid())
5051 : 0 : continue;
5052 : :
5053 : : // Use the correct source model's headerAt based on view mode
5054 : 6 : const MailHeader *h = nullptr;
5055 [ + - ]: 6 : if (m_threadViewActive) {
5056 [ + - ]: 6 : h = m_mailThreadModel->headerAt(srcIdx);
5057 : : } else {
5058 [ # # ]: 0 : h = m_mailListModel->headerAt(srcIdx.row());
5059 : : }
5060 : :
5061 [ + - + - : 6 : if (h && !m_suggestedUids.contains(h->uid)) {
+ - ]
5062 [ + - ]: 6 : result.append(*h);
5063 : : }
5064 : : }
5065 : :
5066 : 3 : return result;
5067 : 0 : }
5068 : :
5069 : 3 : void MainWindow::computeVisibleSuggestions() {
5070 [ + - + + : 3 : if (m_predictorDbPath.isEmpty() || !m_suggestionColumnVisible)
+ + ]
5071 : 1 : return;
5072 : :
5073 [ + - ]: 2 : QList<MailHeader> headers = getVisibleHeaders();
5074 [ - + ]: 2 : if (headers.isEmpty())
5075 : 0 : return;
5076 : :
5077 : : // Mark UIDs as in-progress
5078 [ + - + - : 4 : for (const auto &h : headers) {
+ + ]
5079 [ + - ]: 2 : m_suggestedUids.insert(h.uid);
5080 : : }
5081 : :
5082 : : // Cancel any running batch
5083 [ - + ]: 2 : if (m_suggestionWorker)
5084 [ # # ]: 0 : m_suggestionWorker->cancel();
5085 : :
5086 : : // Create persistent thread + worker on first use
5087 [ + - ]: 2 : if (!m_suggestionThread) {
5088 [ + - + - : 2 : m_suggestionThread = new QThread(this);
- + - - ]
5089 [ + - + - : 2 : m_suggestionWorker = new SuggestionWorker();
- + - - ]
5090 [ + - ]: 2 : m_suggestionWorker->moveToThread(m_suggestionThread);
5091 : :
5092 : : // Route each result to the active source model on the UI thread
5093 : 2 : connect(m_suggestionWorker, &SuggestionWorker::resultReady,
5094 [ + - ]: 2 : this, [this](qint64 uid, const QString &text, double confidence) {
5095 [ + + ]: 2 : if (!m_suggestionColumnVisible)
5096 : 1 : return;
5097 [ + - ]: 1 : if (m_threadViewActive) {
5098 : 1 : m_mailThreadModel->setSuggestion(uid, m_controller->currentFolderId(), text, confidence);
5099 : : } else {
5100 : 0 : m_mailListModel->setSuggestion(uid, m_controller->currentFolderId(), text, confidence);
5101 : : }
5102 : : });
5103 : :
5104 [ + - ]: 2 : m_suggestionThread->start();
5105 : : }
5106 : :
5107 : : // Invoke process() on the worker thread (queued connection)
5108 : 2 : QString dbPath = m_predictorDbPath;
5109 : 2 : QString currentFolder = m_controller->currentFolder();
5110 : 2 : QString junkFolder = m_junkFolder;
5111 : :
5112 [ + - - - : 2 : QMetaObject::invokeMethod(m_suggestionWorker, [this, dbPath, headers, currentFolder, junkFolder]() {
- - - - ]
5113 : 2 : m_suggestionWorker->process(dbPath, headers, currentFolder, junkFolder);
5114 : 2 : }, Qt::QueuedConnection);
5115 [ + - ]: 2 : }
5116 : :
5117 : : // ═══════════════════════════════════════════════════════
5118 : : // T-169: Quick-move to suggested folder (Shift+S)
5119 : : // ═══════════════════════════════════════════════════════
5120 : :
5121 : 6 : void MainWindow::quickMoveToSuggestion() {
5122 : : // T-234: Use the currently displayed suggestion (primary or alternate)
5123 [ + - ]: 6 : auto mailIds = getSelectedMailIds();
5124 [ + + ]: 6 : if (mailIds.isEmpty())
5125 : 1 : return;
5126 : :
5127 [ + - ]: 5 : qint64 uid = mailIds.first().uid;
5128 : 5 : QString target = m_currentSuggestion;
5129 : :
5130 : : // If alternate mode is active for this UID, use the alternate suggestion
5131 [ + + + - : 5 : if (m_alternateUids.contains(uid) && !m_currentAltSuggestion.isEmpty()) {
+ + ]
5132 : 2 : target = m_currentAltSuggestion;
5133 : : }
5134 : :
5135 [ + - + - : 5 : if (target.isEmpty() || !m_suggestionLabel->isVisible()) {
+ - + - ]
5136 : : // T-265: Fallback — no visible suggestion → manual folder selection
5137 [ + - ]: 5 : m_commandBar->activate(CommandBar::MoveToFolder);
5138 : 5 : return;
5139 : : }
5140 : :
5141 : 0 : QList<qint64> uids;
5142 [ # # # # : 0 : for (const auto &mid : mailIds) uids.append(mid.uid);
# # # # ]
5143 : :
5144 : : // T-170: Train — user accepted the suggestion
5145 [ # # ]: 0 : trainAfterMove(mailIds, target);
5146 : :
5147 : : // Clear alternate toggle for moved UIDs
5148 [ # # # # : 0 : for (qint64 u : uids) {
# # ]
5149 [ # # ]: 0 : m_alternateUids.remove(u);
5150 : : }
5151 : :
5152 [ # # ]: 0 : copyTabCacheToFolder(uids, target);
5153 [ # # ]: 0 : selectNextAfterMove();
5154 : :
5155 : : // T-407: Group by source folder for search-mode moves
5156 [ # # ]: 0 : if (isSearchMode()) {
5157 : 0 : QMap<qint64, QList<qint64>> byFolder;
5158 : 0 : QMap<qint64, QString> folderPaths;
5159 [ # # # # : 0 : for (const auto &mid : mailIds) {
# # ]
5160 [ # # # # ]: 0 : byFolder[mid.folderId].append(mid.uid);
5161 [ # # ]: 0 : folderPaths[mid.folderId] = mid.folderPath;
5162 : : }
5163 [ # # # # : 0 : for (auto it = byFolder.constBegin(); it != byFolder.constEnd(); ++it) {
# # ]
5164 [ # # ]: 0 : m_controller->moveMailsToFolderFrom(
5165 [ # # ]: 0 : it.value(), it.key(), folderPaths[it.key()], target);
5166 : : }
5167 : 0 : } else {
5168 [ # # ]: 0 : m_controller->moveMailsToFolder(uids, target);
5169 : : }
5170 : :
5171 [ # # ]: 0 : setStatus(QStringLiteral("move"),
5172 [ # # ]: 0 : QStringLiteral("Verschoben nach %1").arg(target),
5173 : : 3000);
5174 [ # # ]: 0 : updateSuggestion();
5175 [ - + - + ]: 11 : }
5176 : :
5177 : : // ═══════════════════════════════════════════════════════
5178 : : // T-170: Train predictor after a move action
5179 : : // ═══════════════════════════════════════════════════════
5180 : :
5181 : 4 : void MainWindow::trainAfterMove(const QList<MailId> &mails,
5182 : : const QString &targetFolder) {
5183 [ + - - + : 4 : if (!m_folderPredictor || !m_folderPredictor->isOpen())
- + ]
5184 : 0 : return;
5185 : :
5186 : : // T-262: Don't train on Junk/Spam moves — prevents classifier from
5187 : : // suggesting Junk as a target folder for normal emails.
5188 [ - + ]: 4 : if (targetFolder == m_junkFolder)
5189 : 0 : return;
5190 : :
5191 [ + + ]: 8 : for (const auto &mail : mails) {
5192 [ - + ]: 4 : if (!mail.hasFolderId())
5193 : 0 : continue;
5194 [ + - ]: 4 : auto h = m_cache->header(mail.folderId, mail.uid);
5195 [ + - ]: 4 : if (h) {
5196 [ + - ]: 4 : m_folderPredictor->train(h->from, h->subject, h->to, targetFolder);
5197 : : }
5198 : 4 : }
5199 : : }
5200 : :
5201 : : // ═══════════════════════════════════════════════════════
5202 : : // T-213: Open mail in a separate tab
5203 : : // ═══════════════════════════════════════════════════════
5204 : :
5205 : 8 : void MainWindow::openMailInTab(qint64 uid) {
5206 [ - + ]: 11 : if (!m_tabManager) return;
5207 : :
5208 : : // Duplicate check: if tab for this UID exists, switch to it
5209 [ + - ]: 8 : int existing = m_tabManager->findTabByUid(uid);
5210 [ + + ]: 8 : if (existing >= 0) {
5211 [ + - ]: 2 : m_tabManager->switchToTab(existing);
5212 : 2 : return;
5213 : : }
5214 : :
5215 : 6 : qint64 folderId = m_controller->currentFolderId();
5216 [ + - ]: 6 : auto h = m_cache->header(folderId, uid);
5217 [ + + ]: 6 : if (!h) return;
5218 : :
5219 : : // Create the tab (returns the index)
5220 [ + - ]: 5 : int tabIdx = m_tabManager->openMailTab(uid, folderId, h->subject,
5221 : 5 : h->messageId);
5222 : :
5223 : : // Create the MailTabWidget
5224 [ + - + - : 5 : auto *tabWidget = new MailTabWidget(m_tabStack);
- + - - ]
5225 [ + - ]: 5 : tabWidget->setMailInfo(uid, folderId);
5226 [ + - ]: 5 : tabWidget->setCache(m_cache);
5227 : :
5228 : : // Insert widget into the stacked widget at the correct position
5229 [ + - ]: 5 : m_tabStack->insertWidget(tabIdx, tabWidget);
5230 [ + - ]: 5 : m_tabManager->setTabWidget(tabIdx, tabWidget);
5231 : :
5232 : : // Load body from cache
5233 [ + - ]: 5 : auto body = m_cache->body(folderId, uid);
5234 [ + + ]: 5 : if (body) {
5235 [ + - ]: 3 : MailBody displayBody = body.value();
5236 [ + - ]: 3 : displayBody.attachments = m_cache->attachments(folderId, uid);
5237 [ + - ]: 3 : tabWidget->displayMail(*h, displayBody);
5238 : 3 : } else {
5239 [ + - ]: 2 : tabWidget->showLoadingMessage();
5240 : : // T-540: Async body fetch for tabs — trigger fetch via controller,
5241 : : // then update the tab when the body arrives.
5242 : 2 : auto *tw = tabWidget; // capture raw pointer (widget owned by stack)
5243 : 2 : auto hdr = *h; // copy header for lambda
5244 [ + - ]: 2 : connect(m_controller, &MailController::bodyLoaded, tw,
5245 : 4 : [this, tw, uid, folderId, hdr](qint64 loadedUid, qint64 loadedFolderId) {
5246 [ + + - + ]: 2 : if (loadedUid != uid || loadedFolderId != folderId)
5247 : 1 : return;
5248 [ + - ]: 1 : auto cachedBody = m_cache->body(folderId, uid);
5249 [ + - ]: 1 : if (cachedBody) {
5250 [ + - ]: 1 : MailBody displayBody = cachedBody.value();
5251 [ + - ]: 1 : displayBody.attachments = m_cache->attachments(folderId, uid);
5252 [ + - ]: 1 : tw->displayMail(hdr, displayBody);
5253 : 1 : }
5254 : 1 : });
5255 : : // Trigger the body fetch
5256 [ + - ]: 2 : m_controller->onMailSelectedInFolder(uid, folderId);
5257 : 2 : }
5258 : : // Switch to the new tab
5259 [ + - ]: 5 : m_tabManager->switchToTab(tabIdx);
5260 [ + + ]: 6 : }
5261 : :
5262 : : // ═══════════════════════════════════════════════════════
5263 : : // T-290: Folder management handlers
5264 : : // ═══════════════════════════════════════════════════════
5265 : :
5266 : : // T-290 folder management flows moved to FolderOperationsController
5267 : : // (Sprint 65 P2.2).
5268 : :
5269 : : // T-304: Runtime language switching
5270 : 357 : void MainWindow::changeEvent(QEvent *event) {
5271 [ + + ]: 357 : if (event->type() == QEvent::LanguageChange)
5272 : 86 : retranslateUi();
5273 : 357 : QMainWindow::changeEvent(event);
5274 : 357 : }
5275 : :
5276 : 86 : void MainWindow::retranslateUi() {
5277 : : // Preserve thread view state before menu rebuild
5278 [ + - + + ]: 86 : bool wasThreadView = m_threadViewAction && m_threadViewAction->isChecked();
5279 : :
5280 : : // Rebuild menu bar with translated strings
5281 : 86 : menuBar()->clear();
5282 : 86 : setupMenuBar();
5283 : :
5284 : : // Restore thread view toggle state (setupMenuBar sets it to false)
5285 [ + - + + ]: 86 : if (m_threadViewAction && wasThreadView) {
5286 : 26 : QSignalBlocker blocker(m_threadViewAction);
5287 [ + - ]: 26 : m_threadViewAction->setChecked(true);
5288 : 26 : }
5289 : :
5290 : : // Rebuild tray menu with translated strings
5291 [ + - ]: 86 : if (m_trayMenu) {
5292 : 86 : rebuildTrayMenu();
5293 : : }
5294 : 86 : }
5295 : :
5296 : : // ═══════════════════════════════════════════════════════
5297 : : // T-339: CalDAV calendar sync (Sprint 32)
5298 : : // ═══════════════════════════════════════════════════════
5299 : :
5300 : 10 : void MainWindow::initCalendarSync() {
5301 [ + - + - : 10 : m_calendarStore = new CalendarStore(this);
- + - - ]
5302 [ + - ]: 20 : QString configDir = QStandardPaths::writableLocation(
5303 [ + - ]: 30 : QStandardPaths::ConfigLocation) + QStringLiteral("/mailjd");
5304 [ + - + - : 20 : QDir().mkpath(configDir + QStringLiteral("/cache"));
+ - ]
5305 [ + - ]: 10 : QString dbPath = configDir + QStringLiteral("/cache/calendar.db");
5306 [ + - - + ]: 10 : if (!m_calendarStore->open(dbPath)) {
5307 [ # # # # : 0 : qCWarning(lcMainWindow) << "Failed to open CalendarStore at" << dbPath;
# # # # #
# ]
5308 : 0 : return;
5309 : : }
5310 : :
5311 [ + - ]: 10 : QSettings s;
5312 : : int interval =
5313 [ + - + - ]: 20 : s.value(QStringLiteral("caldav/syncIntervalMin"), 15).toInt();
5314 [ + - ]: 10 : if (interval > 0) {
5315 [ + - + - : 10 : m_calDavSyncTimer = new QTimer(this);
- + - - ]
5316 [ + - ]: 10 : m_calDavSyncTimer->setInterval(interval * 60 * 1000);
5317 : 10 : connect(m_calDavSyncTimer, &QTimer::timeout, this,
5318 [ + - ]: 10 : &MainWindow::triggerCalDavSync);
5319 [ + - ]: 10 : m_calDavSyncTimer->start();
5320 [ + - ]: 10 : QTimer::singleShot(3000, this, &MainWindow::triggerCalDavSync);
5321 : : }
5322 [ + - + - ]: 10 : }
5323 : :
5324 : 8 : void MainWindow::triggerCalDavSync() {
5325 [ + + + - : 8 : if (!m_calendarStore || !m_calendarStore->isOpen()) {
- + + + ]
5326 [ + - ]: 1 : setStatus(QStringLiteral("caldav"),
5327 [ + - ]: 2 : tr("Calendar sync not available"), 5000);
5328 : 1 : return;
5329 : : }
5330 : :
5331 : : // Sprint 73: pre-scan accounts so we can avoid advertising a "sync running"
5332 : : // state when no account is actually queueable (e.g. all synced accounts
5333 : : // still need local authorization). Logs the skip reason with account ID +
5334 : : // server URL only; the secret is never logged.
5335 [ + - ]: 7 : QSettings s;
5336 [ + - ]: 7 : int count = s.beginReadArray(QStringLiteral("carddav/accounts"));
5337 : 7 : int queuedAccounts = 0;
5338 : 7 : int missingSecretAccounts = 0;
5339 : 7 : int missingConfigAccounts = 0;
5340 [ + + ]: 8 : for (int i = 0; i < count; ++i) {
5341 [ + - ]: 1 : s.setArrayIndex(i);
5342 [ + - + - ]: 1 : QString accountId = s.value(QStringLiteral("id")).toString();
5343 [ + - + - ]: 1 : QString serverUrl = s.value(QStringLiteral("serverUrl")).toString();
5344 [ + - + - ]: 1 : QString username = s.value(QStringLiteral("username")).toString();
5345 : :
5346 : : // Resolve the per-account CalDAV config first: an account without a
5347 : : // selected calendar is not a sync candidate and is not counted as a
5348 : : // missing-secret case.
5349 [ + - ]: 1 : QSettings s2;
5350 [ + - ]: 1 : int cfgCount = s2.beginReadArray(QStringLiteral("caldav/configs"));
5351 : 1 : QStringList selectedCalendars;
5352 : 1 : bool found = false;
5353 [ + - ]: 1 : for (int j = 0; j < cfgCount; ++j) {
5354 [ + - ]: 1 : s2.setArrayIndex(j);
5355 [ + - + - : 1 : if (s2.value(QStringLiteral("carddavAccountId")).toString() ==
+ - ]
5356 : : accountId) {
5357 : : selectedCalendars =
5358 [ + - + - ]: 1 : s2.value(QStringLiteral("selectedCalendars")).toStringList();
5359 : 1 : found = true;
5360 : 1 : break;
5361 : : }
5362 : : }
5363 [ + - ]: 1 : s2.endArray();
5364 [ + - - + : 1 : if (!found || selectedCalendars.isEmpty()) {
- + ]
5365 : 0 : ++missingConfigAccounts;
5366 : 0 : continue;
5367 : : }
5368 : :
5369 [ + - - + : 1 : if (serverUrl.isEmpty() || username.isEmpty()) {
- + ]
5370 [ # # # # : 0 : qCInfo(lcMainWindow)
# # ]
5371 [ # # ]: 0 : << "Skipping CalDAV account without login metadata: id="
5372 [ # # # # : 0 : << accountId << "server=" << serverUrl;
# # ]
5373 : 0 : continue;
5374 : 0 : }
5375 : :
5376 : : const QByteArray password = DavCredentials::readPasswordBlocking(
5377 [ + - ]: 1 : accountId, serverUrl, username);
5378 [ - + ]: 1 : if (password.isEmpty()) {
5379 : : // Synced account restored without a local secret; needs local
5380 : : // authorization before it can sync.
5381 [ # # # # : 0 : qCWarning(lcMainWindow)
# # ]
5382 [ # # ]: 0 : << "Skipping CalDAV account without local credentials; needs"
5383 [ # # # # ]: 0 : << "local authorization: id=" << accountId
5384 [ # # # # ]: 0 : << "server=" << serverUrl;
5385 : 0 : ++missingSecretAccounts;
5386 : 0 : continue;
5387 : 0 : }
5388 : :
5389 : 1 : ++queuedAccounts;
5390 : : auto *client =
5391 [ + - + - : 1 : new CalDavClient(serverUrl, username, QString::fromUtf8(password), this);
+ - - + -
- ]
5392 : :
5393 : 1 : connect(client, &CalDavClient::syncFailed, this,
5394 [ + - ]: 1 : [this](const QString &error) {
5395 [ + - ]: 4 : setStatus(QStringLiteral("caldav"),
5396 [ + - + - ]: 12 : tr("\u2699 Sync error: %1").arg(error), 10000);
5397 : 4 : });
5398 : :
5399 : 1 : connect(client, &CalDavClient::calendarsDiscovered, this,
5400 [ + - - - : 2 : [this, accountId, selectedCalendars, client](
- - ]
5401 : : const QList<CalendarInfo> &calendars) {
5402 [ + + ]: 3 : for (const auto &cal : calendars) {
5403 [ + + ]: 2 : if (selectedCalendars.contains(cal.path)) {
5404 : 1 : CalendarInfo storedCal = cal;
5405 : 1 : storedCal.accountId = accountId;
5406 [ + - ]: 1 : m_calendarStore->upsertCalendar(storedCal, accountId);
5407 : 1 : }
5408 : : }
5409 [ + + ]: 3 : for (const auto &cal : calendars) {
5410 [ + + ]: 2 : if (!selectedCalendars.contains(cal.path))
5411 : 1 : continue;
5412 [ + - ]: 1 : client->syncCalendar(cal.path);
5413 [ + - ]: 1 : client->syncTasks(cal.path);
5414 : : }
5415 : 1 : });
5416 : :
5417 [ + - ]: 1 : connect(client, &CalDavClient::eventsSynced, this,
5418 : 2 : [this, accountId](const QString &calPath,
5419 : : const QList<CalendarEvent> &events) {
5420 [ + - ]: 1 : m_calendarStore->beginTransaction();
5421 : 1 : QStringList evUids;
5422 [ + + ]: 2 : for (const auto &ev : events) {
5423 : 1 : CalendarEvent storedEvent = ev;
5424 : 1 : storedEvent.accountId = accountId;
5425 [ + - ]: 1 : m_calendarStore->upsertEvent(storedEvent);
5426 [ + - ]: 1 : evUids << ev.uid;
5427 : 1 : }
5428 [ + - ]: 1 : m_calendarStore->removeStaleEvents(accountId, calPath, evUids);
5429 [ + - ]: 1 : m_calendarStore->commitTransaction();
5430 : : // Defer UI refresh to avoid re-entrant DB reads
5431 [ + - ]: 1 : QTimer::singleShot(0, this, [this]() {
5432 [ - + ]: 1 : if (m_calendarWidget)
5433 [ # # ]: 0 : m_calendarWidget->navigateToDate(
5434 : 0 : m_calendarWidget->selectedDate());
5435 : 1 : });
5436 [ + - ]: 1 : setStatus(QStringLiteral("caldav"),
5437 [ + - ]: 1 : tr("%1 calendar entries synced")
5438 [ + - ]: 2 : .arg(events.size()),
5439 : : 5000);
5440 : 1 : });
5441 : :
5442 [ + - ]: 1 : connect(client, &CalDavClient::tasksSynced, this,
5443 : 2 : [this, accountId](const QString &calPath,
5444 : : const QList<CalendarTask> &tasks) {
5445 [ + - ]: 1 : m_calendarStore->beginTransaction();
5446 : 1 : QStringList taskUids;
5447 [ + + ]: 2 : for (const auto &t : tasks) {
5448 : 1 : CalendarTask storedTask = t;
5449 : 1 : storedTask.accountId = accountId;
5450 [ + - ]: 1 : m_calendarStore->upsertTask(storedTask);
5451 [ + - ]: 1 : taskUids << t.uid;
5452 : 1 : }
5453 [ + - ]: 1 : m_calendarStore->removeStaleTasks(accountId, calPath, taskUids);
5454 [ + - ]: 1 : m_calendarStore->commitTransaction();
5455 : : // Defer UI refresh to avoid re-entrant DB reads
5456 [ + - ]: 1 : QTimer::singleShot(0, this, [this]() {
5457 [ - + ]: 1 : if (m_taskListWidget)
5458 : 0 : m_taskListWidget->reload();
5459 : 1 : });
5460 [ + - ]: 1 : setStatus(QStringLiteral("caldav"),
5461 [ + - + - ]: 3 : tr("%1 tasks synced").arg(tasks.size()),
5462 : : 5000);
5463 : 1 : });
5464 : :
5465 [ + - ]: 1 : client->discoverCalendars();
5466 [ + - ]: 1 : QTimer::singleShot(30000, client, &QObject::deleteLater);
5467 [ + - + - : 1 : }
+ - + - +
- + - ]
5468 [ + - ]: 7 : s.endArray();
5469 : :
5470 [ + + ]: 7 : if (queuedAccounts > 0) {
5471 [ + - + - ]: 2 : setStatus(QStringLiteral("caldav"), tr("Calendar sync running…"));
5472 [ - + ]: 6 : } else if (missingSecretAccounts > 0) {
5473 : : // Every queueable account is blocked on local authorization: say so
5474 : : // explicitly instead of a generic "no calendars configured".
5475 [ # # ]: 0 : setStatus(QStringLiteral("caldav"),
5476 [ # # ]: 0 : tr("DAV account was synced without credentials — please "
5477 : : "authorize locally."),
5478 : : 8000);
5479 : : } else {
5480 [ + - ]: 6 : setStatus(QStringLiteral("caldav"),
5481 [ + - ]: 12 : tr("No calendars configured for sync"), 5000);
5482 : : }
5483 : 7 : }
5484 : :
5485 : 14 : void MainWindow::openCalendarTab() {
5486 [ + + ]: 14 : if (!m_calendarStore) initCalendarSync();
5487 [ + + ]: 14 : if (!m_calendarWidget) {
5488 [ + - - + : 7 : m_calendarWidget = new CalendarWidget(this);
- - ]
5489 : 7 : m_calendarWidget->setCalendarStore(m_calendarStore);
5490 : 7 : connect(m_calendarWidget, &CalendarWidget::closeRequested, this,
5491 [ + - ]: 8 : [this]() { m_tabManager->closeCurrentTab(); });
5492 : 7 : connect(m_calendarWidget, &QObject::destroyed, this,
5493 [ + - ]: 7 : [this]() { m_calendarWidget = nullptr; });
5494 : :
5495 : : // Sprint 39: Create event (click empty area / drag-to-select)
5496 : 7 : connect(m_calendarWidget, &CalendarWidget::createEventRequested, this,
5497 [ + - ]: 7 : [this](const QDate &date, const QTime &start, const QTime &end) {
5498 : 1 : CalendarEvent ev;
5499 [ + - ]: 1 : showEventEditDialog(ev, true, date, start, end);
5500 : 1 : });
5501 : :
5502 : : // Sprint 39: Edit event (double-click / popup Edit button)
5503 : 7 : connect(m_calendarWidget, &CalendarWidget::editEventRequested, this,
5504 [ + - ]: 7 : [this](const CalendarEvent &event) {
5505 [ + - + - ]: 1 : showEventEditDialog(event, false);
5506 : 1 : });
5507 : :
5508 : : // Sprint 39: Delete event (popup Delete button)
5509 : 7 : connect(m_calendarWidget, &CalendarWidget::deleteEventRequested, this,
5510 [ + - ]: 14 : [this](const CalendarEvent &event) {
5511 [ + - + - ]: 2 : if (m_confirm(tr("Delete event"),
5512 [ + - + - : 6 : tr("Do you really want to delete \"%1\"?").arg(event.summary)))
+ + ]
5513 : 1 : onEventDeleted(event);
5514 : 2 : });
5515 : : }
5516 : 14 : m_tabManager->openCalendarTab(m_calendarWidget);
5517 : 14 : m_calendarWidget->setFocus();
5518 : 14 : }
5519 : :
5520 : 7 : void MainWindow::openTaskTab() {
5521 [ - + ]: 7 : if (!m_calendarStore) initCalendarSync();
5522 [ + + ]: 7 : if (!m_taskListWidget) {
5523 [ + - - + : 6 : m_taskListWidget = new TaskListWidget(this);
- - ]
5524 : 6 : m_taskListWidget->setCalendarStore(m_calendarStore);
5525 : 6 : connect(m_taskListWidget, &TaskListWidget::closeRequested, this,
5526 [ + - ]: 7 : [this]() { m_tabManager->closeCurrentTab(); });
5527 : 6 : connect(m_taskListWidget, &QObject::destroyed, this,
5528 [ + - ]: 6 : [this]() { m_taskListWidget = nullptr; });
5529 : :
5530 : : // Sprint 39: Create new task
5531 : 6 : connect(m_taskListWidget, &TaskListWidget::taskCreateRequested, this,
5532 [ + - ]: 6 : [this]() {
5533 : 1 : CalendarTask task;
5534 [ + - ]: 1 : showTaskEditDialog(task, true);
5535 : 1 : });
5536 : :
5537 : : // Sprint 39: Edit existing task (double-click or inline edit)
5538 : 6 : connect(m_taskListWidget, &TaskListWidget::taskUpdated, this,
5539 [ + - ]: 6 : [this](const CalendarTask &task) {
5540 : 1 : showTaskEditDialog(task, false);
5541 : 1 : });
5542 : :
5543 : : // Sprint 56: Delete task
5544 : 6 : connect(m_taskListWidget, &TaskListWidget::taskDeleteRequested, this,
5545 [ + - ]: 6 : &MainWindow::onTaskDeleted);
5546 : :
5547 : : // Sprint 56: Inline save (checkbox toggle, description edit)
5548 : 6 : connect(m_taskListWidget, &TaskListWidget::taskSaveRequested, this,
5549 [ + - ]: 13 : [this](const CalendarTask &t) { onTaskSaved(t, false); });
5550 : : }
5551 : 7 : m_tabManager->openTaskTab(m_taskListWidget);
5552 : 7 : m_taskListWidget->setFocus();
5553 : 7 : }
5554 : :
5555 : : // ═══════════════════════════════════════════════════════
5556 : : // Sprint 39: Calendar / Task editing helpers
5557 : : // ═══════════════════════════════════════════════════════
5558 : :
5559 : 15 : CalDavClient *MainWindow::createCalDavWriteClient(const QString &calendarPath,
5560 : : const QString &accountId) {
5561 [ + - - + : 15 : if (!m_calendarStore || calendarPath.isEmpty())
- + ]
5562 : 0 : return nullptr;
5563 : :
5564 : 15 : const QString targetAccountId = accountId.isEmpty()
5565 [ + + ]: 15 : ? m_calendarStore->accountIdForCalendarPath(calendarPath)
5566 [ + - ]: 15 : : accountId;
5567 [ + + ]: 15 : if (targetAccountId.isEmpty()) {
5568 [ + - + - : 12 : qCWarning(lcMainWindow)
+ + ]
5569 [ + - ]: 6 : << "Cannot create CalDAV write client: no account for calendar"
5570 [ + - ]: 6 : << calendarPath;
5571 : 6 : return nullptr;
5572 : : }
5573 : :
5574 [ + - ]: 9 : QSettings s;
5575 [ + - ]: 9 : int count = s.beginReadArray(QStringLiteral("carddav/accounts"));
5576 : 9 : QString serverUrl;
5577 : 9 : QString username;
5578 : 9 : QString password;
5579 [ + + ]: 9 : for (int i = 0; i < count; ++i) {
5580 [ + - ]: 6 : s.setArrayIndex(i);
5581 [ + - + - : 6 : if (s.value(QStringLiteral("id")).toString() != targetAccountId)
- + ]
5582 : 0 : continue;
5583 [ + - + - ]: 6 : serverUrl = s.value(QStringLiteral("serverUrl")).toString();
5584 [ + - + - ]: 6 : username = s.value(QStringLiteral("username")).toString();
5585 [ + - + - ]: 12 : password = QString::fromUtf8(DavCredentials::readPasswordBlocking(
5586 : 6 : targetAccountId, serverUrl, username));
5587 : 6 : break;
5588 : : }
5589 [ + - ]: 9 : s.endArray();
5590 [ + + + - : 9 : if (serverUrl.isEmpty() || username.isEmpty() || password.isEmpty())
- + + + ]
5591 : 3 : return nullptr;
5592 : :
5593 [ + - + - : 6 : auto *client = new CalDavClient(serverUrl, username, password, this);
- + - - ]
5594 : : // Auto-delete after 30s
5595 [ + - ]: 6 : QTimer::singleShot(30000, client, &QObject::deleteLater);
5596 : 6 : return client;
5597 : 15 : }
5598 : :
5599 : 4 : void MainWindow::showEventEditDialog(const CalendarEvent &event, bool isNew,
5600 : : const QDate &defaultDate,
5601 : : const QTime &defaultStart,
5602 : : const QTime &defaultEnd) {
5603 [ + - - + : 4 : auto *dlg = new EventEditDialog(this);
- - ]
5604 [ + - + - ]: 4 : dlg->setCalendars(m_calendarStore->allCalendars());
5605 [ + + ]: 4 : if (isNew) {
5606 [ + - + + ]: 2 : QDate date = defaultDate.isValid() ? defaultDate
5607 [ - + ]: 1 : : (m_calendarWidget ? m_calendarWidget->selectedDate()
5608 [ + - ]: 2 : : QDate::currentDate());
5609 [ + - ]: 2 : dlg->setNewEventDefaults(date, defaultStart, defaultEnd);
5610 : : } else {
5611 : 2 : dlg->setEvent(event);
5612 : : }
5613 [ - + ]: 4 : if (m_runDialog(dlg) == QDialog::Accepted) {
5614 [ # # # # ]: 0 : onEventSaved(dlg->result(), isNew);
5615 : : }
5616 : 4 : dlg->deleteLater();
5617 : 4 : }
5618 : :
5619 : 3 : void MainWindow::showTaskEditDialog(const CalendarTask &task, bool isNew) {
5620 [ + - - + : 3 : auto *dlg = new TaskEditDialog(this);
- - ]
5621 [ + - + - ]: 3 : dlg->setCalendars(m_calendarStore->allCalendars());
5622 [ + + ]: 3 : if (!isNew)
5623 : 1 : dlg->setTask(task);
5624 [ - + ]: 3 : if (m_runDialog(dlg) == QDialog::Accepted) {
5625 [ # # ]: 0 : CalendarTask result = dlg->result();
5626 [ # # # # : 0 : if (!isNew && dlg->calendarChanged()) {
# # ]
5627 : : // Sprint 56: Calendar move = DELETE old + CREATE new
5628 : : // (CalDAV has no native MOVE for VTODOs)
5629 [ # # ]: 0 : onTaskDeleted(task); // Remove from old calendar
5630 : 0 : result.etag.clear(); // New server resource
5631 [ # # ]: 0 : onTaskSaved(result, true); // Create on new calendar
5632 : : } else {
5633 [ # # ]: 0 : onTaskSaved(result, isNew);
5634 : : }
5635 : 0 : }
5636 : 3 : dlg->deleteLater();
5637 : 3 : }
5638 : :
5639 : 5 : void MainWindow::onEventSaved(const CalendarEvent &event, bool wasNew) {
5640 [ + - ]: 5 : const auto previous = findStoredEvent(m_calendarStore, event);
5641 : : // 1. Save to local store
5642 [ + - ]: 5 : m_calendarStore->upsertEvent(event);
5643 : : // 2. Refresh calendar UI
5644 [ - + ]: 5 : if (m_calendarWidget)
5645 [ # # ]: 0 : m_calendarWidget->navigateToDate(
5646 : 0 : m_calendarWidget->selectedDate());
5647 : : // 3. Push to CalDAV server
5648 [ + - ]: 5 : auto *client = createCalDavWriteClient(event.calendarPath, event.accountId);
5649 [ + + ]: 5 : if (client) {
5650 : 2 : connect(client, &CalDavClient::eventSaved, this,
5651 [ + - ]: 2 : &MainWindow::onCalDavEventWriteSucceeded);
5652 [ + + ]: 2 : if (wasNew)
5653 [ + - ]: 1 : client->createEvent(event.calendarPath, event);
5654 : : else
5655 [ + - ]: 1 : client->updateEvent(event);
5656 : 2 : connect(client, &CalDavClient::writeFailed, this,
5657 [ + - + - : 4 : [this, event, previous](const QString &err) {
- - ]
5658 [ + + ]: 2 : if (previous) {
5659 : 1 : m_calendarStore->upsertEvent(*previous);
5660 : : } else {
5661 : 1 : m_calendarStore->deleteEvent(event.uid, event.accountId,
5662 : 1 : event.calendarPath);
5663 : : }
5664 [ - + ]: 2 : if (m_calendarWidget)
5665 [ # # ]: 0 : m_calendarWidget->navigateToDate(m_calendarWidget->selectedDate());
5666 [ + - ]: 2 : setStatus(QStringLiteral("caldav"),
5667 [ + - + - ]: 6 : tr("\u2699 Sync error: %1").arg(err), 5000);
5668 : 2 : });
5669 : : }
5670 : 5 : }
5671 : :
5672 : 3 : void MainWindow::onEventDeleted(const CalendarEvent &event) {
5673 [ + - ]: 3 : const auto previous = findStoredEvent(m_calendarStore, event);
5674 : : // 1. Remove from local store
5675 [ + - ]: 3 : m_calendarStore->deleteEvent(event.uid, event.accountId, event.calendarPath);
5676 : : // 2. Refresh calendar UI
5677 [ + + ]: 3 : if (m_calendarWidget)
5678 [ + - ]: 1 : m_calendarWidget->navigateToDate(
5679 : 2 : m_calendarWidget->selectedDate());
5680 : : // 3. DELETE on CalDAV server
5681 [ + - ]: 3 : auto *client = createCalDavWriteClient(event.calendarPath, event.accountId);
5682 [ + + ]: 3 : if (client) {
5683 [ + - ]: 1 : client->deleteEvent(event);
5684 [ + - ]: 1 : connect(client, &CalDavClient::writeFailed, this,
5685 [ + - ]: 2 : [this, previous](const QString &err) {
5686 [ + - ]: 1 : if (previous) {
5687 : 1 : m_calendarStore->upsertEvent(*previous);
5688 [ - + ]: 1 : if (m_calendarWidget)
5689 [ # # ]: 0 : m_calendarWidget->navigateToDate(m_calendarWidget->selectedDate());
5690 : : }
5691 [ + - ]: 1 : setStatus(QStringLiteral("caldav"),
5692 [ + - + - ]: 3 : tr("\u2699 Sync error: %1").arg(err), 5000);
5693 : 1 : });
5694 : : }
5695 : 3 : }
5696 : :
5697 : 5 : void MainWindow::onTaskSaved(const CalendarTask &task, bool wasNew) {
5698 [ + - ]: 5 : const auto previous = findStoredTask(m_calendarStore, task);
5699 : : // 1. Save to local store
5700 [ + - ]: 5 : m_calendarStore->upsertTask(task);
5701 : : // 2. Refresh task list UI
5702 [ + + ]: 5 : if (m_taskListWidget)
5703 [ + - ]: 1 : m_taskListWidget->reload();
5704 : : // 3. Push to CalDAV server
5705 [ + - ]: 5 : auto *client = createCalDavWriteClient(task.calendarPath, task.accountId);
5706 [ + + ]: 5 : if (client) {
5707 : 2 : connect(client, &CalDavClient::taskSaved, this,
5708 [ + - ]: 2 : &MainWindow::onCalDavTaskWriteSucceeded);
5709 [ + + ]: 2 : if (wasNew)
5710 [ + - ]: 1 : client->createTask(task.calendarPath, task);
5711 : : else
5712 [ + - ]: 1 : client->updateTask(task);
5713 : 2 : connect(client, &CalDavClient::writeFailed, this,
5714 [ + - + - : 4 : [this, task, previous](const QString &err) {
- - ]
5715 [ + + ]: 2 : if (previous) {
5716 : 1 : m_calendarStore->upsertTask(*previous);
5717 : : } else {
5718 : 1 : m_calendarStore->deleteTask(task.uid, task.accountId,
5719 : 1 : task.calendarPath);
5720 : : }
5721 [ - + ]: 2 : if (m_taskListWidget)
5722 : 0 : m_taskListWidget->reload();
5723 [ + - ]: 2 : setStatus(QStringLiteral("caldav"),
5724 [ + - + - ]: 6 : tr("\u2699 Sync error: %1").arg(err), 5000);
5725 : 2 : });
5726 : : }
5727 : 5 : }
5728 : :
5729 : 2 : void MainWindow::onCalDavEventWriteSucceeded(const CalendarEvent &event) {
5730 [ - + ]: 2 : if (!m_calendarStore)
5731 : 0 : return;
5732 : :
5733 : 2 : m_calendarStore->upsertEvent(event);
5734 [ - + ]: 2 : if (m_calendarWidget)
5735 [ # # ]: 0 : m_calendarWidget->navigateToDate(m_calendarWidget->selectedDate());
5736 [ + - + - ]: 4 : setStatus(QStringLiteral("caldav"), tr("Calendar saved"), 3000);
5737 : : }
5738 : :
5739 : 2 : void MainWindow::onCalDavTaskWriteSucceeded(const CalendarTask &task) {
5740 [ - + ]: 2 : if (!m_calendarStore)
5741 : 0 : return;
5742 : :
5743 : 2 : m_calendarStore->upsertTask(task);
5744 [ - + ]: 2 : if (m_taskListWidget)
5745 : 0 : m_taskListWidget->reload();
5746 [ + - + - ]: 4 : setStatus(QStringLiteral("caldav"), tr("Task saved"), 3000);
5747 : : }
5748 : :
5749 : 2 : void MainWindow::onTaskDeleted(const CalendarTask &task) {
5750 [ + - ]: 2 : const auto previous = findStoredTask(m_calendarStore, task);
5751 : : // 1. Remove from local store
5752 [ + - ]: 2 : m_calendarStore->deleteTask(task.uid, task.accountId, task.calendarPath);
5753 : : // 2. Refresh task list UI
5754 [ - + ]: 2 : if (m_taskListWidget)
5755 [ # # ]: 0 : m_taskListWidget->reload();
5756 : : // 3. DELETE on CalDAV server
5757 [ + - ]: 2 : auto *client = createCalDavWriteClient(task.calendarPath, task.accountId);
5758 [ + + ]: 2 : if (client) {
5759 [ + - ]: 1 : client->deleteTask(task);
5760 [ + - ]: 1 : connect(client, &CalDavClient::writeFailed, this,
5761 [ + - ]: 2 : [this, previous](const QString &err) {
5762 [ + - ]: 1 : if (previous) {
5763 : 1 : m_calendarStore->upsertTask(*previous);
5764 [ - + ]: 1 : if (m_taskListWidget)
5765 : 0 : m_taskListWidget->reload();
5766 : : }
5767 [ + - ]: 1 : setStatus(QStringLiteral("caldav"),
5768 [ + - + - ]: 3 : tr("\u2699 Sync error: %1").arg(err), 5000);
5769 : 1 : });
5770 : : }
5771 [ + - + - ]: 4 : setStatus(QStringLiteral("task"), tr("Task deleted"), 3000);
5772 : 2 : }
|