summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md37
-rw-r--r--README.md58
-rw-r--r--src/CMakeLists.txt1
-rw-r--r--src/keymap.cpp5
-rw-r--r--src/mainwindow.cpp219
-rw-r--r--src/mainwindow.h21
-rw-r--r--src/savequerydialog.cpp128
-rw-r--r--src/savequerydialog.h63
-rw-r--r--tests/test_mainwindow.cpp275
9 files changed, 755 insertions, 52 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e867f02..2d264e2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,43 @@ point at which they are stable.
## [Unreleased]
+### Added
+
+- A **Save query** action (`Ctrl+S`) keeps the query in the bar as a saved
+ query, naming it, optionally scoping it to one account, and choosing whether
+ it appears as a button or in a menu. Saved queries no longer have to be added
+ by hand-editing the config file (item 23).
+- Saved queries live in `~/.config/qtmaildir/queries.json`, which carries their
+ **order**, a `pinned` flag and an optional account scope. The order in the
+ file is the order the buttons appear in, so rearranging them is a matter of
+ moving lines.
+
+### Changed
+
+- Saved queries have a **row of their own** beneath the query bar rather than
+ sharing it, and the unpinned ones sit behind a **More queries** menu, so the
+ query field is no longer squeezed by a long list of buttons.
+- Saved-query buttons follow the file's order instead of appearing
+ alphabetically.
+
+### Upgrading
+
+Saved queries move out of the `[queries]` section of `qtmaildir.conf` and into
+`~/.config/qtmaildir/queries.json`. **The first launch migrates them for you**:
+the section is read, the JSON file is written from it, and every entry is
+marked pinned so your buttons stay where they were.
+
+Your config file is left byte-for-byte alone. The old `[queries]` section stays
+in it, ignored from then on, and can be deleted by hand whenever you like. It is
+not removed automatically because rewriting the file would discard your comments
+and reorder your keys.
+
+One behaviour changes with the move. Buttons used to appear in alphabetical
+order and now follow the file. If `[general] 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, so a config that relied on that fallback may open
+on a different query than before.
+
## [0.17.0] - 2026-08-13
A rule is built from rows now instead of typed into four free-text fields:
diff --git a/README.md b/README.md
index 0921791..81ec7d8 100644
--- a/README.md
+++ b/README.md
@@ -106,7 +106,7 @@ 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 [queries] entry to open at startup, by name. Defaults to
+; 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.
; startup_query = Unread
@@ -191,11 +191,6 @@ shopping = #3366cc ; also colours shopping/amazon, shopping/nike, ...
shopping/amazon = #ff9900 ; ... unless the exact tag overrides it
work = #cc4444
-[queries]
-Inbox = tag:inbox
-Unread = tag:unread
-Important = tag:flagged
-
[keys]
Ctrl+E = archive
Ctrl+D = delete
@@ -203,14 +198,47 @@ j = next_thread
k = prev_thread
```
-Saved-query buttons appear in alphabetical order rather than file order:
-QSettings returns keys sorted, and preserving file order would mean
-hand-rolling an INI parser. Which query opens at startup is therefore a
-separate setting, `[general] startup_query`, rather than "the first one".
+### Saved queries
+
+Saved queries live in `~/.config/qtmaildir/queries.json`, not in the config
+file. They are written by the **Save query** action (`Ctrl+S`), which names the
+query in the bar, optionally scopes it to one account, and chooses whether it
+gets a button:
+
+```json
+{
+ "version": 1,
+ "queries": [
+ { "name": "Inbox", "query": "tag:inbox", "pinned": true },
+ { "name": "Unread", "query": "tag:unread", "pinned": true },
+ { "name": "Billing", "query": "from:billing", "account": "work" }
+ ]
+}
+```
-The button text is the key you write here, so these names are yours to
-choose. `Important = tag:flagged` and `Flagged = tag:flagged` run the same
-query and differ only in what the button says.
+The order in the file is the order the buttons appear in, so rearranging them
+is a matter of moving lines. `pinned` decides between a button and the **More
+queries** menu, which keeps the row usable once you have more than a handful.
+`account` names an `[account.<key>]` section and scopes the query to it, the
+same as choosing that account in the dropdown; leave it out for a query that
+spans every account.
+
+The name is what the button says, so `Important` and `Flagged` can run the same
+query and differ only in the label.
+
+**Upgrading from 0.17.0 or earlier.** Saved queries used to live in a
+`[queries]` section of `qtmaildir.conf`. The first launch after upgrading reads
+that section, writes `queries.json` from it, and marks every entry pinned so
+your buttons stay where they were. Your config file is not modified: the old
+`[queries]` section is left exactly as it is, ignored from then on, and you can
+delete it by hand whenever you like. The reason it is not removed for you is
+that rewriting the file would 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.
### Sent mail
@@ -253,7 +281,8 @@ behaves like any other query, threads and all.
## The query bar
The bar at the top takes a notmuch query and shows the matching threads.
-Saved queries from `[queries]` sit beside it as buttons.
+Saved queries sit on their own row beneath it: the pinned ones as buttons, the
+rest behind **More queries**. `Ctrl+S` keeps the current query as a new one.
Completion helps with the syntax rather than replacing it. `Ctrl+Space`
opens the popup, and ordinary typing keeps it up to date. Candidates carry a
@@ -478,6 +507,7 @@ Defaults, all rebindable through `[keys]`:
| `Ctrl+I` | `flag` | Mark important (adds `flagged`) |
| `Ctrl+L` | `focus_query` | Focus and select the query bar |
| `Ctrl+Space` | `complete_query` | Focus the query bar and offer completions |
+| `Ctrl+S` | `save_query` | Keep the current query as a saved query |
| `Ctrl+H` | `toggle_html` | Switch the thread between HTML and plain text |
| `Ctrl+M` | `load_remote` | Load remote images for the current thread |
| `Ctrl+T` | `edit_tags` | Add or remove any tag on the selected threads |
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 6ae157b..0945f65 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -11,6 +11,7 @@ add_library(qtmaildir_lib STATIC
notmuchworker.cpp
tagchip.cpp
tagcolors.cpp
+ savequerydialog.cpp
tagdialog.cpp
tagrules.cpp
tagrulesdialog.cpp
diff --git a/src/keymap.cpp b/src/keymap.cpp
index 71c5355..52b015f 100644
--- a/src/keymap.cpp
+++ b/src/keymap.cpp
@@ -39,6 +39,7 @@ QStringList KeyMap::knownActions()
QStringLiteral("flag"),
QStringLiteral("focus_query"),
QStringLiteral("complete_query"),
+ QStringLiteral("save_query"),
QStringLiteral("select_all"),
QStringLiteral("clear_pane"),
QStringLiteral("clear_selection"),
@@ -100,6 +101,10 @@ QList<QPair<QString, QString>> KeyMap::defaultBindings()
// shells and editors, and it is a named key rather than a symbol, so
// no layout has to shift it.
{ QStringLiteral("Ctrl+Space"), QStringLiteral("complete_query") },
+ // The conventional save key, and free here: nothing in this window
+ // saves a document, so Ctrl+S is unclaimed and means what a user
+ // expects it to.
+ { QStringLiteral("Ctrl+S"), QStringLiteral("save_query") },
// The conventional select-all key, and free here: the thread list is a
// read-only view, so nothing else in the window wants it.
{ QStringLiteral("Ctrl+A"), QStringLiteral("select_all") },
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index e2df6de..3966e9f 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -56,6 +56,7 @@
#include "cardlayout.h"
#include "tagchip.h"
#include "tagdialog.h"
+#include "savequerydialog.h"
#include "tagrulesdialog.h"
#include "threadlistmodel.h"
#include "threadlistview.h"
@@ -339,6 +340,21 @@ MainWindow::MainWindow(const Config &config, QWidget *parent)
buildUi();
registerActions();
+
+ // After registerActions(), not inside buildUi(): the query bar exists by
+ // then but the action does not, so wiring this where the field is built
+ // silently connected nothing and left Save query enabled on an empty
+ // query. Hung on textChanged rather than textEdited, because the field is
+ // also set programmatically, by the saved-query buttons and by
+ // recoverStaleThread(), and the action must track those too.
+ if (QAction *save = m_actions.value(QStringLiteral("save_query"))) {
+ auto updateSaveState = [this, save]() {
+ save->setEnabled(!m_queryEdit->text().trimmed().isEmpty());
+ };
+ connect(m_queryEdit, &QLineEdit::textChanged, this, updateSaveState);
+ updateSaveState();
+ }
+
buildMenus();
// After buildMenus(): QMainWindow::restoreState() matches toolbars by
// object name, so they must already exist or their position is dropped.
@@ -543,49 +559,17 @@ void MainWindow::buildUi()
this, &MainWindow::onExternalSyncStateChanged);
m_syncMonitor->start();
- // One row: the account dropdown, the query field, then the saved queries.
- // The field is the only stretching item, so it is framed on both sides
- // rather than running flush to the window edge, which is what the removed
- // Sync button used to terminate.
- //
- // ponytail: no overflow handling. [queries] is unbounded and enough entries
- // would squeeze the field, but three is the real-world case today. Item 23
- // already specifies buttons-plus-menu and is where that belongs.
+ // The query row proper: account, sort order, the field. The saved queries
+ // used to share it and now have a row of their own below, which is what
+ // stops an unbounded list squeezing the field (item 23; the ponytail note
+ // that stood here predicted exactly this).
queryRow->addWidget(m_accountBox);
queryRow->addWidget(m_sortOrder);
queryRow->addWidget(m_queryEdit, 1);
- 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();
- });
- queryRow->addWidget(button);
- }
-
- // Sent sits with the saved queries and is not one: its query is COMPOSED
- // from the accounts' `sent` keys at click time, so adding an account or
- // correcting a folder name is a config edit and nothing else. A [queries]
- // entry holding the same string would go stale silently, and could not
- // narrow to the selected account the way this does through
- // runCurrentQuery()'s existing scope wrap.
- //
- // Hidden entirely when no account configures a sent folder, rather than
- // offering a button that always finds nothing.
- if (!m_config.allSentQuery().isEmpty()) {
- auto *sentButton = new QPushButton(tr("Sent"), central);
- sentButton->setObjectName(QStringLiteral("sentButton"));
- connect(sentButton, &QPushButton::clicked, this, [this]() {
- m_queryEdit->setText(m_config.allSentQuery());
- // Flat for this query only. runCurrentQuery() clears it again for
- // anything else, including the same query typed by hand, so the
- // flag cannot outlive the button that set it.
- runQuery(FlatResult::Yes);
- });
- queryRow->addWidget(sentButton);
- }
layout->addLayout(queryRow);
+ buildSavedQueryRow(central, layout);
+
// Thread list and message pane.
m_model = new ThreadListModel(this);
m_model->setTagColors(&m_tagColors);
@@ -836,6 +820,10 @@ void MainWindow::registerActions()
tr("Edit the rules that tag mail as it arrives"), [this]() {
showTagRulesDialog();
});
+ addAction(QStringLiteral("save_query"), tr("&Save query..."),
+ tr("Keep the current query as a saved query"), [this]() {
+ saveCurrentQuery();
+ });
addAction(QStringLiteral("toggle_html"), tr("Toggle &HTML"),
tr("Switch the thread between HTML and plain text"), [this]() {
m_messageView->toggleHtml();
@@ -982,6 +970,7 @@ void MainWindow::buildMenus()
editMenu->addSeparator();
editMenu->addAction(m_actions.value(QStringLiteral("focus_query")));
editMenu->addAction(m_actions.value(QStringLiteral("complete_query")));
+ editMenu->addAction(m_actions.value(QStringLiteral("save_query")));
editMenu->addSeparator();
editMenu->addAction(m_actions.value(QStringLiteral("select_all")));
@@ -1060,6 +1049,7 @@ void MainWindow::buildMenus()
// the selection's tags.
{ QStringLiteral("tag_rules"), QStringLiteral("configure") },
{ QStringLiteral("complete_query"), QStringLiteral("edit-find-replace") },
+ { QStringLiteral("save_query"), QStringLiteral("document-save") },
{ QStringLiteral("select_all"), QStringLiteral("edit-select-all") },
{ QStringLiteral("clear_pane"), QStringLiteral("edit-clear") },
{ QStringLiteral("clear_selection"), QStringLiteral("edit-clear-all") },
@@ -1644,6 +1634,159 @@ void MainWindow::showWarnings()
problems.join(QLatin1Char('\n')));
}
+void MainWindow::buildSavedQueryRow(QWidget *parent, QVBoxLayout *layout)
+{
+ auto *row = new QWidget(parent);
+ row->setObjectName(QStringLiteral("savedQueryRow"));
+ auto *box = new QHBoxLayout(row);
+ box->setContentsMargins(0, 0, 0, 0);
+
+ QList<SavedQuery> unpinned;
+ for (const SavedQuery &saved : m_config.savedQueries()) {
+ if (!saved.pinned) {
+ unpinned.append(saved);
+ continue;
+ }
+ auto *button = new QPushButton(saved.name, row);
+ connect(button, &QPushButton::clicked, this,
+ [this, saved]() { runSavedQuery(saved); });
+ box->addWidget(button);
+ }
+
+ // Sent sits with the saved queries and is not one: its query is COMPOSED
+ // from the accounts' `sent` keys at click time, so adding an account or
+ // correcting a folder name is a config edit and nothing else. A stored
+ // entry holding the same string would go stale silently, and could not
+ // narrow to the selected account the way this does through
+ // runCurrentQuery()'s existing scope wrap.
+ //
+ // Hidden entirely when no account configures a sent folder, rather than
+ // offering a button that always finds nothing.
+ if (!m_config.allSentQuery().isEmpty()) {
+ auto *sentButton = new QPushButton(tr("Sent"), row);
+ sentButton->setObjectName(QStringLiteral("sentButton"));
+ connect(sentButton, &QPushButton::clicked, this, [this]() {
+ m_queryEdit->setText(m_config.allSentQuery());
+ // Flat for this query only. runCurrentQuery() clears it again for
+ // anything else, including the same query typed by hand, so the
+ // flag cannot outlive the button that set it.
+ runQuery(FlatResult::Yes);
+ });
+ box->addWidget(sentButton);
+ }
+
+ // The overflow menu, and only when something is in it: an empty menu
+ // button is a control that always does nothing.
+ if (!unpinned.isEmpty()) {
+ auto *menuButton = new QPushButton(tr("More queries"), row);
+ menuButton->setObjectName(QStringLiteral("savedQueryMenuButton"));
+ auto *menu = new QMenu(menuButton);
+ for (const SavedQuery &saved : unpinned) {
+ QAction *action = menu->addAction(saved.name);
+ connect(action, &QAction::triggered, this,
+ [this, saved]() { runSavedQuery(saved); });
+ }
+ menuButton->setMenu(menu);
+ box->addWidget(menuButton);
+ }
+
+ box->addStretch(1);
+ layout->addWidget(row);
+
+ // Nothing saved and no sent folder leaves an empty strip of padding, so
+ // the row goes away rather than sitting there as a gap.
+ if (box->count() == 1)
+ row->hide();
+}
+
+void MainWindow::runSavedQuery(const SavedQuery &saved)
+{
+ // Through the dropdown, never by pre-scoping the text: runQuery() applies
+ // the selected account's path itself, so a scope baked in here would be
+ // applied twice. An unscoped query CLEARS the selection rather than
+ // inheriting whatever was there, which is the defect the rules preview hit.
+ const int index = saved.account.isEmpty()
+ ? m_accountBox->findData(QString())
+ : m_accountBox->findData(saved.account);
+ if (index >= 0)
+ m_accountBox->setCurrentIndex(index);
+
+ m_queryEdit->setText(saved.query);
+ runCurrentQuery();
+}
+
+void MainWindow::saveCurrentQuery()
+{
+ const QString query = m_queryEdit->text().trimmed();
+ if (query.isEmpty())
+ return;
+
+ SaveQueryDialog dialog(m_config, query,
+ m_accountBox->currentData().toString(), this);
+ if (dialog.exec() != QDialog::Accepted)
+ return;
+
+ QList<SavedQuery> queries = m_config.savedQueries();
+ const SavedQuery saved = dialog.savedQuery();
+
+ // Replacing by name keeps the dialog's overwrite offer honest, and keeps
+ // the entry where it already sat rather than moving it to the end.
+ bool replaced = false;
+ for (SavedQuery &existing : queries) {
+ if (existing.name.compare(saved.name, Qt::CaseInsensitive) == 0) {
+ // The unknown fields belong to the STORED entry, not to the
+ // dialog's fresh value, so a field a later build wrote survives
+ // being edited here.
+ SavedQuery merged = saved;
+ merged.unknown = existing.unknown;
+ existing = merged;
+ replaced = true;
+ break;
+ }
+ }
+ if (!replaced)
+ queries.append(saved);
+
+ m_config.setSavedQueries(queries);
+ if (!m_config.saveSavedQueries()) {
+ QMessageBox::warning(this, tr("Save query"),
+ tr("Could not write the saved queries file."));
+ return;
+ }
+
+ rebuildSavedQueryRow();
+ statusBar()->showMessage(tr("Saved query '%1'.").arg(saved.name),
+ kStatusMessageMs);
+}
+
+void MainWindow::rebuildSavedQueryRow()
+{
+ // The row is rebuilt wholesale rather than patched: a new query can be
+ // pinned, unpinned, or replace an existing one, and each moves a different
+ // widget. Deleting and rebuilding is a handful of buttons and cannot get
+ // the three cases wrong.
+ auto *old = findChild<QWidget *>(QStringLiteral("savedQueryRow"));
+ if (!old)
+ return;
+
+ auto *layout = qobject_cast<QVBoxLayout *>(centralWidget()->layout());
+ if (!layout)
+ return;
+
+ const int index = layout->indexOf(old);
+ layout->removeWidget(old);
+ old->deleteLater();
+
+ buildSavedQueryRow(centralWidget(), layout);
+
+ // buildSavedQueryRow appends; move it back to where the old row sat, or it
+ // lands under the thread list.
+ if (index >= 0) {
+ auto *item = layout->takeAt(layout->count() - 1);
+ layout->insertItem(index, item);
+ }
+}
+
void MainWindow::runQuery(FlatResult flat)
{
// Set on EVERY run, not only when Yes. This is the line that stops flat
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 18fbbb4..8b43fd0 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -49,6 +49,7 @@ class QPlainTextEdit;
class QSplitter;
class QProgressBar;
class QTimer;
+class QVBoxLayout;
class ThreadListModel;
class MessageView;
@@ -231,6 +232,26 @@ private:
/// is what widgets connect to.
void runQuery(FlatResult flat);
+ /// Builds the row of saved-query buttons, the overflow menu and Sent.
+ ///
+ /// Its own row since item 23: an unbounded list of buttons sharing the
+ /// query row squeezed the field, which is the whole reason for the
+ /// pinned/unpinned split.
+ void buildSavedQueryRow(QWidget *parent, QVBoxLayout *layout);
+
+ /// Runs a saved query, taking its account scope through the dropdown.
+ ///
+ /// Not by pre-scoping the text: runQuery() already wraps the query in the
+ /// selected account's path, so a scope baked in here would be applied
+ /// twice. Setting the dropdown also shows the user what scope they are in.
+ void runSavedQuery(const SavedQuery &saved);
+
+ /// Names the current query and stores it in queries.json.
+ void saveCurrentQuery();
+
+ /// Rebuilds the saved-query row in place after the stored list changed.
+ void rebuildSavedQueryRow();
+
private slots:
void runCurrentQuery() { runQuery(FlatResult::No); }
diff --git a/src/savequerydialog.cpp b/src/savequerydialog.cpp
new file mode 100644
index 0000000..1ea97c2
--- /dev/null
+++ b/src/savequerydialog.cpp
@@ -0,0 +1,128 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include "savequerydialog.h"
+
+#include <QCheckBox>
+#include <QComboBox>
+#include <QDialogButtonBox>
+#include <QFormLayout>
+#include <QLabel>
+#include <QLineEdit>
+#include <QPushButton>
+#include <QVBoxLayout>
+
+bool SaveQueryDialog::namesAnExistingQuery(const Config &config,
+ const QString &name)
+{
+ for (const SavedQuery &saved : config.savedQueries()) {
+ if (saved.name.compare(name.trimmed(), Qt::CaseInsensitive) == 0)
+ return true;
+ }
+ return false;
+}
+
+SaveQueryDialog::SaveQueryDialog(const Config &config, const QString &query,
+ const QString &accountKey, QWidget *parent)
+ : QDialog(parent)
+ , m_config(config)
+{
+ setWindowTitle(tr("Save query"));
+
+ auto *layout = new QVBoxLayout(this);
+ auto *form = new QFormLayout;
+
+ m_name = new QLineEdit(this);
+ m_name->setObjectName(QStringLiteral("saveQueryName"));
+ m_name->setPlaceholderText(tr("A name for this query"));
+ form->addRow(tr("Name"), m_name);
+
+ m_query = new QLineEdit(query, this);
+ m_query->setObjectName(QStringLiteral("saveQueryQuery"));
+ form->addRow(tr("Query"), m_query);
+
+ // The scope is stored as an account KEY, so the entries carry the key as
+ // data exactly as the main window's dropdown does. "All accounts" is the
+ // empty key, not a missing entry, so an unscoped query is a real choice
+ // rather than the absence of one.
+ m_account = new QComboBox(this);
+ m_account->setObjectName(QStringLiteral("saveQueryAccount"));
+ m_account->addItem(tr("All accounts"), QString());
+ for (const Account &account : config.accounts())
+ m_account->addItem(account.key, account.key);
+ const int index = m_account->findData(accountKey);
+ m_account->setCurrentIndex(index >= 0 ? index : 0);
+ form->addRow(tr("Account"), m_account);
+
+ m_pinned = new QCheckBox(tr("Show as a button"), this);
+ m_pinned->setObjectName(QStringLiteral("saveQueryPinned"));
+ m_pinned->setChecked(true);
+ form->addRow(QString(), m_pinned);
+
+ layout->addLayout(form);
+
+ // Says what is about to happen rather than refusing the name. Overwriting
+ // a saved query on purpose is a normal edit, and the only thing worth
+ // preventing is doing it without noticing.
+ m_notice = new QLabel(this);
+ m_notice->setObjectName(QStringLiteral("saveQueryNotice"));
+ m_notice->setWordWrap(true);
+ layout->addWidget(m_notice);
+
+ auto *buttons = new QDialogButtonBox(
+ QDialogButtonBox::Save | QDialogButtonBox::Cancel, this);
+ connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
+ connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
+ layout->addWidget(buttons);
+
+ m_ok = buttons->button(QDialogButtonBox::Save);
+ m_ok->setObjectName(QStringLiteral("saveQueryOk"));
+
+ connect(m_name, &QLineEdit::textChanged,
+ this, &SaveQueryDialog::updateOkState);
+ connect(m_query, &QLineEdit::textChanged,
+ this, &SaveQueryDialog::updateOkState);
+ updateOkState();
+
+ m_name->setFocus();
+}
+
+void SaveQueryDialog::updateOkState()
+{
+ const QString name = m_name->text().trimmed();
+ const bool usable = !name.isEmpty() && !m_query->text().trimmed().isEmpty();
+ m_ok->setEnabled(usable);
+
+ if (!name.isEmpty() && namesAnExistingQuery(m_config, name)) {
+ m_notice->setText(
+ tr("A saved query named '%1' already exists and will be "
+ "replaced.").arg(name));
+ } else {
+ m_notice->clear();
+ }
+}
+
+SavedQuery SaveQueryDialog::savedQuery() const
+{
+ SavedQuery saved;
+ saved.name = m_name->text().trimmed();
+ saved.query = m_query->text().trimmed();
+ saved.account = m_account->currentData().toString();
+ saved.pinned = m_pinned->isChecked();
+ return saved;
+}
diff --git a/src/savequerydialog.h b/src/savequerydialog.h
new file mode 100644
index 0000000..be5a2af
--- /dev/null
+++ b/src/savequerydialog.h
@@ -0,0 +1,63 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#pragma once
+
+#include <QDialog>
+
+#include "config.h"
+
+class QCheckBox;
+class QComboBox;
+class QLabel;
+class QLineEdit;
+class QPushButton;
+
+/// Names a query and keeps it in queries.json.
+///
+/// Pure UI: it is handed the config and returns a SavedQuery. It writes
+/// nothing, so it can be tested without touching a file, and the caller owns
+/// the decision of what to do with the result.
+class SaveQueryDialog : public QDialog
+{
+ Q_OBJECT
+public:
+ /// `query` is the text to store, `accountKey` the scope to preselect,
+ /// which is the account the user is already looking at.
+ SaveQueryDialog(const Config &config, const QString &query,
+ const QString &accountKey, QWidget *parent = nullptr);
+
+ /// The query as edited. Only meaningful after exec() returned Accepted.
+ SavedQuery savedQuery() const;
+
+ /// Whether `name` already names a stored query. Case-insensitive, matching
+ /// how startup_query resolves, so "inbox" and "Inbox" cannot both exist and
+ /// leave the user unable to tell which one a button ran.
+ static bool namesAnExistingQuery(const Config &config, const QString &name);
+
+private:
+ void updateOkState();
+
+ const Config &m_config;
+ QLineEdit *m_name = nullptr;
+ QLineEdit *m_query = nullptr;
+ QComboBox *m_account = nullptr;
+ QCheckBox *m_pinned = nullptr;
+ QPushButton *m_ok = nullptr;
+ QLabel *m_notice = nullptr;
+};
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index 4c1c5d9..2c70e0f 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -188,6 +188,14 @@ private slots:
void flatModeDoesNotSurviveTheNextQuery();
void noTwoActionsShareAnIcon();
+ void onlyPinnedQueriesBecomeButtons();
+ void unpinnedQueriesReachTheMenu();
+ void pinnedButtonsFollowTheDocumentOrder();
+ void theSavedQueryMenuIsHiddenWhenEveryQueryIsPinned();
+ void aScopedSavedQuerySelectsItsAccount();
+ void anUnscopedSavedQueryClearsTheAccount();
+ void theSaveQueryActionIsDisabledOnAnEmptyQuery();
+
private:
/// Owns the throwaway lock table init() points every test at. A pointer
/// rather than a value because it is rebuilt per test, and QTemporaryDir
@@ -5265,4 +5273,271 @@ int main(int argc, char *argv[])
return QTest::qExec(&test, argc, argv);
}
+// ---------------------------------------------------------------------------
+// Saved queries in the query row (item 23)
+// ---------------------------------------------------------------------------
+
+/// Writes a config plus a queries.json beside it, and loads both.
+static void loadWithQueries(Config &config, QTemporaryDir &dir,
+ const QString &queriesJson,
+ const QString &iniExtra = {})
+{
+ QDir().mkpath(dir.filePath(QStringLiteral("qtmaildir")));
+ const QString conf =
+ dir.filePath(QStringLiteral("qtmaildir/qtmaildir.conf"));
+ QFile ini(conf);
+ ini.open(QIODevice::WriteOnly | QIODevice::Text);
+ ini.write(iniExtra.toUtf8());
+ ini.close();
+
+ QFile json(dir.filePath(QStringLiteral("qtmaildir/queries.json")));
+ json.open(QIODevice::WriteOnly);
+ json.write(queriesJson.toUtf8());
+ json.close();
+
+ config.load(conf);
+}
+
+/// Buttons in the saved-query row, by label, in the order they are laid out.
+static QStringList savedQueryButtonLabels(MainWindow &window)
+{
+ QStringList labels;
+ auto *row = window.findChild<QWidget *>(QStringLiteral("savedQueryRow"));
+ if (!row)
+ return labels;
+ const QList<QPushButton *> buttons =
+ row->findChildren<QPushButton *>(QString(), Qt::FindDirectChildrenOnly);
+ for (QPushButton *button : buttons) {
+ // The menu button is not a saved query and must not be counted as one.
+ if (button->objectName() != QStringLiteral("savedQueryMenuButton"))
+ labels.append(button->text());
+ }
+ return labels;
+}
+
+void TestMainWindow::onlyPinnedQueriesBecomeButtons()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Inbox", "query": "tag:inbox", "pinned": true },
+ { "name": "Buried", "query": "tag:buried" }
+ ]
+ })"));
+
+ MainWindow window(config);
+ const QStringList labels = savedQueryButtonLabels(window);
+
+ QVERIFY2(labels.contains(QStringLiteral("Inbox")),
+ "a pinned query must have a button");
+ QVERIFY2(!labels.contains(QStringLiteral("Buried")),
+ "an unpinned query must NOT have a button");
+}
+
+void TestMainWindow::unpinnedQueriesReachTheMenu()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Inbox", "query": "tag:inbox", "pinned": true },
+ { "name": "Buried", "query": "tag:buried" }
+ ]
+ })"));
+
+ MainWindow window(config);
+ auto *menuButton =
+ window.findChild<QPushButton *>(QStringLiteral("savedQueryMenuButton"));
+ QVERIFY2(menuButton, "an unpinned query needs a menu to live in");
+ QVERIFY(menuButton->menu());
+
+ QStringList entries;
+ const QList<QAction *> actions = menuButton->menu()->actions();
+ for (QAction *action : actions)
+ entries.append(action->text());
+
+ QVERIFY2(entries.contains(QStringLiteral("Buried")),
+ "the unpinned query is missing from the menu");
+ // A pinned query is already a button; listing it twice is the duplicate
+ // this asserts against.
+ QVERIFY2(!entries.contains(QStringLiteral("Inbox")),
+ "a pinned query must not also appear in the menu");
+}
+
+/// The property the whole storage change was made for. "Zebra" is written
+/// first and must stay first; alphabetical order would put it last.
+void TestMainWindow::pinnedButtonsFollowTheDocumentOrder()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Zebra", "query": "tag:zebra", "pinned": true },
+ { "name": "Apple", "query": "tag:apple", "pinned": true }
+ ]
+ })"));
+
+ MainWindow window(config);
+ const QStringList labels = savedQueryButtonLabels(window);
+
+ QCOMPARE(labels.size(), 2);
+ QCOMPARE(labels.at(0), QStringLiteral("Zebra"));
+ QCOMPARE(labels.at(1), QStringLiteral("Apple"));
+}
+
+void TestMainWindow::theSavedQueryMenuIsHiddenWhenEveryQueryIsPinned()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Inbox", "query": "tag:inbox", "pinned": true }
+ ]
+ })"));
+
+ MainWindow window(config);
+
+ // The guard the assertion below needs. Asserting only that the menu button
+ // is absent passed against NO implementation at all, before any of this
+ // was built, so it has to prove first that the row it is looking in was
+ // populated and that a button was found.
+ const QStringList labels = savedQueryButtonLabels(window);
+ QCOMPARE(labels, QStringList{ QStringLiteral("Inbox") });
+
+ auto *menuButton =
+ window.findChild<QPushButton *>(QStringLiteral("savedQueryMenuButton"));
+ QVERIFY2(!menuButton,
+ "an empty menu button is a control that always does nothing");
+}
+
+/// The scope goes through the account dropdown rather than being baked into
+/// the query text. runQuery() already scopes by that dropdown, so pre-scoping
+/// the text would apply the path twice, and the selection would be invisible.
+void TestMainWindow::aScopedSavedQuerySelectsItsAccount()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Billing", "query": "from:billing",
+ "account": "work", "pinned": true }
+ ]
+ })"), QStringLiteral(
+ "[account.work]\n"
+ "name=Test User\n"
+ "address=user@example.org\n"
+ "maildir=work-mail\n"
+ "\n"
+ "[account.personal]\n"
+ "name=Test User\n"
+ "address=me@example.net\n"
+ "maildir=personal\n"
+ ));
+
+ MainWindow window(config);
+ auto *accountBox =
+ window.findChild<QComboBox *>(QStringLiteral("accountBox"));
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(accountBox);
+ QVERIFY(queryEdit);
+
+ // Start somewhere else, so a passing result cannot be the default.
+ accountBox->setCurrentIndex(accountBox->findData(
+ QStringLiteral("personal")));
+ QCOMPARE(accountBox->currentData().toString(), QStringLiteral("personal"));
+
+ auto *row = window.findChild<QWidget *>(QStringLiteral("savedQueryRow"));
+ QVERIFY(row);
+ auto *button = row->findChild<QPushButton *>();
+ QVERIFY(button);
+ button->click();
+
+ QCOMPARE(accountBox->currentData().toString(), QStringLiteral("work"));
+ // The text is the bare query. The path scope is applied once, by
+ // runQuery(), from the dropdown this just set.
+ QCOMPARE(queryEdit->text(), QStringLiteral("from:billing"));
+ QVERIFY2(!queryEdit->text().contains(QStringLiteral("path:")),
+ "the scope must not be baked into the query text");
+}
+
+/// A query with no account must CLEAR the dropdown, not inherit whatever the
+/// last one left there. Confirmed against the same defect in the rules
+/// preview, where an already-selected account survived the click.
+void TestMainWindow::anUnscopedSavedQueryClearsTheAccount()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1,
+ "queries": [
+ { "name": "Everywhere", "query": "tag:inbox", "pinned": true }
+ ]
+ })"), QStringLiteral(
+ "[account.work]\n"
+ "name=Test User\n"
+ "address=user@example.org\n"
+ "maildir=work-mail\n"
+ ));
+
+ MainWindow window(config);
+ auto *accountBox =
+ window.findChild<QComboBox *>(QStringLiteral("accountBox"));
+ QVERIFY(accountBox);
+
+ accountBox->setCurrentIndex(accountBox->findData(QStringLiteral("work")));
+ QCOMPARE(accountBox->currentData().toString(), QStringLiteral("work"));
+
+ auto *row = window.findChild<QWidget *>(QStringLiteral("savedQueryRow"));
+ QVERIFY(row);
+ auto *button = row->findChild<QPushButton *>();
+ QVERIFY(button);
+ button->click();
+
+ QVERIFY2(accountBox->currentData().toString().isEmpty(),
+ "an unscoped saved query must clear the account selection");
+}
+
+void TestMainWindow::theSaveQueryActionIsDisabledOnAnEmptyQuery()
+{
+ QTemporaryDir dir;
+ QVERIFY(dir.isValid());
+ Config config;
+ loadWithQueries(config, dir, QStringLiteral(R"({
+ "version": 1, "queries": []
+ })"));
+
+ MainWindow window(config);
+ auto *save = window.findChild<QAction *>(QStringLiteral("save_query"));
+ QVERIFY2(save, "there is no way to save a query");
+
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(queryEdit);
+
+ queryEdit->clear();
+ QVERIFY2(!save->isEnabled(),
+ "saving an empty query would store a query that matches nothing");
+
+ queryEdit->setText(QStringLiteral("tag:inbox"));
+ QVERIFY2(save->isEnabled(), "a real query must be savable");
+
+ // Whitespace is not a query. setText does not drive a completer, but it
+ // does emit textChanged, which is what the enabling is hung on.
+ queryEdit->setText(QStringLiteral(" "));
+ QVERIFY(!save->isEnabled());
+}
+
#include "test_mainwindow.moc"