/* * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs * Copyright (C) 2026 Danilo M. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License version 2 as * published by the Free Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. */ #include "mainwindow.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "mailsync.h" #include "messageview.h" #include "mimeparser.h" #include "notmuchworker.h" #include "threadlistmodel.h" #include "version.h" QStringList MainWindow::registeredActionNames() { // Keep in sync with registerActions(). Held against KeyMap::knownActions() // by a test rather than by hope. return { QStringLiteral("next_thread"), QStringLiteral("prev_thread"), QStringLiteral("open_thread"), QStringLiteral("archive"), QStringLiteral("delete"), QStringLiteral("spam"), QStringLiteral("toggle_unread"), QStringLiteral("flag"), QStringLiteral("focus_query"), QStringLiteral("toggle_html"), QStringLiteral("load_remote"), QStringLiteral("undo"), QStringLiteral("sync"), QStringLiteral("quit"), }; } QString MainWindow::cidPrefixForIndex(int index) { // "m" is digits only after the 'm', so it cannot contain '!'. return QStringLiteral("m%1").arg(index); } MainWindow::MainWindow(const Config &config, QWidget *parent) : QMainWindow(parent), m_config(config) { qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType(); qRegisterMetaType>(); qRegisterMetaType>(); m_keyMap.loadDefaults(); { QSettings settings(Config::defaultPath(), QSettings::IniFormat); m_keyMap.loadOverrides(settings); } buildUi(); registerActions(); wireWorker(); showWarnings(); installEventFilter(this); // The thread view needs its own filter, not just the window's. A filter on // the window only sees key presses the focused child did not consume, and // QAbstractItemView consumes plain letters for its type-to-search feature: // with the list focused, 'h' jumped to the next thread whose subject began // with "h" instead of toggling HTML, and every other single-letter binding // (j, k, a, d, N, F, u, G) was swallowed the same way. Filtering the view // itself puts the keymap ahead of that search. m_threadView->installEventFilter(this); if (!m_config.savedQueries().isEmpty()) { m_queryEdit->setText(m_config.savedQueries().first().query); runCurrentQuery(); } } MainWindow::~MainWindow() { m_workerThread.quit(); m_workerThread.wait(); } void MainWindow::buildUi() { auto *central = new QWidget(this); auto *layout = new QVBoxLayout(central); // The status label is created first: the sync wiring below can report into // it before the rest of the UI exists. m_statusLabel = new QLabel(this); statusBar()->addWidget(m_statusLabel); // Query row. auto *queryRow = new QHBoxLayout; m_accountBox = new QComboBox(central); m_accountBox->addItem(tr("All accounts"), QString()); for (const Account &account : m_config.accounts()) m_accountBox->addItem(account.key, account.key); m_queryEdit = new QLineEdit(central); m_queryEdit->setPlaceholderText(tr("notmuch query, e.g. tag:inbox")); connect(m_queryEdit, &QLineEdit::returnPressed, this, &MainWindow::runCurrentQuery); m_syncLog = new QPlainTextEdit(central); m_syncLog->setReadOnly(true); m_syncLog->setMaximumHeight(120); m_syncLog->hide(); m_syncButton = new QPushButton(tr("Sync"), central); m_sync = new MailSync(m_config.syncCommand(), this); m_syncButton->setEnabled(m_sync->isAvailable()); if (!m_sync->isAvailable()) { m_syncButton->setToolTip( tr("No sync command configured ([sync] command in qtmaildir.conf)")); } connect(m_syncButton, &QPushButton::clicked, this, [this]() { if (!m_sync->start()) m_statusLabel->setText(tr("Sync already running")); }); connect(m_sync, &MailSync::finished, this, &MainWindow::onSyncFinished); connect(m_sync, &MailSync::outputReceived, this, [this](const QString &chunk) { m_syncLog->appendPlainText(chunk.trimmed()); }); queryRow->addWidget(m_accountBox); queryRow->addWidget(m_queryEdit, 1); queryRow->addWidget(m_syncButton); layout->addLayout(queryRow); // Saved query buttons. auto *savedRow = new QHBoxLayout; for (const SavedQuery &saved : m_config.savedQueries()) { auto *button = new QPushButton(saved.name, central); connect(button, &QPushButton::clicked, this, [this, saved]() { m_queryEdit->setText(saved.query); runCurrentQuery(); }); savedRow->addWidget(button); } savedRow->addStretch(); layout->addLayout(savedRow); // Thread list and message pane. m_model = new ThreadListModel(this); m_threadView = new QTableView(central); m_threadView->setModel(m_model); m_threadView->setSelectionBehavior(QAbstractItemView::SelectRows); m_threadView->setSelectionMode(QAbstractItemView::ExtendedSelection); m_threadView->verticalHeader()->hide(); m_threadView->horizontalHeader()->setStretchLastSection(false); m_threadView->horizontalHeader()->setSectionResizeMode( ThreadListModel::SubjectColumn, QHeaderView::Stretch); connect(m_threadView->selectionModel(), &QItemSelectionModel::currentRowChanged, this, &MainWindow::onThreadSelected); m_messageView = new MessageView(central); connect(m_messageView, &MessageView::statusMessage, this, [this](const QString &text) { m_statusLabel->setText(text); }); auto *splitter = new QSplitter(Qt::Horizontal, central); splitter->addWidget(m_threadView); splitter->addWidget(m_messageView); splitter->setStretchFactor(1, 2); layout->addWidget(splitter, 1); layout->addWidget(m_syncLog); setCentralWidget(central); resize(1200, 800); setWindowTitle(QStringLiteral("qtmaildir %1").arg(QTMAILDIR_VERSION)); } void MainWindow::registerActions() { m_actions[QStringLiteral("focus_query")] = [this]() { m_queryEdit->setFocus(); m_queryEdit->selectAll(); }; m_actions[QStringLiteral("next_thread")] = [this]() { const QModelIndex current = m_threadView->currentIndex(); const int row = current.isValid() ? current.row() + 1 : 0; if (row < m_model->rowCount()) m_threadView->selectRow(row); }; m_actions[QStringLiteral("prev_thread")] = [this]() { const QModelIndex current = m_threadView->currentIndex(); if (current.isValid() && current.row() > 0) m_threadView->selectRow(current.row() - 1); }; m_actions[QStringLiteral("open_thread")] = [this]() { m_threadView->setFocus(); }; m_actions[QStringLiteral("archive")] = [this]() { tagSelected({}, { QStringLiteral("inbox") }, tr("Archive")); }; m_actions[QStringLiteral("delete")] = [this]() { tagSelected({ QStringLiteral("deleted") }, {}, tr("Delete")); }; m_actions[QStringLiteral("spam")] = [this]() { tagSelected({ QStringLiteral("spam") }, { QStringLiteral("inbox") }, tr("Mark spam")); }; m_actions[QStringLiteral("flag")] = [this]() { tagSelected({ QStringLiteral("flagged") }, {}, tr("Flag")); }; m_actions[QStringLiteral("toggle_unread")] = [this]() { // The direction comes from the current row, but the change applies to // the whole selection, so a mixed selection lands in one consistent // state rather than each row flipping its own way. const QModelIndex current = m_threadView->currentIndex(); if (!current.isValid()) return; const ThreadSummary thread = m_model->threadAt(current.row()); if (thread.isUnread()) tagSelected({}, { QStringLiteral("unread") }, tr("Mark read")); else tagSelected({ QStringLiteral("unread") }, {}, tr("Mark unread")); }; m_actions[QStringLiteral("toggle_html")] = [this]() { m_messageView->toggleHtml(); }; m_actions[QStringLiteral("load_remote")] = [this]() { m_messageView->loadRemoteContent(); }; m_actions[QStringLiteral("undo")] = [this]() { if (m_undoStack.canUndo()) m_undoStack.undo(); else m_statusLabel->setText(tr("Nothing to undo")); }; m_actions[QStringLiteral("sync")] = [this]() { if (m_sync->isAvailable()) m_sync->start(); }; m_actions[QStringLiteral("quit")] = [this]() { close(); }; // The two lists are maintained by hand and a test pins them together; this // catches the same drift in a debug run. Q_ASSERT(m_actions.size() == registeredActionNames().size()); } void MainWindow::wireWorker() { m_worker = new NotmuchWorker(m_config.notmuchConfig()); m_worker->moveToThread(&m_workerThread); connect(&m_workerThread, &QThread::finished, m_worker, &QObject::deleteLater); connect(m_worker, &NotmuchWorker::threadsReady, this, &MainWindow::onThreadsReady); connect(m_worker, &NotmuchWorker::queryFinished, this, &MainWindow::onQueryFinished); connect(m_worker, &NotmuchWorker::threadLoaded, this, &MainWindow::onThreadLoaded); connect(m_worker, &NotmuchWorker::errorOccurred, this, &MainWindow::onWorkerError); // A confirmed write clears the pending revert: without this, a later // unrelated error would roll back a change that actually succeeded. connect(m_worker, &NotmuchWorker::tagsApplied, this, [this](const TagChange &) { m_pendingChange = {}; m_pendingThreadIds.clear(); }); m_workerThread.start(); } void MainWindow::showWarnings() { const QStringList warnings = m_config.warnings() + m_keyMap.warnings(); if (warnings.isEmpty()) return; // Non-fatal: the app runs degraded rather than refusing to start. m_statusLabel->setText( tr("%n configuration warning(s)", "", warnings.size())); // Interrupt startup only for things that are actually wrong. Every KeyMap // warning qualifies (each one means a binding the user wrote is being // ignored), but a Config notice such as "no sync command configured" does // not: nothing is broken, the feature is simply off, and a modal on every // launch teaches the user to dismiss dialogs unread. const QStringList problems = m_config.problems() + m_keyMap.warnings(); if (problems.isEmpty()) return; QMessageBox::warning(this, tr("Configuration problems"), problems.join(QLatin1Char('\n'))); } void MainWindow::runCurrentQuery() { QString query = m_queryEdit->text().trimmed(); const QString accountKey = m_accountBox->currentData().toString(); if (!accountKey.isEmpty()) query = m_config.account(accountKey).scopedQuery(query); if (query.isEmpty()) return; // Kept so loadThread() can work out which messages of a thread matched. m_lastQuery = query; ++m_generation; m_model->clear(); m_messageView->clear(); // Undo entries refer to rows that are about to be discarded. The model // update they invert would be a no-op against the new result set, leaving // undo half-applied: the database would change and the list would not. m_undoStack.clear(); m_pendingChange = {}; m_pendingThreadIds.clear(); m_statusLabel->setText(tr("Searching...")); QMetaObject::invokeMethod(m_worker, "runQuery", Qt::QueuedConnection, Q_ARG(QString, query), Q_ARG(quint64, m_generation)); } void MainWindow::onThreadsReady(const QVector &threads, quint64 generation) { if (generation != m_generation) return; // Superseded by a newer query. m_model->appendBatch(threads); } void MainWindow::onQueryFinished(int total, quint64 generation) { if (generation != m_generation) return; m_statusLabel->setText(tr("%n thread(s)", "", total)); } void MainWindow::onThreadSelected(const QModelIndex ¤t, const QModelIndex &) { if (!current.isValid()) return; m_currentThreadId = m_model->threadAt(current.row()).threadId; QMetaObject::invokeMethod(m_worker, "loadThread", Qt::QueuedConnection, Q_ARG(QString, m_currentThreadId), Q_ARG(QString, m_lastQuery), Q_ARG(quint64, m_generation)); } void MainWindow::onThreadLoaded(const QVector &messages, quint64 generation) { if (generation != m_generation || messages.isEmpty()) return; MimeParser parser; QList items; items.reserve(messages.size()); for (int i = 0; i < messages.size(); ++i) { const MessageRef &ref = messages.at(i); ThreadRenderItem item; item.message = parser.parse(ref.filePath); if (!item.message.ok) { // One unreadable message must not lose the rest of the thread, so // it becomes an inline note rather than replacing the whole pane. item.message = {}; item.message.ok = true; item.message.from = tr("(unreadable message)"); item.message.subject = ref.filePath; item.message.plainBody = tr("This message could not be parsed.\n%1").arg(ref.filePath); } // Namespace prefix keeps cid: references distinct across the thread. item.cidPrefix = cidPrefixForIndex(i); // Matched messages open; the rest collapse to a stub. The last message // always opens, so a thread never renders as nothing but stubs. item.expanded = ref.matched || i == messages.size() - 1; items.append(item); } m_messageView->showThread(items); } void MainWindow::revertPendingTagChange() { if (m_pendingThreadIds.isEmpty()) return; // Put the rows back the way they were. Only the model is touched: the // worker never applied the change, so there is nothing to undo there. for (const QString &threadId : m_pendingThreadIds) { m_model->applyTagChange(threadId, m_pendingChange.removed, m_pendingChange.added); } // The undo entry describes a change that never landed, so it would apply a // spurious inverse if the user pressed undo. if (m_undoStack.canUndo()) m_undoStack.undo(); m_undoStack.clear(); m_pendingChange = {}; m_pendingThreadIds.clear(); } void MainWindow::onWorkerError(const QString &message) { // Spec: the UI updates optimistically and reverts if the write fails. // Without this the list would keep showing a tag the database never got. revertPendingTagChange(); m_statusLabel->setText(message); } void MainWindow::onSyncFinished(bool success, int exitCode) { if (success) { m_statusLabel->setText(tr("Sync complete")); runCurrentQuery(); } else { m_statusLabel->setText(tr("Sync failed (exit %1)").arg(exitCode)); m_syncLog->show(); } } void MainWindow::tagSelected(const QStringList &add, const QStringList &remove, const QString &description) { const QModelIndexList rows = m_threadView->selectionModel()->selectedRows(); if (rows.isEmpty()) return; QStringList threadIds; threadIds.reserve(rows.size()); for (const QModelIndex &index : rows) threadIds.append(m_model->threadAt(index.row()).threadId); sendThreadTagChange(threadIds, add, remove, description); // Pushed for undo. The inverse re-resolves the same threads, so it works // whether or not those rows are still selected. m_undoStack.push(new ThreadTagCommand(this, threadIds, add, remove, description)); m_statusLabel->setText( tr("%1: %n thread(s)", "", threadIds.size()).arg(description)); } void MainWindow::sendThreadTagChange(const QStringList &threadIds, const QStringList &add, const QStringList &remove, const QString &description) { // Optimistic: the rows change now, so a bulk archive of hundreds of threads // feels instant. Recorded so onWorkerError() can put them back. for (const QString &threadId : threadIds) m_model->applyTagChange(threadId, add, remove); m_pendingThreadIds = threadIds; m_pendingChange = TagChange{ {}, add, remove, description }; // The worker resolves thread ids to message ids: the UI does not hold // message ids for rows it never opened. QMetaObject::invokeMethod(m_worker, "applyTagsToThreads", Qt::QueuedConnection, Q_ARG(QStringList, threadIds), Q_ARG(QStringList, add), Q_ARG(QStringList, remove), Q_ARG(QString, description)); } bool MainWindow::eventFilter(QObject *watched, QEvent *event) { if (event->type() != QEvent::KeyPress) return QMainWindow::eventFilter(watched, event); // The query bar must receive ordinary typing, so single-key bindings are // suppressed while it has focus. if (m_queryEdit->hasFocus()) return QMainWindow::eventFilter(watched, event); auto *keyEvent = static_cast(event); const QKeySequence sequence(keyEvent->keyCombination()); const QString action = m_keyMap.actionFor(sequence); if (action.isEmpty() || !m_actions.contains(action)) return QMainWindow::eventFilter(watched, event); m_actions.value(action)(); return true; }