diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-03 09:15:07 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-03 09:15:07 +0200 |
| commit | 633ae0c09a4a3bb1cfdf15b36328d8e2ced55d30 (patch) | |
| tree | 646d082553a3010d991c4479bc7294c456b0d6a3 | |
| parent | e7dceee1f449cafd07a86dfd11ff77f35897bdcf (diff) | |
| download | qtmaildir-633ae0c09a4a3bb1cfdf15b36328d8e2ced55d30.tar.gz qtmaildir-633ae0c09a4a3bb1cfdf15b36328d8e2ced55d30.zip | |
feat: add MainWindow wiring query, list, message, and sync
Wires the worker thread, thread list, message pane, sync process and undo
stack together, and replaces the placeholder main() with real startup:
custom URL schemes registered before QApplication, a libnotmuch ABI check,
and config loading.
Four fixes against the drafted version:
- onWorkerError() only set a status label. Its own comment elsewhere
claimed it reverted the optimistic update, and the spec requires that;
it did not, so a rejected write left the list showing a tag the database
never received. The pending change is now recorded and rolled back, and
a confirmed tagsApplied clears it so a later unrelated error cannot undo
a write that succeeded.
- runCurrentQuery() cleared the model but left the undo stack pointing at
rows that no longer exist. Undoing after a new query would have written
to the database while the visible list stayed put. The stack is cleared
with the model.
- m_currentMessages was assigned on every thread load and never read.
Removed.
- buildUi() connected sync output to m_syncLog and errors to m_statusLabel
before either existed. Both are constructed before the wiring now.
cidPrefix generation lives here, this being its only producer in the
application, and is pinned by tests: it must never contain '!' and must be
distinct per message, which are the invariants the cid: namespacing rests
on. A second test holds registeredActionNames() against
KeyMap::knownActions(), since those two hand-maintained lists drifting
either way silently breaks a user's key binding. Mutation-verified that
dropping an action fails the test by name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| -rw-r--r-- | src/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | src/main.cpp | 39 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 495 | ||||
| -rw-r--r-- | src/mainwindow.h | 148 | ||||
| -rw-r--r-- | tests/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 75 |
6 files changed, 756 insertions, 3 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0073870..9f2278b 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -10,6 +10,7 @@ add_library(qtmaildir_lib STATIC mailsync.cpp threadcidmap.cpp messageview.cpp + mainwindow.cpp ) target_include_directories(qtmaildir_lib diff --git a/src/main.cpp b/src/main.cpp index 9c7b977..2c5dc13 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,10 +1,43 @@ #include <QApplication> -#include <QLabel> +#include <QMessageBox> +#include <QWebEngineUrlScheme> + +#include <notmuch.h> + +#include "config.h" +#include "mainwindow.h" int main(int argc, char *argv[]) { + // Custom schemes must be registered before QApplication is constructed. + { + QWebEngineUrlScheme scheme(QByteArrayLiteral("cid")); + scheme.setFlags(QWebEngineUrlScheme::SecureScheme + | QWebEngineUrlScheme::ContentSecurityPolicyIgnored); + QWebEngineUrlScheme::registerScheme(scheme); + } + { + QWebEngineUrlScheme scheme(QByteArrayLiteral("qtmaildir")); + scheme.setFlags(QWebEngineUrlScheme::SecureScheme); + QWebEngineUrlScheme::registerScheme(scheme); + } + QApplication app(argc, argv); - QLabel label(QStringLiteral("qtmaildir")); - label.show(); + app.setApplicationName(QStringLiteral("qtmaildir")); + app.setOrganizationName(QStringLiteral("qtmaildir")); + + // Fail loudly on an ABI mismatch rather than crashing later. + if (LIBNOTMUCH_MAJOR_VERSION < 5) { + QMessageBox::critical(nullptr, QObject::tr("qtmaildir"), + QObject::tr("libnotmuch 5 or newer is required.")); + return 1; + } + + Config config; + config.load(Config::defaultPath()); + + MainWindow window(config); + window.show(); + return app.exec(); } diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp new file mode 100644 index 0000000..ed16010 --- /dev/null +++ b/src/mainwindow.cpp @@ -0,0 +1,495 @@ +#include "mainwindow.h" + +#include <QComboBox> +#include <QEvent> +#include <QHBoxLayout> +#include <QHeaderView> +#include <QKeyEvent> +#include <QLabel> +#include <QLineEdit> +#include <QMessageBox> +#include <QPlainTextEdit> +#include <QPushButton> +#include <QSettings> +#include <QSplitter> +#include <QStatusBar> +#include <QTableView> +#include <QVBoxLayout> + +#include "mailsync.h" +#include "messageview.h" +#include "mimeparser.h" +#include "notmuchworker.h" +#include "threadlistmodel.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<index>" 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<ThreadSummary>(); + qRegisterMetaType<MessageRef>(); + qRegisterMetaType<TagChange>(); + qRegisterMetaType<QVector<ThreadSummary>>(); + qRegisterMetaType<QVector<MessageRef>>(); + + m_keyMap.loadDefaults(); + { + QSettings settings(Config::defaultPath(), QSettings::IniFormat); + m_keyMap.loadOverrides(settings); + } + + buildUi(); + registerActions(); + wireWorker(); + showWarnings(); + + 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(tr("qtmaildir")); +} + +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() +{ + 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("%1 configuration warning(s); see Help").arg(warnings.size())); + QMessageBox::warning(this, tr("Configuration warnings"), + warnings.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<ThreadSummary> &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<MessageRef> &messages, + quint64 generation) +{ + if (generation != m_generation || messages.isEmpty()) + return; + + MimeParser parser; + QList<ThreadRenderItem> 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<QKeyEvent *>(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; +} diff --git a/src/mainwindow.h b/src/mainwindow.h new file mode 100644 index 0000000..bbfc3ec --- /dev/null +++ b/src/mainwindow.h @@ -0,0 +1,148 @@ +#pragma once + +#include <QHash> +#include <QMainWindow> +#include <QThread> +#include <QUndoCommand> +#include <QUndoStack> + +#include <functional> + +#include "config.h" +#include "keymap.h" +#include "types.h" + +class QLineEdit; +class QTableView; +class QLabel; +class QPushButton; +class QComboBox; +class QPlainTextEdit; + +class ThreadListModel; +class MessageView; +class MailSync; +class NotmuchWorker; + +class MainWindow : public QMainWindow +{ + Q_OBJECT +public: + explicit MainWindow(const Config &config, QWidget *parent = nullptr); + ~MainWindow() override; + + /// Every action name registerActions() installs. Exposed so a test can hold + /// it against KeyMap::knownActions(): the two lists are maintained by hand, + /// and a drift either way silently breaks a user's key binding. + static QStringList registeredActionNames(); + + /// The cid: namespace prefix for the nth message of a thread. + /// + /// MainWindow is the only producer of this value in the application. It + /// must never contain '!', which is the separator that keeps one message's + /// cid: references from resolving to another's. + static QString cidPrefixForIndex(int index); + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; + +private slots: + void runCurrentQuery(); + void onThreadsReady(const QVector<ThreadSummary> &threads, quint64 generation); + void onQueryFinished(int total, quint64 generation); + void onThreadSelected(const QModelIndex ¤t, const QModelIndex &previous); + void onThreadLoaded(const QVector<MessageRef> &messages, quint64 generation); + void onWorkerError(const QString &message); + void onSyncFinished(bool success, int exitCode); + +private: + void buildUi(); + void registerActions(); + void wireWorker(); + void showWarnings(); + + void tagSelected(const QStringList &add, const QStringList &remove, + const QString &description); + + /// Sends a tag change for a set of threads without touching the undo stack. + /// Both tagSelected() and ThreadTagCommand route through this. + void sendThreadTagChange(const QStringList &threadIds, + const QStringList &add, + const QStringList &remove, + const QString &description); + + /// Undoes the optimistic model update for a write the worker rejected. + void revertPendingTagChange(); + + friend class ThreadTagCommand; + + Config m_config; + KeyMap m_keyMap; + + QThread m_workerThread; + NotmuchWorker *m_worker = nullptr; + + ThreadListModel *m_model = nullptr; + MessageView *m_messageView = nullptr; + MailSync *m_sync = nullptr; + QUndoStack m_undoStack; + + QLineEdit *m_queryEdit = nullptr; + QTableView *m_threadView = nullptr; + QComboBox *m_accountBox = nullptr; + QPushButton *m_syncButton = nullptr; + QLabel *m_statusLabel = nullptr; + QPlainTextEdit *m_syncLog = nullptr; + + QHash<QString, std::function<void()>> m_actions; + quint64 m_generation = 0; + QString m_lastQuery; + QString m_currentThreadId; + + /// The optimistic update awaiting confirmation, kept so a worker error can + /// put the model back. Only the most recent one: mutations are sent from + /// the UI thread one user action at a time. + TagChange m_pendingChange; + QStringList m_pendingThreadIds; +}; + +/// Undo entry for a tag change over a set of threads. +/// +/// Stores thread ids rather than message ids, so undo re-resolves them on the +/// worker and stays correct even if the selection has moved on. +class ThreadTagCommand : public QUndoCommand +{ +public: + ThreadTagCommand(MainWindow *window, const QStringList &threadIds, + const QStringList &add, const QStringList &remove, + const QString &description) + : QUndoCommand(description), m_window(window), m_threadIds(threadIds), + m_add(add), m_remove(remove), m_description(description) {} + + /// The stack calls redo() when the command is pushed. The change has + /// already been sent by that point, so the first call is skipped. + void redo() override + { + if (m_firstRedo) { + m_firstRedo = false; + return; + } + m_window->sendThreadTagChange(m_threadIds, m_add, m_remove, + m_description); + } + + void undo() override + { + // Inverted: what was added is removed and vice versa. + m_window->sendThreadTagChange(m_threadIds, m_remove, m_add, + QStringLiteral("Undo %1").arg(m_description)); + } + +private: + MainWindow *m_window; + QStringList m_threadIds; + QStringList m_add; + QStringList m_remove; + QString m_description; + bool m_firstRedo = true; +}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d92bc6a..8ff07b6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,3 +16,4 @@ add_qtmaildir_test(notmuchworker) add_qtmaildir_test(threadlistmodel) add_qtmaildir_test(mailsync) add_qtmaildir_test(threadcidmap) +add_qtmaildir_test(mainwindow) diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp new file mode 100644 index 0000000..7b84a60 --- /dev/null +++ b/tests/test_mainwindow.cpp @@ -0,0 +1,75 @@ +#include <QtTest> + +#include "keymap.h" +#include "mainwindow.h" + +/// MainWindow is mostly wiring and needs a live QApplication plus a real +/// database, so it is verified manually in Task 13. Two things do not need +/// either, and both are the kind of drift a comment alone does not prevent. +class TestMainWindow : public QObject +{ + Q_OBJECT +private slots: + void everyKnownActionIsRegistered(); + void everyRegisteredActionIsKnown(); + void cidPrefixesAreBangFree(); + void cidPrefixesAreDistinctPerMessage(); +}; + +void TestMainWindow::everyKnownActionIsRegistered() +{ + // KeyMap::knownActions() is what loadOverrides() validates config bindings + // against. An action listed there but never registered means a user can + // bind a key in qtmaildir.conf, get no warning, and have it do nothing. + const QStringList known = KeyMap::knownActions(); + const QStringList registered = MainWindow::registeredActionNames(); + + for (const QString &action : known) { + QVERIFY2(registered.contains(action), + qPrintable(QStringLiteral("known action '%1' is never registered " + "by MainWindow").arg(action))); + } +} + +void TestMainWindow::everyRegisteredActionIsKnown() +{ + // The reverse drift: an action MainWindow implements but KeyMap rejects. + // The user would get "unknown action" for a binding that is really there. + const QStringList known = KeyMap::knownActions(); + const QStringList registered = MainWindow::registeredActionNames(); + + for (const QString &action : registered) { + QVERIFY2(known.contains(action), + qPrintable(QStringLiteral("registered action '%1' is not in " + "KeyMap::knownActions()").arg(action))); + } +} + +void TestMainWindow::cidPrefixesAreBangFree() +{ + // MainWindow is the only producer of cidPrefix in the application. The + // '!' separator that keeps two messages' cid: references apart is only + // unambiguous while the prefix half contains none. + for (int i : { 0, 1, 9, 10, 99, 1000 }) { + const QString prefix = MainWindow::cidPrefixForIndex(i); + QVERIFY(!prefix.isEmpty()); + QVERIFY2(!prefix.contains(QLatin1Char('!')), + qPrintable(QStringLiteral("prefix '%1' contains '!'").arg(prefix))); + } +} + +void TestMainWindow::cidPrefixesAreDistinctPerMessage() +{ + // Two messages sharing a prefix would share a cid: namespace, which is the + // collision the namespacing exists to prevent. + QSet<QString> seen; + for (int i = 0; i < 200; ++i) { + const QString prefix = MainWindow::cidPrefixForIndex(i); + QVERIFY2(!seen.contains(prefix), + qPrintable(QStringLiteral("prefix '%1' repeats").arg(prefix))); + seen.insert(prefix); + } +} + +QTEST_MAIN(TestMainWindow) +#include "test_mainwindow.moc" |
