summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-11 19:02:59 +0200
committerDanilo M. <danix@danix.xyz>2026-08-11 19:02:59 +0200
commit72812c0ac053a1a841f3864c9e4cce5cb6dab04a (patch)
tree26285a20ab686b1f4bb1e51e0dfbfa16e4307d1b /src
parentddce3f3b198b733bd1815cd11c083f0d5ae0beb7 (diff)
downloadqtmaildir-72812c0ac053a1a841f3864c9e4cce5cb6dab04a.tar.gz
qtmaildir-72812c0ac053a1a841f3864c9e4cce5cb6dab04a.zip
feat(placeholder): count sent mail and drafts on the blank pane
Item 67. The pane counted unread, flagged and inbox from three fixed tag: queries. Sent and drafts cannot join that list as tags: tag:draft counts 0 against a real database and no draft-ish tag exists in it at all, so a tag-based line would be a permanent zero that reads as working code. Both are composed from each account's folder keys instead, the same way the Sent view already composes its query. The drafts key was parsed and documented as unused in v1. Composing drafts is still v2; counting them is not, so Account::draftsQuery() and Config::allDraftsQuery() now mirror the sent pair. The shared body moved into folderQuery() and joinAccountQueries(), so the load-bearing quoting (a provider nests both folders under a bracketed parent, and [ and ] are Xapian syntax) and the bare-"or" guard exist once rather than once per folder type. The fixed array is gone rather than extended. It held queries and labels in two lists indexed in parallel, which is a hazard that grows with the list: an entry inserted in one and not the other prints a real number against the wrong name and looks entirely plausible. placeholderLines() carries each query beside the callable that labels it, so the two cannot drift, and the count reply stays paired by position as the worker requires. A line is omitted when no account configures that folder rather than shown as 0, following item 63: a missing folder is a real configuration, and "0 sent" claims the user has sent nothing. Measured against the real config: 4 sent terms over 601 threads, 5 drafts terms over 3, the extra drafts term coming from the one account that configures drafts and no sent, which is what proves the two are collected independently. Four tests here and four in test_config, mutation-checked at three points: dropping the drafts line, an off-by-one in the label pairing, and removing the -1 guard for an uncountable query. Each mutation fails a test.
Diffstat (limited to 'src')
-rw-r--r--src/config.cpp68
-rw-r--r--src/config.h22
-rw-r--r--src/mainwindow.cpp83
-rw-r--r--src/mainwindow.h45
4 files changed, 170 insertions, 48 deletions
diff --git a/src/config.cpp b/src/config.cpp
index 47fadec..a81651f 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -47,37 +47,67 @@ QString Account::scopedQuery(const QString &query) const
return QStringLiteral("%1 and (%2)").arg(prefix, query);
}
-QString Account::sentQuery() const
+namespace {
+
+/// Composes `path:"<maildir>/<folder>/**"`, or empty when the folder is unset.
+///
+/// The QUOTES are load-bearing, not decoration. A real provider nests both its
+/// sent and its drafts folder under a bracketed parent with a localised name,
+/// "[Provider]/Posta inviata" and "[Provider]/Bozze", and "[" and "]" are
+/// Xapian syntax: unquoted, the term is parsed rather than matched and the
+/// query silently returns nothing while looking correct.
+///
+/// The path is user config and is interpolated into a query, so this is the
+/// only place that composition happens; a caller building it by hand would be
+/// a second chance to forget the quotes.
+QString folderQuery(const QString &maildir, const QString &folder)
{
- if (sent.isEmpty())
+ if (folder.isEmpty())
return QString();
-
- // The QUOTES are load-bearing, not decoration. A real provider nests its
- // sent folder under a bracketed parent, "[Provider]/Posta inviata", and
- // "[" and "]" are Xapian syntax: unquoted, the term is parsed rather than
- // matched and the query silently returns nothing while looking correct.
- //
- // The path is user config and is interpolated into a query, so this is the
- // only place that composition happens; a caller building it by hand would
- // be a second chance to forget the quotes.
- return QStringLiteral("path:\"%1/%2/**\"").arg(maildir, sent);
+ return QStringLiteral("path:\"%1/%2/**\"").arg(maildir, folder);
}
-QString Config::allSentQuery() const
+/// Joins the non-empty results of `extract` across `accounts` with " or ".
+///
+/// Collect first, join after. Appending "or" per account and trimming the
+/// result is the version that produced the defect this guards: an account with
+/// no key contributes an empty term, notmuch accepts the bare "or" without
+/// complaint, and the query quietly means something else. Measured against a
+/// real database, `A or or B` returns 190 where the correct pair returns 211.
+QString joinAccountQueries(const QList<Account> &accounts,
+ QString (Account::*extract)() const)
{
- // Collect first, join after. Appending "or" per account and trimming the
- // result is the version that produced the defect this guards: an account
- // with no sent key contributes an empty term, notmuch accepts the bare
- // "or" without complaint, and the query quietly means something else.
QStringList parts;
- for (const Account &account : m_accounts) {
- const QString query = account.sentQuery();
+ for (const Account &account : accounts) {
+ const QString query = (account.*extract)();
if (!query.isEmpty())
parts.append(query);
}
return parts.join(QStringLiteral(" or "));
}
+} // namespace
+
+QString Account::sentQuery() const
+{
+ return folderQuery(maildir, sent);
+}
+
+QString Account::draftsQuery() const
+{
+ return folderQuery(maildir, drafts);
+}
+
+QString Config::allSentQuery() const
+{
+ return joinAccountQueries(m_accounts, &Account::sentQuery);
+}
+
+QString Config::allDraftsQuery() const
+{
+ return joinAccountQueries(m_accounts, &Account::draftsQuery);
+}
+
QString Config::defaultPath()
{
const QString base =
diff --git a/src/config.h b/src/config.h
index e3c5b6e..4092141 100644
--- a/src/config.h
+++ b/src/config.h
@@ -34,7 +34,13 @@ struct Account
QString name;
QString address;
QString maildir; ///< Relative to notmuch's database.path.
- QString drafts; ///< Unused in v1; send is v2.
+ /// The account's drafts folder, relative to maildir. Optional, exactly as
+ /// `sent` is, and absent more often: an account that composes elsewhere
+ /// keeps no local drafts folder at all.
+ ///
+ /// Composing drafts is v2. Reading them is not: the placeholder pane
+ /// counts them (item 67), which is why this is no longer unused.
+ QString drafts;
/// The account's sent folder, relative to maildir. Optional and empty for
/// an account that has none, which is a real case rather than a
@@ -81,6 +87,13 @@ struct Account
/// selector wraps whatever query runs, so a Sent view under one account
/// intersects to that account's sent mail and cannot leak another's.
QString sentQuery() const;
+
+ /// Matches this account's drafts, or empty when `drafts` is unset.
+ ///
+ /// Separate from sentQuery() rather than one parameterised helper: the two
+ /// keys are independent, and one real account configures `drafts` with no
+ /// `sent` at all.
+ QString draftsQuery() const;
};
struct SavedQuery
@@ -145,6 +158,13 @@ public:
/// open-coded at the call site.
QString allSentQuery() const;
+ /// Matches every configured account's drafts, or empty when none has one.
+ ///
+ /// Joins only the NON-EMPTY draftsQuery() results, for the same reason
+ /// allSentQuery() does: notmuch accepts a bare "or" without complaint and
+ /// silently answers a different question.
+ QString allDraftsQuery() const;
+
/// A QDateTime::toString() pattern for the date on a card, or empty for the
/// system locale's short format.
///
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index be6cfdb..824bdca 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -1385,40 +1385,73 @@ void MainWindow::onAllTagsReady(const QStringList &tags)
m_queryCompleter->setTags(tags);
}
-namespace {
+QList<MainWindow::PlaceholderLine> MainWindow::placeholderLines() const
+{
+ // One list of (query, label-maker) pairs rather than two arrays indexed in
+ // parallel. The parallel version is what the fixed array was, and its
+ // hazard is that inserting an entry in one and not the other prints a real
+ // number against the wrong name, which reads as a plausible pane.
+ //
+ // The queries are wire format and deliberately untranslated: `tag:` is
+ // notmuch syntax, not user-facing prose. Only the labels are translated.
+ QList<PlaceholderLine> lines = {
+ { QStringLiteral("tag:unread"),
+ [this](int n) { return tr("%n unread", "", n); } },
+ { QStringLiteral("tag:flagged"),
+ [this](int n) { return tr("%n flagged", "", n); } },
+ { QStringLiteral("tag:inbox"),
+ [this](int n) { return tr("%n in inbox", "", n); } },
+ };
-/// The queries behind the placeholder's helper lines, in render order.
-///
-/// Wire format, deliberately untranslated: `tag:` is notmuch syntax, not user
-/// -facing prose. Only the labels beside them are translated.
-const std::array<const char *, 3> kPlaceholderQueries = {
- "tag:unread",
- "tag:flagged",
- "tag:inbox",
-};
+ // Sent and drafts are composed from the account folders, not from a tag.
+ // `tag:draft` counts 0 against a real database and no draft-ish tag exists
+ // in it, so a tag-based line would be a permanent zero.
+ //
+ // Omitted entirely when no account configures the folder, rather than
+ // shown as 0: item 63 established that a missing sent folder is a real
+ // configuration, and "0 sent" claims the user has sent nothing.
+ const QString sent = m_config.allSentQuery();
+ if (!sent.isEmpty()) {
+ lines.append({ sent, [this](int n) { return tr("%n sent", "", n); } });
+ }
-} // namespace
+ const QString drafts = m_config.allDraftsQuery();
+ if (!drafts.isEmpty()) {
+ lines.append({ drafts,
+ [this](int n) { return tr("%n draft(s)", "", n); } });
+ }
+
+ return lines;
+}
+
+QStringList MainWindow::placeholderQueries() const
+{
+ QStringList queries;
+ for (const PlaceholderLine &line : placeholderLines())
+ queries.append(line.query);
+ return queries;
+}
QList<HtmlBuilder::PlaceholderHelper> MainWindow::placeholderHelpers() const
{
QList<HtmlBuilder::PlaceholderHelper> helpers;
- // Empty until the first reply lands. Rendering three zeroes meanwhile
- // would be worse than rendering nothing: a zero is a claim.
- if (m_placeholderCounts.size() == int(kPlaceholderQueries.size())) {
- const QStringList labels = {
- tr("%n unread", "", m_placeholderCounts.at(0)),
- tr("%n flagged", "", m_placeholderCounts.at(1)),
- tr("%n in inbox", "", m_placeholderCounts.at(2)),
- };
+ const QList<PlaceholderLine> lines = placeholderLines();
- for (int i = 0; i < labels.size(); ++i) {
+ // Empty until the first reply lands. Rendering zeroes meanwhile would be
+ // worse than rendering nothing: a zero is a claim.
+ //
+ // The size check is also what keeps the pairing honest across a config
+ // that changed shape between the request and the reply: counts that do not
+ // match the current line list are not this list's answers.
+ if (m_placeholderCounts.size() == lines.size()) {
+ for (int i = 0; i < lines.size(); ++i) {
// A query notmuch could not count yields -1; skip that line rather
// than print a negative number at the user.
if (m_placeholderCounts.at(i) < 0)
continue;
- helpers.append({ labels.at(i),
- QString::fromLatin1(kPlaceholderQueries[i]) });
+ helpers.append({ lines.at(i).label(m_placeholderCounts.at(i)),
+ lines.at(i).query });
}
}
@@ -1438,12 +1471,8 @@ void MainWindow::showPlaceholderPane()
{
m_messageView->showPlaceholder(placeholderHelpers());
- QStringList queries;
- for (const char *query : kPlaceholderQueries)
- queries.append(QString::fromLatin1(query));
-
QMetaObject::invokeMethod(m_worker, "requestCounts", Qt::QueuedConnection,
- Q_ARG(QStringList, queries),
+ Q_ARG(QStringList, placeholderQueries()),
Q_ARG(quint64, ++m_countsGeneration));
}
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 6e90ba1..0912520 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -165,6 +165,25 @@ public:
return !m_recoverThreadId.isEmpty();
}
+ /// The placeholder's queries, in the order requestCounts() asks for them.
+ ///
+ /// A test seam. The worker's reply is paired with these POSITIONALLY, so a
+ /// test standing in for it has to know the order, and that order now
+ /// depends on config rather than on a fixed list.
+ QStringList placeholderQueriesForTesting() const
+ {
+ return placeholderQueries();
+ }
+
+ /// The helper lines as the pane would render them.
+ QList<HtmlBuilder::PlaceholderHelper> placeholderHelpersForTesting() const
+ {
+ return placeholderHelpers();
+ }
+
+ /// The generation the next counts reply must carry to be accepted.
+ quint64 countsGenerationForTesting() const { return m_countsGeneration; }
+
protected:
void closeEvent(QCloseEvent *event) override;
@@ -310,6 +329,30 @@ private:
/// The helper lines, built from the last counts received. Rendered with
/// whatever the previous answer was until the new one lands, so the pane
/// never flashes empty while the worker replies.
+ /// One placeholder line: the query it counts, and how to label the answer.
+ ///
+ /// The label is a callable rather than a string because the count is not
+ /// known until the worker replies, and `tr("%n ...")` has to be given the
+ /// number to pick its plural form.
+ ///
+ /// Query and label travel together deliberately. The version this replaced
+ /// held them in two arrays indexed in parallel, where inserting an entry in
+ /// one and not the other put a real number against the wrong name.
+ struct PlaceholderLine {
+ QString query;
+ std::function<QString(int)> label;
+ };
+
+ /// The placeholder's lines, in render order.
+ ///
+ /// Built per call rather than cached: the sent and drafts lines come from
+ /// config, and a cache would be a second source of truth for the pairing
+ /// the counts reply depends on.
+ QList<PlaceholderLine> placeholderLines() const;
+
+ /// Just the queries, in the order requestCounts() asks for them.
+ QStringList placeholderQueries() const;
+
QList<HtmlBuilder::PlaceholderHelper> placeholderHelpers() const;
void showWarnings();
@@ -552,7 +595,7 @@ private:
/// Indeterminate, shown only while a sync runs. See setSyncBusy().
QProgressBar *m_syncProgress = nullptr;
- /// The last counts the worker answered, one per kPlaceholderQueries entry.
+ /// The last counts the worker answered, one per placeholderLines() entry.
/// Empty until the first reply, which renders the pane without its helper
/// lines rather than with three zeroes that would be a lie.
QVector<int> m_placeholderCounts;