aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-07 18:29:58 +0200
committerDanilo M. <danix@danix.xyz>2026-08-07 18:29:58 +0200
commitfbc65d2413190faa873d6b0b5cb9ed31ebba453b (patch)
tree3a85bbd06e153bac58e6d0fbed6364a50458b0d7 /src
parent48c243d7aee3382713f7a4653be2179d313b432c (diff)
downloadqtmaildir-fbc65d2413190faa873d6b0b5cb9ed31ebba453b.tar.gz
qtmaildir-fbc65d2413190faa873d6b0b5cb9ed31ebba453b.zip
feat(ui): add a Maildir overview under Help
Nothing in the UI reported database-level facts: every query gave a thread count for that query, and nothing said how much mail there is overall. A dialog under Help now shows messages, threads and tags from notmuch, plus the account list from config, since notmuch does not model accounts at all. A separate worker call rather than a reuse of requestCounts, which counts threads to match the row count of a query. This counts messages, which is what a user means by "how much mail is in here". The test pins 4 messages in 3 threads against the fixture and fails if they are ever made equal, so routing both through one count cannot pass unnoticed. Every field starts at -1 and renders as "unknown" when notmuch could not answer it. Printing 0 would say the Maildir is empty, and telling someone their mail is gone is the worst way to report an index that failed to open. The dialog opens showing "Counting..." rather than blocking, since counting every message is not free on a large database. That makes two lifetimes matter: the reply can arrive after the dialog is closed, so the label is a QPointer, and the dialog can be closed and reopened while a count runs, so a generation counter drops the older answer. The test drains DeferredDelete before firing the late reply, because close() deletes through deleteLater and without that the dangling case is never actually reached.
Diffstat (limited to 'src')
-rw-r--r--src/mainwindow.cpp93
-rw-r--r--src/mainwindow.h33
-rw-r--r--src/notmuchworker.cpp42
-rw-r--r--src/notmuchworker.h17
-rw-r--r--src/types.h14
5 files changed, 199 insertions, 0 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 3f8a53d..0ce94a8 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -288,6 +288,7 @@ MainWindow::MainWindow(const Config &config, QWidget *parent)
qRegisterMetaType<ThreadSummary>();
qRegisterMetaType<MessageRef>();
qRegisterMetaType<TagChange>();
+ qRegisterMetaType<DatabaseStats>();
qRegisterMetaType<QVector<ThreadSummary>>();
qRegisterMetaType<QVector<MessageRef>>();
@@ -885,6 +886,13 @@ void MainWindow::buildMenus()
auto *shortcuts = helpMenu->addAction(tr("&Keyboard shortcuts"));
connect(shortcuts, &QAction::triggered,
this, &MainWindow::showShortcutReference);
+ // A dialog the user asks for, per item 34: counting every message is not
+ // free on a large database, so this must not be anything that refreshes on
+ // its own.
+ auto *maildirInfo = helpMenu->addAction(tr("&Maildir overview"));
+ maildirInfo->setObjectName(QStringLiteral("maildirOverview"));
+ connect(maildirInfo, &QAction::triggered,
+ this, &MainWindow::showMaildirOverview);
auto *about = helpMenu->addAction(tr("&About"));
connect(about, &QAction::triggered, this, &MainWindow::showAbout);
@@ -1031,6 +1039,89 @@ void MainWindow::showShortcutReference()
dialog.exec();
}
+void MainWindow::showMaildirOverview()
+{
+ auto *dialog = new QDialog(this);
+ dialog->setWindowTitle(tr("Maildir overview"));
+ dialog->setObjectName(QStringLiteral("maildirOverviewDialog"));
+ // Deleted on close, which is what makes m_overviewCounts a QPointer: the
+ // worker's reply can arrive after the user has dismissed it.
+ dialog->setAttribute(Qt::WA_DeleteOnClose);
+
+ auto *counts = new QLabel(dialog);
+ counts->setObjectName(QStringLiteral("maildirCounts"));
+ counts->setTextFormat(Qt::RichText);
+ // Shown as pending rather than as zero. The dialog opens before the answer
+ // arrives, and a zero would read as "no mail", which is a claim rather than
+ // an absence of one.
+ counts->setText(tr("<b>Counting...</b>"));
+ m_overviewCounts = counts;
+
+ // From config, never from notmuch, which does not model accounts at all.
+ // That is the whole reason per-account subdirectories are configured.
+ QString accountText;
+ const QList<Account> accounts = m_config.accounts();
+ accountText += tr("<b>%n account(s)</b>", "", int(accounts.size()));
+ if (!accounts.isEmpty()) {
+ accountText += QStringLiteral("<ul>");
+ for (const Account &account : accounts) {
+ // Account names are user-written config, and this label is rich
+ // text, so they are escaped like any other untrusted value.
+ const QString label = account.label.isEmpty() ? account.key
+ : account.label;
+ accountText += QStringLiteral("<li>%1</li>")
+ .arg(label.toHtmlEscaped());
+ }
+ accountText += QStringLiteral("</ul>");
+ }
+
+ auto *accountLabel = new QLabel(accountText, dialog);
+ accountLabel->setObjectName(QStringLiteral("maildirAccounts"));
+ accountLabel->setTextFormat(Qt::RichText);
+
+ auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, dialog);
+ connect(buttons, &QDialogButtonBox::rejected, dialog, &QDialog::reject);
+
+ auto *layout = new QVBoxLayout(dialog);
+ layout->addWidget(counts);
+ layout->addWidget(accountLabel);
+ layout->addStretch();
+ layout->addWidget(buttons);
+
+ // Asked for when the dialog opens and never on a timer: counting every
+ // message in a large database is not free, which is the constraint that
+ // made this a dialog rather than a status-bar field.
+ QMetaObject::invokeMethod(m_worker, "requestDatabaseStats",
+ Qt::QueuedConnection,
+ Q_ARG(quint64, ++m_statsGeneration));
+
+ dialog->show();
+}
+
+void MainWindow::onDatabaseStatsReady(const DatabaseStats &stats,
+ quint64 generation)
+{
+ // Closed and reopened while the count ran: this answer belongs to the old
+ // dialog. The QPointer covers "closed", this covers "closed and reopened".
+ if (generation != m_statsGeneration)
+ return;
+
+ if (!m_overviewCounts)
+ return;
+
+ // A field notmuch could not answer stays unknown. Printing 0 would say the
+ // database is empty, which is the opposite of "we could not tell".
+ const auto number = [](int value) {
+ return value < 0 ? tr("unknown") : QLocale().toString(value);
+ };
+
+ m_overviewCounts->setText(
+ tr("<b>%1</b> messages in <b>%2</b> threads<br>"
+ "<b>%3</b> tags")
+ .arg(number(stats.messages), number(stats.threads),
+ number(stats.tags)));
+}
+
void MainWindow::showAbout()
{
QDialog dialog(this);
@@ -1098,6 +1189,8 @@ void MainWindow::wireWorker()
this, &MainWindow::onAllTagsReady);
connect(m_worker, &NotmuchWorker::countsReady,
this, &MainWindow::onCountsReady);
+ connect(m_worker, &NotmuchWorker::databaseStatsReady,
+ this, &MainWindow::onDatabaseStatsReady);
// A confirmed write clears the pending revert: without this, a later
// unrelated error would roll back a change that actually succeeded.
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 8043727..579597c 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -20,6 +20,7 @@
#include <QHash>
#include <QMainWindow>
+#include <QPointer>
#include <QThread>
#include <QUndoCommand>
#include <QUndoStack>
@@ -133,6 +134,13 @@ public:
/// stale, so a test standing in for the worker has to know the current one.
quint64 currentGenerationForTesting() const { return m_generation; }
+ /// The generation a database-stats reply must carry to be accepted.
+ ///
+ /// A test seam, for the same reason as the one above: onDatabaseStatsReady
+ /// discards a reply belonging to a dialog that has since been closed and
+ /// reopened, so a test standing in for the worker needs the current value.
+ quint64 statsGenerationForTesting() const { return m_statsGeneration; }
+
protected:
void closeEvent(QCloseEvent *event) override;
@@ -189,6 +197,10 @@ private slots:
/// requestPlaceholderCounts() asked for them.
void onCountsReady(const QVector<int> &counts, quint64 generation);
+ /// Fills in the overview dialog's counts when the worker answers. Does
+ /// nothing if the dialog has since been closed.
+ void onDatabaseStatsReady(const DatabaseStats &stats, quint64 generation);
+
/// Runs a query the user clicked on the placeholder pane.
void onPlaceholderQueryRequested(const QString &query);
@@ -225,6 +237,16 @@ private:
void showShortcutReference();
void showAbout();
+ /// The Maildir overview (item 34): what notmuch knows about the database,
+ /// plus the account list, which comes from config since notmuch does not
+ /// model accounts at all.
+ ///
+ /// Opens immediately showing the counts as pending and fills them in when
+ /// the worker answers, rather than blocking: counting every message is not
+ /// free on a large database and a dialog that hangs first is worse than one
+ /// that populates.
+ void showMaildirOverview();
+
/// Creates a QAction, binds it to the sequence KeyMap holds for `name`,
/// and registers it. `name` is the action name used in [keys].
QAction *addAction(const QString &name, const QString &text,
@@ -422,6 +444,17 @@ private:
/// attention, so it must survive until the next successful run.
bool m_lastSyncFailed = false;
+ /// The overview dialog's counts label while that dialog is open, null
+ /// otherwise. A QPointer because the dialog is deleted on close and the
+ /// worker's reply can arrive afterwards: a raw pointer would dangle for
+ /// exactly as long as the count takes on a large database, which is
+ /// precisely when the user is most likely to close it first.
+ QPointer<QLabel> m_overviewCounts;
+
+ /// Discriminates a stats reply from a dialog that has since been closed
+ /// and reopened, so an old answer cannot fill in a newer dialog.
+ quint64 m_statsGeneration = 0;
+
/// Holds the sync log and its close button, so the pane can be dismissed.
QWidget *m_syncLogPane = nullptr;
QPlainTextEdit *m_syncLog = nullptr;
diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp
index 973d110..752a52c 100644
--- a/src/notmuchworker.cpp
+++ b/src/notmuchworker.cpp
@@ -363,6 +363,48 @@ void NotmuchWorker::requestAllTags(quint64 generation)
emit allTagsReady(result, generation);
}
+void NotmuchWorker::requestDatabaseStats(quint64 generation)
+{
+ if (!openReadOnly())
+ return;
+
+ DatabaseStats stats;
+
+ // "*" is notmuch's match-everything query. Counting messages and threads
+ // needs two calls on it: the numbers differ by the reply depth of the
+ // database and there is no single call that yields both.
+ NmQuery all(notmuch_query_create(m_db, "*"));
+ if (all) {
+ unsigned int messages = 0;
+ if (notmuch_query_count_messages(all.get(), &messages)
+ == NOTMUCH_STATUS_SUCCESS) {
+ stats.messages = static_cast<int>(messages);
+ }
+ }
+
+ // A second query object rather than reusing the one above: notmuch caches
+ // results on a query, and counting both ways from one has bitten people.
+ NmQuery allThreads(notmuch_query_create(m_db, "*"));
+ if (allThreads) {
+ unsigned int threads = 0;
+ if (notmuch_query_count_threads(allThreads.get(), &threads)
+ == NOTMUCH_STATUS_SUCCESS) {
+ stats.threads = static_cast<int>(threads);
+ }
+ }
+
+ // Already enumerated for the completer, so this costs nothing extra.
+ NmTags tags(notmuch_database_get_all_tags(m_db));
+ if (tags) {
+ int count = 0;
+ for (; notmuch_tags_valid(tags.get()); notmuch_tags_move_to_next(tags.get()))
+ ++count;
+ stats.tags = count;
+ }
+
+ emit databaseStatsReady(stats, generation);
+}
+
void NotmuchWorker::requestCounts(const QStringList &queries, quint64 generation)
{
if (!openReadOnly())
diff --git a/src/notmuchworker.h b/src/notmuchworker.h
index 05c1d3a..7d6a587 100644
--- a/src/notmuchworker.h
+++ b/src/notmuchworker.h
@@ -83,6 +83,19 @@ public slots:
/// refreshing one nobody is looking at is work for nothing.
void requestCounts(const QStringList &queries, quint64 generation);
+ /// Database-level facts for the Maildir overview (item 34): total messages,
+ /// total threads, and the number of tags.
+ ///
+ /// **Messages, not threads**, which is what distinguishes this from
+ /// requestCounts above. That one answers "how many rows will this query
+ /// produce" and counts threads to match the list; this one describes the
+ /// database, where the message total is the number a user means by "how
+ /// much mail is in here".
+ ///
+ /// Counting every message is not free on a large database, so this is
+ /// called when the dialog is opened and never on a timer.
+ void requestDatabaseStats(quint64 generation);
+
signals:
void threadsReady(const QVector<ThreadSummary> &threads, quint64 generation);
void queryFinished(int totalThreads, quint64 generation);
@@ -95,6 +108,10 @@ signals:
/// positional correspondence the caller relies on always holds.
void countsReady(const QVector<int> &counts, quint64 generation);
+ /// Fields left at -1 are ones notmuch could not answer, which the dialog
+ /// renders as unknown rather than as zero.
+ void databaseStatsReady(const DatabaseStats &stats, quint64 generation);
+
void errorOccurred(const QString &message);
private:
diff --git a/src/types.h b/src/types.h
index 59d1d06..999d3f3 100644
--- a/src/types.h
+++ b/src/types.h
@@ -78,6 +78,20 @@ struct TagChange
}
};
+/// Database-level facts for the Maildir overview.
+///
+/// Every field is -1 until answered, so a dialog opened against a database that
+/// cannot be read shows "unknown" rather than a confident zero. A zero is a
+/// claim, and "no mail at all" is exactly the wrong thing to tell someone whose
+/// index failed to open.
+struct DatabaseStats
+{
+ int messages = -1; ///< Every message notmuch has indexed.
+ int threads = -1; ///< Every thread. Differs from messages by reply depth.
+ int tags = -1; ///< Distinct tag names in the database.
+};
+
Q_DECLARE_METATYPE(ThreadSummary)
Q_DECLARE_METATYPE(MessageRef)
Q_DECLARE_METATYPE(TagChange)
+Q_DECLARE_METATYPE(DatabaseStats)