diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-15 11:46:58 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-15 11:46:58 +0200 |
| commit | d4e4cebbefdce5f575954594461dc707492b2dd8 (patch) | |
| tree | b71ae3b6f66bf661b60dfc565eb9949f5f778f95 | |
| parent | aecb22de0c6644380ea3b1e909c864fd24501dc7 (diff) | |
| download | qtmaildir-d4e4cebbefdce5f575954594461dc707492b2dd8.tar.gz qtmaildir-d4e4cebbefdce5f575954594461dc707492b2dd8.zip | |
feat(startup): add startup_account, the account the dropdown opens on
Answers "start me in work - Inbox rather than All accounts - Inbox". The key
names an account by its [account.<key>] suffix and the dropdown is set to it
before the startup query runs; because a built-in filter composes with the
dropdown, that is the whole mechanism and the key never reaches a query
builder. Validated on load: a name matching no account is reported and cleared,
since the dropdown has no entry for it and would sit on All accounts without
saying why.
Which side applies the scope depends on what the startup entry is, and getting
it wrong is silent in both directions. A generated filter comes back from
resolvedQuery() already scoped, so letting runQuery() apply the dropdown again
gives path:"work/**" and (path:"work/**" and (tag:inbox)). A saved query does
not, because resolvedQuery() ignores the account key for one, so claiming it
was already scoped leaves it unscoped with the dropdown pointing at Work.
The first of those shipped in this session's working tree and passed its test,
because the assertion used contains() and the double-scoped string contains the
scope too. It asserts the exact query now. The second was found by writing the
test for the case rather than by reading, and is covered by
aStartupAccountAlsoScopesASavedStartupQuery.
The README's startup_query documentation was wrong on two counts after the
previous commit: the fallback is the Unread filter rather than the first query
in the file, and the name can now match a built-in filter.
| -rw-r--r-- | CHANGELOG.md | 12 | ||||
| -rw-r--r-- | README.md | 23 | ||||
| -rw-r--r-- | src/config.cpp | 17 | ||||
| -rw-r--r-- | src/config.h | 15 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 39 | ||||
| -rw-r--r-- | tests/test_config.cpp | 45 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 89 |
7 files changed, 228 insertions, 12 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 942fe41..10d9ecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,18 @@ point at which they are stable. ## [Unreleased] +### Added + +- **`startup_account` under `[general]`**, naming the account the dropdown + starts on by its `[account.<key>]` suffix. With a `startup_query` naming a + built-in filter, the application opens on that account's view of it: + `startup_account = work` with `startup_query = Inbox` starts in + "work - Inbox" rather than "All accounts - Inbox". + + It sets the starting scope, not a sticky one. Clicking a saved query that + names no account still clears the selection, as it always has. A key naming + no configured account is reported at startup and ignored. + ### Changed - The built-in filters carry icons, like the Save button at the other end of @@ -106,10 +106,19 @@ identity. ; point: once you zoom with Ctrl+wheel or Ctrl+/Ctrl-, that is remembered ; separately and this value no longer applies. ; message_zoom = 1.0 -; Optional. Which saved query to open at startup, by name. Defaults to -; Unread. Falls back to the first saved query if no query by this name -; exists, and warns if you named one explicitly. +; Optional. Which query to open at startup, by name. Defaults to Unread. +; Matches your own saved queries first, then the built-in filters (Unread, +; Inbox, Important, Sent), so either can be named here. Falls back to the +; Unread filter if no query by this name exists, and warns if you named one +; explicitly. ; startup_query = Unread +; Optional. Which account the dropdown starts on, by key: the suffix of an +; [account.<key>] section. Unset means All accounts. Combined with a +; startup_query naming a built-in filter, this opens the application on that +; account's view of it, so startup_account = work with startup_query = Inbox +; starts in "work - Inbox". It only sets the STARTING scope: clicking a saved +; query that names no account still clears the selection, as it always does. +; startup_account = work ; Optional. Toolbar icon size in pixels, 16 to 64. Defaults to 24. The ; toolbar follows your desktop's toolbar button style, so if that is set to ; "icon only" this is the whole size of the control; 16 matches what most @@ -262,9 +271,11 @@ drop your comments and reorder your keys. One behaviour changes with the move. Buttons used to appear in alphabetical order, because the INI backend returns keys sorted and preserving file order -would have meant hand-rolling a parser. They now follow the file. If -`startup_query` names a query that does not exist, the fallback is likewise the -first query in the file rather than the alphabetically first one. +would have meant hand-rolling a parser. They now follow the file. + +`startup_query` looks at your saved queries first and then at the built-in +filters, so it can name either; yours wins if both carry the same name. A name +matching neither falls back to the **Unread** filter. ### Sent mail diff --git a/src/config.cpp b/src/config.cpp index 8ca7910..9bed74a 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -191,6 +191,11 @@ void Config::load(const QString &path) m_startupQueryWasSet = true; } + // Stored raw here and validated once the accounts are parsed, below: the + // account sections have not been read yet at this point. + m_startupAccount = + settings.value(QStringLiteral("startup_account")).toString().trimmed(); + const QVariant zoom = settings.value(QStringLiteral("message_zoom")); if (zoom.isValid()) { bool ok = false; @@ -442,6 +447,18 @@ void Config::load(const QString &path) // are not parsed until now. Only a name the user actually wrote is worth a // problem; the built-in default naming a query they never created is not // something they got wrong. + // Same shape as the startup_query check below: a name the user wrote that + // matches nothing is a problem, because they asked for something and are + // not getting it. Cleared rather than passed on, since the dropdown has no + // entry for an account that does not exist and would sit on "All accounts" + // without saying why. + if (!m_startupAccount.isEmpty() && !account(m_startupAccount).isValid()) { + addProblem(QStringLiteral("Startup account '%1' is not a configured " + "account; starting on all accounts.") + .arg(m_startupAccount)); + m_startupAccount.clear(); + } + if (m_startupQueryWasSet && !m_savedQueries.isEmpty() && startupSavedQuery().name.compare(m_startupQuery, Qt::CaseInsensitive) != 0) { diff --git a/src/config.h b/src/config.h index 3946b40..d1f8e65 100644 --- a/src/config.h +++ b/src/config.h @@ -299,6 +299,17 @@ public: /// sort first rather than anything the user chose. QString startupQuery() const { return m_startupQuery; } + /// Account key the dropdown starts on, or empty for "All accounts". + /// + /// Scoping happens because the built-in filters COMPOSE with the dropdown, + /// so setting it before the startup query runs is the whole mechanism: this + /// key does not need to reach the query builders at all. + /// + /// Validated on load. A name matching no account is reported and left + /// empty, rather than passed on to a dropdown that has no such entry and + /// would silently stay on "All accounts". + QString startupAccount() const { return m_startupAccount; } + /// The saved query startupQuery() names, or the first one when it names /// nothing that exists. A default-constructed SavedQuery when there are /// none at all. @@ -401,6 +412,10 @@ private: QList<CompletionEntry> m_extraMimetypes; QString m_startupQuery = QStringLiteral("Unread"); + /// Empty means "All accounts", which is the same convention every other + /// account key here follows. + QString m_startupAccount; + /// Whether startup_query came from the config rather than being the /// built-in default. Only a name the user wrote is worth reporting when /// it matches no saved query. diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 3ebcbc4..3d7add2 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -404,19 +404,46 @@ MainWindow::MainWindow(const Config &config, QWidget *parent) // Not savedQueries().first(): [queries] is read through childKeys(), which // sorts alphabetically, so "first" means whatever happens to sort first // rather than anything the user chose. Config resolves the name. + // BEFORE the startup query runs, so the query below is composed in this + // scope. That is the whole of `startup_account`: a built-in filter composes + // with the dropdown, so setting the dropdown is all that is needed and the + // key never reaches a query builder. + // + // Config has already checked the key names a real account and cleared it if + // not, so findData either matches or this is "All accounts" anyway. + const QString startupAccount = m_config.startupAccount(); + if (!startupAccount.isEmpty()) { + const int index = m_accountBox->findData(startupAccount); + if (index >= 0) + m_accountBox->setCurrentIndex(index); + } + // resolvedQuery(), not startup.query: a generated entry stores no query at // all, since its text is composed from the accounts at run time. Reading // the field directly meant a startup_query naming a built-in filter opened // an empty bar and ran nothing. - // - // No account scope here. The dropdown starts on "All accounts", which is - // the empty key, so this is the unscoped form either way; passing the - // selection would be reading a widget the user has not touched yet. const SavedQuery startup = m_config.startupSavedQuery(); - const QString startupQuery = m_config.resolvedQuery(startup, QString()); + const QString startupQuery = + m_config.resolvedQuery(startup, startupAccount); if (!startupQuery.isEmpty()) { m_queryEdit->setText(startupQuery); - runCurrentQuery(); + + // Which of the two applies the scope depends on what the startup entry + // IS, and getting this wrong is silent in both directions. + // + // A generated filter came back from resolvedQuery() already scoped to + // the startup account, so applying the dropdown again gives + // path:"work/**" and (path:"work/**" and (tag:inbox)) + // which returns exactly the right rows while being the double scope + // this item exists to avoid. + // + // A saved query did NOT: resolvedQuery() ignores the account key for + // one, because a saved query states its own scope. Claiming it was + // already scoped leaves it unscoped for good, with the dropdown sitting + // on Work and the list showing every account. + runQuery(FlatResult::No, + startup.isGenerated() ? AccountScope::AlreadyScoped + : AccountScope::Apply); } } diff --git a/tests/test_config.cpp b/tests/test_config.cpp index 0cacb04..62913d7 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -96,6 +96,7 @@ private slots: void allSentQuerySkipsAccountsWithoutTheKey(); void allSentQueryJoinsEveryConfiguredAccount(); void aStoredGeneratedQueryIsUnpinnedNotDropped(); + void theStartupAccountIsReadAndValidated(); void theStartupQueryCanNameABuiltinFilter(); void theStartupQueryPrefersASavedQueryOverAFilterOfTheSameName(); void anUnmatchedStartupQueryFallsBackToAFilterNotAStrayQuery(); @@ -1057,6 +1058,50 @@ static QString writeTwoAccounts(const QTemporaryDir &dir) "maildir=personal\n")); } +void TestConfig::theStartupAccountIsReadAndValidated() +{ + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[general]\n" + "startup_account=work\n" + "\n" + "[account.work]\n" + "maildir=work\n" + "\n" + "[account.personal]\n" + "maildir=personal\n"))); + + QCOMPARE(config.startupAccount(), QStringLiteral("work")); + QVERIFY(config.problems().isEmpty()); + + // Unset is "All accounts", which is what an empty key means everywhere the + // account is carried, so no separate sentinel. + QTemporaryDir plain; + Config unset; + unset.load(writeIni(plain, QStringLiteral( + "[account.work]\n" + "maildir=work\n"))); + QVERIFY(unset.startupAccount().isEmpty()); + + // A name that matches no account is a problem: the user asked for + // something and is not getting it, exactly as for startup_query. Reported + // and IGNORED rather than passed on, since setting the dropdown to a key + // that is not in it would silently leave it on All accounts anyway. + QTemporaryDir bad; + Config wrong; + wrong.load(writeIni(bad, QStringLiteral( + "[general]\n" + "startup_account=nosuchaccount\n" + "\n" + "[account.work]\n" + "maildir=work\n"))); + QVERIFY2(wrong.startupAccount().isEmpty(), + "an unknown startup account was passed through rather than " + "falling back to All accounts"); + QCOMPARE(wrong.problems().size(), 1); +} + void TestConfig::theStartupQueryCanNameABuiltinFilter() { // The defect: startup_query searched the SAVED queries only. A user whose diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp index 949b59e..38a8b08 100644 --- a/tests/test_mainwindow.cpp +++ b/tests/test_mainwindow.cpp @@ -169,6 +169,8 @@ private slots: void narrowingAnEmptyQueryBarIsAPlainSearch(); void aMalformedAccountIsReportedWithoutBlockingTheConstructor(); void aWorkerBackedWindowReturnsRealThreads(); + void aStartupAccountScopesTheStartupQuery(); + void aStartupAccountAlsoScopesASavedStartupQuery(); void aGeneratedStartupQueryActuallyRuns(); void everyBuiltinFilterButtonCarriesAnIconAndItsText(); void aQueryInTheMenuCanActuallyBeRun(); @@ -6382,6 +6384,93 @@ void TestMainWindow::aWorkerBackedWindowReturnsRealThreads() QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000); } +void TestMainWindow::aStartupAccountScopesTheStartupQuery() +{ + // "Start me in Work - Inbox rather than All accounts - Inbox." The account + // dropdown is set before the startup query runs, and because a built-in + // filter COMPOSES with the dropdown, the query it runs is scoped to that + // account without the filter knowing anything about startup. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("qtmaildir.conf")); + { + QSettings s(path, QSettings::IniFormat); + // [general] keys are read WITHOUT the prefix, see writeSentConfig. + s.setValue(QStringLiteral("startup_query"), QStringLiteral("Inbox")); + s.setValue(QStringLiteral("startup_account"), QStringLiteral("work")); + s.beginGroup(QStringLiteral("account.work")); + s.setValue(QStringLiteral("maildir"), QStringLiteral("work")); + s.endGroup(); + s.beginGroup(QStringLiteral("account.personal")); + s.setValue(QStringLiteral("maildir"), QStringLiteral("personal")); + s.endGroup(); + s.sync(); + } + Config config; + config.load(path); + + MainWindow window(config); + + QCOMPARE(window.selectedAccountForTesting(), QStringLiteral("work")); + + auto *queryEdit = window.findChild<QLineEdit *>(QStringLiteral("queryEdit")); + QVERIFY(queryEdit); + QCOMPARE(queryEdit->text(), + QStringLiteral("path:\"work/**\" and (tag:inbox)")); + + // And the query that actually RAN carries the scope, not merely the text in + // the bar. runQuery() is told the filter is already scoped, so a mistake + // here would drop the scope rather than double it, and the bar would still + // look right. + // The exact string, not contains(): the double-scoped + // path:"work/**" and (path:"work/**" and (tag:inbox)) + // contains the scope too, returns exactly the right rows, and passed a + // contains() assertion while being the very thing item 93 exists to avoid. + QCOMPARE(window.lastRunQueryForTesting(), + QStringLiteral("path:\"work/**\" and (tag:inbox)")); +} + +void TestMainWindow::aStartupAccountAlsoScopesASavedStartupQuery() +{ + // The other half, and the one the scoping shortcut can silently drop. + // resolvedQuery(query, accountKey) ignores the account key for a SAVED + // query, because a saved query states its own scope. So the startup path + // gets back an unscoped string, and telling runQuery() the query is + // "already scoped" would leave it unscoped for good, with the dropdown + // sitting on Work and the list showing everything. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("qtmaildir.conf")); + { + QSettings s(path, QSettings::IniFormat); + s.setValue(QStringLiteral("startup_query"), QStringLiteral("Mine")); + s.setValue(QStringLiteral("startup_account"), QStringLiteral("work")); + s.beginGroup(QStringLiteral("account.work")); + s.setValue(QStringLiteral("maildir"), QStringLiteral("work")); + s.endGroup(); + s.sync(); + } + QFile queries(dir.filePath(QStringLiteral("queries.json"))); + QVERIFY(queries.open(QIODevice::WriteOnly | QIODevice::Text)); + queries.write(QStringLiteral(R"({ + "version": 1, + "queries": [ { "name": "Mine", "query": "tag:todo" } ] + })").toUtf8()); + queries.close(); + + Config config; + config.load(path); + + MainWindow window(config); + + QCOMPARE(window.selectedAccountForTesting(), QStringLiteral("work")); + QVERIFY2(window.lastRunQueryForTesting() + == QStringLiteral("path:\"work/**\" and (tag:todo)"), + qPrintable(QStringLiteral("the dropdown says Work and the query " + "that ran was: ") + + window.lastRunQueryForTesting())); +} + void TestMainWindow::aGeneratedStartupQueryActuallyRuns() { // The second half of the startup defect. The constructor read |
