aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-03 09:15:07 +0200
committerDanilo M. <danix@danix.xyz>2026-08-03 09:15:07 +0200
commit633ae0c09a4a3bb1cfdf15b36328d8e2ced55d30 (patch)
tree646d082553a3010d991c4479bc7294c456b0d6a3
parente7dceee1f449cafd07a86dfd11ff77f35897bdcf (diff)
downloadqtmaildir-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.txt1
-rw-r--r--src/main.cpp39
-rw-r--r--src/mainwindow.cpp495
-rw-r--r--src/mainwindow.h148
-rw-r--r--tests/CMakeLists.txt1
-rw-r--r--tests/test_mainwindow.cpp75
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 &current,
+ 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 &current, 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"