diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/mainwindow.cpp | 93 | ||||
| -rw-r--r-- | src/mainwindow.h | 33 | ||||
| -rw-r--r-- | src/notmuchworker.cpp | 42 | ||||
| -rw-r--r-- | src/notmuchworker.h | 17 | ||||
| -rw-r--r-- | src/types.h | 14 |
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) |
