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