aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/CMakeLists.txt1
-rw-r--r--src/config.cpp255
-rw-r--r--src/config.h86
-rw-r--r--src/keymap.cpp5
-rw-r--r--src/mainwindow.cpp374
-rw-r--r--src/mainwindow.h60
-rw-r--r--src/savequerydialog.cpp174
-rw-r--r--src/savequerydialog.h79
8 files changed, 985 insertions, 49 deletions
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/config.cpp b/src/config.cpp
index b1f730c..ae26555 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -24,8 +24,14 @@
#include "messageview.h"
#include <QDateTime>
+#include <QDir>
+#include <QFile>
#include <QFileInfo>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
#include <QLocale>
+#include <QSaveFile>
#include <QSettings>
#include <QStandardPaths>
@@ -37,6 +43,22 @@ namespace {
constexpr int kMinToolbarIconSize = 16;
constexpr int kMaxToolbarIconSize = 64;
+/// queries.json format version. Bump only for a BREAKING change: an optional
+/// field needs no bump, because an older build preserves what it does not
+/// understand rather than dropping it.
+///
+/// Unlike rules.json this file has ONE implementation, so a bump here is not a
+/// two-repo change and no hook stops tagging if it is half-deployed.
+constexpr int kQueriesFormatVersion = 1;
+
+/// Generators a saved query may name in its `generated` field.
+///
+/// A closed set, checked on load so a typo is reported rather than producing a
+/// button that silently finds nothing. Adding one here needs no format bump:
+/// an older build keeps the row and reports it, which is why an unknown
+/// generator is a problem rather than a reason to drop the entry.
+const QStringList kQueryGenerators = { QStringLiteral("sent") };
+
} // namespace
QString Account::scopedQuery(const QString &query) const
@@ -397,17 +419,10 @@ void Config::load(const QString &path)
m_accounts.append(account);
}
- settings.beginGroup(QStringLiteral("queries"));
- // QSettings::childKeys() returns keys in alphabetical order, not file
- // order, so the saved-query button order in the UI is alphabetical too.
- // A hand-rolled parser would be needed to preserve file order; not
- // needed in v1.
- for (const QString &name : settings.childKeys())
- m_savedQueries.append({ name, settings.value(name).toString() });
- settings.endGroup();
-
- // Checked here rather than where startup_query is read: [queries] is not
- // parsed until now. Only a name the user actually wrote is worth a
+ loadSavedQueries(path, settings);
+
+ // Checked here rather than where startup_query is read: the saved queries
+ // 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.
if (m_startupQueryWasSet && !m_savedQueries.isEmpty()
@@ -419,6 +434,224 @@ void Config::load(const QString &path)
}
}
+QString Config::queriesPath(const QString &configPath)
+{
+ return QFileInfo(configPath).absolutePath()
+ + QStringLiteral("/queries.json");
+}
+
+void Config::loadSavedQueries(const QString &configPath, QSettings &settings)
+{
+ m_queriesPath = queriesPath(configPath);
+
+ QFile file(m_queriesPath);
+ if (!file.exists()) {
+ // Migration. Read [queries] once, write the JSON, and leave the INI
+ // section alone: stripping it would mean rewriting a hand-edited file
+ // with QSettings, which drops comments and key order across the WHOLE
+ // file. A few stale lines the user can delete by hand is the cheaper
+ // loss, and it keeps a downgrade working.
+ settings.beginGroup(QStringLiteral("queries"));
+ const QStringList names = settings.childKeys();
+ for (const QString &name : names) {
+ SavedQuery query;
+ query.name = name;
+ query.query = settings.value(name).toString();
+ // Pinned, because these are buttons today. A migration that left
+ // them unpinned would empty the query row on the first launch
+ // after an upgrade, which reads as data loss.
+ query.pinned = true;
+ m_savedQueries.append(query);
+ }
+ settings.endGroup();
+
+ // Sent was a hardcoded button beside the saved queries and becomes an
+ // ordinary row here, so it can be reordered, renamed, unpinned or
+ // removed like any other. It stays GENERATED, so it still follows the
+ // accounts. Appended last, where the button already sat.
+ //
+ // Only when an account actually configures a sent folder: the button
+ // was hidden entirely otherwise, and migrating a row that always finds
+ // nothing would be worse than what it replaces.
+ if (!allSentQuery().isEmpty()) {
+ SavedQuery sent;
+ sent.name = QStringLiteral("Sent");
+ sent.generated = QStringLiteral("sent");
+ sent.pinned = true;
+ sent.flat = true;
+ m_savedQueries.append(sent);
+ }
+
+ // Order is alphabetical here because childKeys() is genuinely all the
+ // INI knows. The user reorders once and it sticks from then on.
+ if (!m_savedQueries.isEmpty() && !saveSavedQueries()) {
+ addProblem(QStringLiteral("Could not write saved queries to %1.")
+ .arg(m_queriesPath));
+ }
+ return;
+ }
+
+ if (!file.open(QIODevice::ReadOnly)) {
+ addProblem(QStringLiteral("Could not read %1: %2.")
+ .arg(m_queriesPath, file.errorString()));
+ m_queriesRefused = true;
+ return;
+ }
+
+ QJsonParseError error;
+ const QJsonDocument document =
+ QJsonDocument::fromJson(file.readAll(), &error);
+ file.close();
+
+ if (error.error != QJsonParseError::NoError || !document.isObject()) {
+ addProblem(QStringLiteral("%1 is not valid JSON: %2.")
+ .arg(m_queriesPath, error.errorString()));
+ m_queriesRefused = true;
+ return;
+ }
+
+ const QJsonObject root = document.object();
+ const int version =
+ root.value(QStringLiteral("version")).toInt(kQueriesFormatVersion);
+ if (version != kQueriesFormatVersion) {
+ // Refused rather than guessed at, and the refusal blocks the save:
+ // rewriting a newer document with this build's reading of it would
+ // destroy whatever the newer build stored.
+ addProblem(QStringLiteral("%1 has format version %2; this build "
+ "understands %3. Saved queries were not "
+ "loaded.")
+ .arg(m_queriesPath)
+ .arg(version)
+ .arg(kQueriesFormatVersion));
+ m_queriesRefused = true;
+ return;
+ }
+
+ for (auto it = root.begin(); it != root.end(); ++it) {
+ if (it.key() != QStringLiteral("version")
+ && it.key() != QStringLiteral("queries"))
+ m_queriesUnknown.insert(it.key(), it.value());
+ }
+
+ const QJsonArray array = root.value(QStringLiteral("queries")).toArray();
+ for (const QJsonValue &value : array) {
+ const QJsonObject object = value.toObject();
+ SavedQuery query;
+ query.name = object.value(QStringLiteral("name")).toString();
+ query.query = object.value(QStringLiteral("query")).toString();
+ query.pinned = object.value(QStringLiteral("pinned")).toBool(false);
+ query.account = object.value(QStringLiteral("account")).toString();
+ query.generated = object.value(QStringLiteral("generated")).toString();
+ // A generator carries its own view mode, so "sent" is flat whether or
+ // not the file says so. Storing it as a plain field would let a
+ // hand-edited or migrated-from-elsewhere row produce a THREADED sent
+ // view, which folds every reply back into the conversation the user
+ // sent one message into. The file may still set it for an ordinary
+ // query.
+ query.flat = object.value(QStringLiteral("flat")).toBool(false)
+ || query.generated == QStringLiteral("sent");
+
+ if (query.isGenerated()
+ && !kQueryGenerators.contains(query.generated)) {
+ // Reported but KEPT. A later build may know this generator, and
+ // dropping the row here would delete it from the file on the next
+ // save, which is the same data loss the unknown-field handling
+ // exists to prevent.
+ addProblem(QStringLiteral("Saved query '%1' uses an unknown "
+ "generator '%2' and will find nothing.")
+ .arg(query.name, query.generated));
+ }
+
+ if (query.name.isEmpty()) {
+ addProblem(QStringLiteral("A saved query in %1 has no name and was "
+ "skipped.").arg(m_queriesPath));
+ continue;
+ }
+
+ for (auto it = object.begin(); it != object.end(); ++it) {
+ static const QStringList known = {
+ QStringLiteral("name"), QStringLiteral("query"),
+ QStringLiteral("pinned"), QStringLiteral("account"),
+ QStringLiteral("generated"), QStringLiteral("flat")
+ };
+ if (!known.contains(it.key()))
+ query.unknown.insert(it.key(), it.value());
+ }
+
+ m_savedQueries.append(query);
+ }
+}
+
+bool Config::saveSavedQueries() const
+{
+ if (m_queriesPath.isEmpty() || m_queriesRefused)
+ return false;
+
+ QJsonArray array;
+ for (const SavedQuery &query : m_savedQueries) {
+ // Only what carries information. The file is hand-editable, so a key
+ // that always holds the same value, or one the generator already
+ // implies, is just something the reader has to skip past. Same reason
+ // `pinned` and `account` are written only when set.
+ QJsonObject object;
+ object.insert(QStringLiteral("name"), query.name);
+ if (query.isGenerated()) {
+ object.insert(QStringLiteral("generated"), query.generated);
+ } else {
+ object.insert(QStringLiteral("query"), query.query);
+ }
+ if (query.pinned)
+ object.insert(QStringLiteral("pinned"), true);
+ if (!query.account.isEmpty())
+ object.insert(QStringLiteral("account"), query.account);
+ // Skipped when the generator already implies it, which loadSavedQueries
+ // reapplies on the way back in.
+ if (query.flat && query.generated != QStringLiteral("sent"))
+ object.insert(QStringLiteral("flat"), true);
+ for (auto it = query.unknown.begin(); it != query.unknown.end(); ++it)
+ object.insert(it.key(), it.value());
+ array.append(object);
+ }
+
+ QJsonObject root = m_queriesUnknown;
+ root.insert(QStringLiteral("version"), kQueriesFormatVersion);
+ root.insert(QStringLiteral("queries"), array);
+
+ QDir().mkpath(QFileInfo(m_queriesPath).absolutePath());
+
+ // QSaveFile writes a temporary and renames on commit, so an interrupted
+ // write cannot leave a half-written file where the queries used to be.
+ QSaveFile file(m_queriesPath);
+ if (!file.open(QIODevice::WriteOnly))
+ return false;
+ file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
+ return file.commit();
+}
+
+QString Config::resolvedQuery(const SavedQuery &query) const
+{
+ // Composed from the accounts every time it is asked for, which is the
+ // point: the answer follows the config rather than a copy of it taken when
+ // the entry was written.
+ if (query.isGenerated()) {
+ if (query.generated == QStringLiteral("sent"))
+ return allSentQuery();
+ // An unknown generator was reported on load. Empty rather than the
+ // bare stored query, which for a generated entry is empty anyway and
+ // would otherwise run as "match everything".
+ return QString();
+ }
+
+ if (query.account.isEmpty())
+ return query.query;
+
+ const Account scope = account(query.account);
+ if (!scope.isValid())
+ return query.query;
+
+ return scope.scopedQuery(query.query);
+}
+
SavedQuery Config::startupSavedQuery() const
{
if (m_savedQueries.isEmpty())
diff --git a/src/config.h b/src/config.h
index ea0b055..b9ee9d6 100644
--- a/src/config.h
+++ b/src/config.h
@@ -19,12 +19,15 @@
#pragma once
#include <QColor>
+#include <QJsonObject>
#include <QList>
#include <QString>
#include <QStringList>
#include "completionentry.h"
+class QSettings;
+
/// One mail account. notmuch has no concept of accounts; it sees a single flat
/// tree. An account is therefore a path prefix within that tree plus an
/// identity.
@@ -96,10 +99,53 @@ struct Account
QString draftsQuery() const;
};
+/// A named query, stored in queries.json.
+///
+/// Stored as an ORDERED array, which is the whole reason the storage moved out
+/// of `[queries]`: QSettings reads a section through childKeys(), which sorts
+/// alphabetically and cannot express the order the buttons appear in.
struct SavedQuery
{
QString name;
QString query;
+
+ /// Renders as a button in the query row; otherwise it lives in the menu.
+ /// The two tiers are the point of the flag: a row that shows every saved
+ /// query does not scale past a handful.
+ bool pinned = false;
+
+ /// Account KEY, the INI group suffix ("work" from [account.work]), and
+ /// empty for a query that spans every account.
+ ///
+ /// A key rather than a maildir path: the path already lives in the account
+ /// section, and storing a second copy here would go stale the moment the
+ /// user edits it. Resolve through Config::resolvedQuery().
+ QString account;
+
+ /// Names a builtin that COMPOSES this query from the accounts at run time,
+ /// rather than storing it. Empty for an ordinary query.
+ ///
+ /// "sent" is the only one today. Its query is built from every account's
+ /// `sent` key, so adding an account or correcting a folder name is a config
+ /// edit and nothing else; a stored copy of the same string would go stale
+ /// silently. That property is why Sent used to be hardcoded beside the
+ /// saved queries instead of living with them, which left one button on the
+ /// row that could not be reordered, renamed, unpinned or removed.
+ ///
+ /// Storing the GENERATOR rather than its output keeps both: the query stays
+ /// live, and the entry is an ordinary row the user owns.
+ QString generated;
+
+ /// Lists messages rather than threads. Set for the sent view, where a
+ /// thread would fold every reply back into the conversation the user sent
+ /// one message into.
+ bool flat = false;
+
+ bool isGenerated() const { return !generated.isEmpty(); }
+
+ /// Keys this build does not understand, preserved verbatim so a file
+ /// written by a later version survives a save from this one.
+ QJsonObject unknown;
};
/// Reads ~/.config/qtmaildir/qtmaildir.conf.
@@ -117,7 +163,32 @@ public:
QList<Account> accounts() const { return m_accounts; }
Account account(const QString &key) const;
+
+ /// In document order, which IS the display order. Never sort this.
QList<SavedQuery> savedQueries() const { return m_savedQueries; }
+ void setSavedQueries(const QList<SavedQuery> &queries)
+ {
+ m_savedQueries = queries;
+ }
+
+ /// Path of queries.json, derived from the config file's own directory so a
+ /// test can point load() at a temporary tree and get both files there.
+ static QString queriesPath(const QString &configPath);
+
+ /// Writes queries.json. False when the file could not be written, or when
+ /// the loaded file had a version this build refuses: overwriting a
+ /// newer-format file with a lossy reading of it is the one outcome worth
+ /// preventing outright.
+ bool saveSavedQueries() const;
+
+ /// The query as it should be run: scoped to its account when it names one.
+ ///
+ /// Composes through Account::scopedQuery(), whose parentheses are
+ /// load-bearing. `path:... and a or b` binds as `(path:... and a) or b`, so
+ /// an unparenthesised disjunction escapes its scope and matches every
+ /// account. An account key naming nothing returns the bare query rather
+ /// than a scope built from an empty maildir, which would be path:"/**".
+ QString resolvedQuery(const SavedQuery &query) const;
/// Empty when unset; the caller disables the Sync button in that case.
QString syncCommand() const { return m_syncCommand; }
@@ -254,8 +325,23 @@ private:
/// Records a notice: nothing is wrong, a feature is simply not configured.
void addNotice(const QString &message);
+ /// Reads queries.json, or migrates [queries] when it is absent. Called by
+ /// load(), which has already parsed the INI by then.
+ void loadSavedQueries(const QString &configPath, QSettings &settings);
+
QList<Account> m_accounts;
QList<SavedQuery> m_savedQueries;
+
+ /// Where saveSavedQueries() writes, remembered from load().
+ QString m_queriesPath;
+
+ /// Top-level keys of queries.json this build does not understand.
+ QJsonObject m_queriesUnknown;
+
+ /// Set when the file was refused for its version. Blocks the save, so a
+ /// document from a newer build is never overwritten with less than it held.
+ bool m_queriesRefused = false;
+
QString m_syncCommand;
QString m_syncLog;
int m_toolbarIconSize = 24;
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..14e4202 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -45,6 +45,7 @@
#include <QScrollBar>
#include <QTimer>
#include <QToolBar>
+#include <QToolButton>
#include <QVBoxLayout>
#include "mailsync.h"
@@ -56,6 +57,7 @@
#include "cardlayout.h"
#include "tagchip.h"
#include "tagdialog.h"
+#include "savequerydialog.h"
#include "tagrulesdialog.h"
#include "threadlistmodel.h"
#include "threadlistview.h"
@@ -339,6 +341,38 @@ 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"))) {
+ // setDefaultAction, not a second connect: the button then takes the
+ // action's text, icon, tooltip and ENABLED state, so it cannot end up
+ // offering to save an empty query while the menu entry refuses.
+ m_saveQueryButton->setDefaultAction(save);
+ // Icon AND text, unlike the toolbar, which follows the desktop's
+ // button style. This button sits in a row of text buttons, the saved
+ // queries, and an icon on its own next to them reads as a different
+ // kind of control than it is. It is also the one action whose meaning
+ // an icon alone does not carry: "save" is a shape everyone knows and
+ // the question is always "save WHAT".
+ m_saveQueryButton->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
+ // Its own text, not the action's: "&Save query..." is menu phrasing,
+ // and a button rendering the ampersand's accelerator and the ellipsis
+ // that promises a dialog reads as a menu entry that escaped. The
+ // action keeps both for the menu it lives in.
+ m_saveQueryButton->setText(tr("Save"));
+
+ 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 +577,27 @@ 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);
- }
+
+ // Beside the field, where a user looks for it. The menu entry and Ctrl+S
+ // were not enough on their own: saving is a thing you decide on while
+ // looking at the results, so it needs to be visible at the query bar
+ // rather than remembered. Created here and given its action in the
+ // constructor, since registerActions() has not run yet.
+ m_saveQueryButton = new QToolButton(central);
+ m_saveQueryButton->setObjectName(QStringLiteral("saveQueryButton"));
+ queryRow->addWidget(m_saveQueryButton);
+
layout->addLayout(queryRow);
+ buildSavedQueryRow(central, layout);
+
// Thread list and message pane.
m_model = new ThreadListModel(this);
m_model->setTagColors(&m_tagColors);
@@ -836,6 +848,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 +998,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 +1077,11 @@ void MainWindow::buildMenus()
// the selection's tags.
{ QStringLiteral("tag_rules"), QStringLiteral("configure") },
{ QStringLiteral("complete_query"), QStringLiteral("edit-find-replace") },
+ // NOT "document-save": that is the floppy/disk shape, which reads as
+ // "write a file somewhere" and asks the user to guess what is being
+ // written. Saving a query is bookmarking a search, and bookmark-new is
+ // the icon set every desktop already uses for "keep this for later".
+ { QStringLiteral("save_query"), QStringLiteral("bookmark-new") },
{ QStringLiteral("select_all"), QStringLiteral("edit-select-all") },
{ QStringLiteral("clear_pane"), QStringLiteral("edit-clear") },
{ QStringLiteral("clear_selection"), QStringLiteral("edit-clear-all") },
@@ -1644,6 +1666,282 @@ 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);
+
+ // Sent is an ordinary row here, not a hardcoded button beside the others.
+ // It is still GENERATED, so its query is composed from the accounts' `sent`
+ // keys at click time and correcting a folder name stays a config edit and
+ // nothing else; what changed is that the entry can now be reordered,
+ // renamed, unpinned or removed like every other, instead of being the one
+ // control on the row the user did not own.
+ QList<SavedQuery> unpinned;
+ for (const SavedQuery &saved : m_config.savedQueries()) {
+ // A generator whose accounts configure nothing produces a button that
+ // always finds nothing. Skipped entirely, which is what the hardcoded
+ // Sent button did and is worth keeping.
+ if (saved.isGenerated() && m_config.resolvedQuery(saved).isEmpty())
+ continue;
+
+ if (!saved.pinned) {
+ unpinned.append(saved);
+ continue;
+ }
+ auto *button = new QPushButton(saved.name, row);
+ // The generated entries keep a stable object name so a test can find
+ // the sent button without depending on what the user renamed it to.
+ if (saved.generated == QStringLiteral("sent"))
+ button->setObjectName(QStringLiteral("sentButton"));
+ connect(button, &QPushButton::clicked, this,
+ [this, saved]() { runSavedQuery(saved); });
+ addSavedQueryActions(button, saved);
+ box->addWidget(button);
+ }
+
+ // Everything above is left-aligned; the stretch here pushes what follows
+ // to the right edge. The buttons are the row's content and read as a set,
+ // while the overflow menu is a control over that set, so it sits apart
+ // from them rather than trailing the last one.
+ const int contentCount = box->count();
+ box->addStretch(1);
+
+ // 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); });
+ // A menu entry has no context menu of its own, so its own submenu
+ // carries the same three actions; an unpinned query would
+ // otherwise be the one thing that cannot be edited or deleted.
+ auto *entryMenu = new QMenu(menu);
+ addSavedQueryActions(entryMenu, saved);
+ action->setMenu(entryMenu);
+ }
+ menuButton->setMenu(menu);
+ box->addWidget(menuButton);
+ }
+
+ layout->addWidget(row);
+
+ // Nothing on either side of the stretch leaves an empty strip of padding,
+ // so the row goes away rather than sitting there as a gap. Counted before
+ // the stretch was added, since the stretch is always there: an unpinned
+ // query with no pinned ones still needs the row for its menu.
+ if (contentCount == 0 && unpinned.isEmpty())
+ row->hide();
+}
+
+void MainWindow::addSavedQueryActions(QWidget *target, const SavedQuery &saved)
+{
+ target->setContextMenuPolicy(Qt::ActionsContextMenu);
+
+ auto *edit = new QAction(tr("Edit..."), target);
+ edit->setObjectName(QStringLiteral("editQuery"));
+ connect(edit, &QAction::triggered, this,
+ [this, saved]() { editSavedQuery(saved); });
+ target->addAction(edit);
+
+ auto *pin = new QAction(saved.pinned ? tr("Move to menu")
+ : tr("Show as a button"),
+ target);
+ pin->setObjectName(QStringLiteral("pinQuery"));
+ connect(pin, &QAction::triggered, this, [this, saved]() {
+ SavedQuery toggled = saved;
+ toggled.pinned = !saved.pinned;
+ replaceSavedQuery(saved.name, toggled);
+ });
+ target->addAction(pin);
+
+ auto *separator = new QAction(target);
+ separator->setSeparator(true);
+ target->addAction(separator);
+
+ auto *remove = new QAction(tr("Delete"), target);
+ remove->setObjectName(QStringLiteral("deleteQuery"));
+ connect(remove, &QAction::triggered, this,
+ [this, saved]() { deleteSavedQuery(saved); });
+ target->addAction(remove);
+}
+
+void MainWindow::editSavedQuery(const SavedQuery &saved)
+{
+ SaveQueryDialog dialog(m_config, saved, this);
+ if (dialog.exec() != QDialog::Accepted)
+ return;
+
+ // Matched on the name the dialog OPENED with. Using the returned name would
+ // leave the original entry in place and add a second one under the new
+ // name, which is a duplicate rather than a rename.
+ replaceSavedQuery(saved.name, dialog.savedQuery());
+}
+
+void MainWindow::deleteSavedQuery(const SavedQuery &saved)
+{
+ // One of the few places in this application that confirms. The rule against
+ // confirmation dialogs covers tag mutations, which are undoable through the
+ // undo stack; this writes user config, is not on that stack, and cannot be
+ // taken back.
+ if (m_confirmDelete) {
+ const auto answer = QMessageBox::question(
+ this, tr("Delete saved query"),
+ tr("Delete the saved query '%1'?").arg(saved.name),
+ QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
+ if (answer != QMessageBox::Yes)
+ return;
+ }
+
+ replaceSavedQuery(saved.name, SavedQuery());
+}
+
+void MainWindow::replaceSavedQuery(const QString &originalName,
+ const SavedQuery &replacement)
+{
+ QList<SavedQuery> queries = m_config.savedQueries();
+ const bool removing = replacement.name.isEmpty();
+
+ for (int i = 0; i < queries.size(); ++i) {
+ if (queries.at(i).name.compare(originalName, Qt::CaseInsensitive) != 0)
+ continue;
+
+ if (removing) {
+ queries.removeAt(i);
+ } else {
+ // The unknown fields belong to the STORED entry: a field written by
+ // a later build survives an edit made here rather than being
+ // dropped on the next save.
+ SavedQuery merged = replacement;
+ merged.unknown = queries.at(i).unknown;
+ queries[i] = merged;
+ }
+ break;
+ }
+
+ m_config.setSavedQueries(queries);
+ if (!m_config.saveSavedQueries()) {
+ QMessageBox::warning(this, tr("Saved queries"),
+ tr("Could not write the saved queries file."));
+ return;
+ }
+
+ rebuildSavedQueryRow();
+ statusBar()->showMessage(
+ removing ? tr("Deleted saved query '%1'.").arg(originalName)
+ : tr("Updated saved query '%1'.").arg(replacement.name),
+ kStatusMessageMs);
+}
+
+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);
+
+ // A generated entry has no stored query: the text is composed from the
+ // accounts now, so what lands in the bar is what actually ran and the user
+ // can see and edit it.
+ m_queryEdit->setText(saved.isGenerated() ? m_config.resolvedQuery(saved)
+ : saved.query);
+
+ // Flat for this query only. runQuery() sets the mode on EVERY run, so the
+ // flag cannot outlive the entry that asked for it, including for the same
+ // query typed by hand afterwards.
+ runQuery(saved.flat ? FlatResult::Yes : FlatResult::No);
+}
+
+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);
+ // Reparented out NOW, not merely scheduled for deletion. deleteLater()
+ // defers destruction to the event loop, so the old row goes on answering
+ // findChild() until it runs, and findChild returns the FIRST match: every
+ // lookup after a rebuild found the stale row and reported the state from
+ // before the edit. Nothing visible was wrong, which is why this only
+ // showed up as three tests failing on a row that had in fact been rebuilt.
+ old->setParent(nullptr);
+ 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..6e8501a 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -49,6 +49,8 @@ class QPlainTextEdit;
class QSplitter;
class QProgressBar;
class QTimer;
+class QToolButton;
+class QVBoxLayout;
class ThreadListModel;
class MessageView;
@@ -123,6 +125,25 @@ public:
static void setLocksPathForTesting(const QString &path);
static QString locksPath();
+ /// Suppresses the delete confirmation.
+ ///
+ /// A test seam. Deleting a saved query is destructive and not on the undo
+ /// stack, so it asks first; a test cannot answer a modal dialog without
+ /// hanging, and driving one through QTest would assert the dialog rather
+ /// than the deletion.
+ void setConfirmDeleteForTesting(bool confirm) { m_confirmDelete = confirm; }
+
+ /// Renames or replaces a stored query, as the edit dialog would on accept.
+ ///
+ /// A test seam for the rename path specifically: the dialog is modal, and
+ /// the property worth asserting is that a rename REPLACES rather than
+ /// duplicating, which is decided after the dialog returns.
+ void replaceSavedQueryForTesting(const QString &originalName,
+ const SavedQuery &replacement)
+ {
+ replaceSavedQuery(originalName, replacement);
+ }
+
/// How many commands are on the undo stack.
///
/// A test seam. The undo QAction is always enabled and checks canUndo()
@@ -231,6 +252,41 @@ 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();
+
+ /// Hangs Edit, Pin/Unpin and Delete on a saved query's button or menu
+ /// entry. The only route to changing a stored query from the UI.
+ void addSavedQueryActions(QWidget *target, const SavedQuery &saved);
+
+ /// Replaces the entry named `originalName`, writes the file and rebuilds
+ /// the row. An empty `replacement.name` deletes it instead.
+ ///
+ /// Matched on the ORIGINAL name, not the replacement's: a rename otherwise
+ /// leaves the old entry in place and adds a second one.
+ void replaceSavedQuery(const QString &originalName,
+ const SavedQuery &replacement);
+
+ void editSavedQuery(const SavedQuery &saved);
+ void deleteSavedQuery(const SavedQuery &saved);
+
private slots:
void runCurrentQuery() { runQuery(FlatResult::No); }
@@ -604,6 +660,10 @@ private:
QUndoStack m_undoStack;
QLineEdit *m_queryEdit = nullptr;
+ /// Save query, beside the field. Driven by the save_query action.
+ QToolButton *m_saveQueryButton = nullptr;
+ /// Whether deleting a saved query asks first. Always true outside tests.
+ bool m_confirmDelete = true;
QueryCompleter *m_queryCompleter = nullptr;
/// Its own type, not the QTreeView base. The strip painting and the
/// expander column are ThreadListView's, and holding the base here only
diff --git a/src/savequerydialog.cpp b/src/savequerydialog.cpp
new file mode 100644
index 0000000..2d09986
--- /dev/null
+++ b/src/savequerydialog.cpp
@@ -0,0 +1,174 @@
+/*
+ * 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)
+{
+ SavedQuery initial;
+ initial.query = query;
+ initial.account = accountKey;
+ initial.pinned = true;
+ setWindowTitle(tr("Save query"));
+ build(initial);
+}
+
+SaveQueryDialog::SaveQueryDialog(const Config &config,
+ const SavedQuery &existing, QWidget *parent)
+ : QDialog(parent)
+ , m_config(config)
+ , m_originalName(existing.name)
+ , m_generated(existing.generated)
+ , m_flat(existing.flat)
+{
+ setWindowTitle(tr("Edit saved query"));
+ build(existing);
+}
+
+void SaveQueryDialog::build(const SavedQuery &initial)
+{
+ auto *layout = new QVBoxLayout(this);
+ auto *form = new QFormLayout;
+
+ m_name = new QLineEdit(initial.name, 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(initial.query, this);
+ m_query->setObjectName(QStringLiteral("saveQueryQuery"));
+ if (initial.isGenerated()) {
+ // A generated entry has no stored query: it is composed from the
+ // accounts every time it runs. Shown, so the user can see what it will
+ // do, but read-only, since editing it would change nothing.
+ m_query->setText(m_config.resolvedQuery(initial));
+ m_query->setReadOnly(true);
+ m_query->setToolTip(tr("Built from your accounts and not editable. "
+ "It follows the sent folder each account "
+ "configures."));
+ form->addRow(tr("Query"), m_query);
+ } else {
+ 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 : m_config.accounts())
+ m_account->addItem(account.key, account.key);
+ const int index = m_account->findData(initial.account);
+ 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(initial.pinned);
+ 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);
+
+ // Ignores the entry being edited: warning that "Inbox" already exists
+ // while editing Inbox is noise, and the real case worth catching is a
+ // rename onto a name something else already holds.
+ const bool isItsOwnName =
+ !m_originalName.isEmpty()
+ && name.compare(m_originalName, Qt::CaseInsensitive) == 0;
+ if (!name.isEmpty() && !isItsOwnName
+ && 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();
+ // Carried through rather than re-derived: an edit must not turn a
+ // generated entry into a plain one holding a snapshot of what it happened
+ // to resolve to today.
+ saved.generated = m_generated;
+ saved.flat = m_flat;
+ if (saved.isGenerated())
+ saved.query.clear();
+ return saved;
+}
diff --git a/src/savequerydialog.h b/src/savequerydialog.h
new file mode 100644
index 0000000..d859254
--- /dev/null
+++ b/src/savequerydialog.h
@@ -0,0 +1,79 @@
+/*
+ * 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);
+
+ /// Edits an entry that already exists, prefilled from it rather than from
+ /// the query bar.
+ SaveQueryDialog(const Config &config, const SavedQuery &existing,
+ 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 build(const SavedQuery &initial);
+ void updateOkState();
+
+ /// The name the dialog was opened on, empty when creating. The caller
+ /// matches on this rather than on the returned name, so a rename replaces
+ /// the entry instead of adding a second one beside it.
+ QString m_originalName;
+
+ /// Set for a generated entry, whose query is composed from the accounts
+ /// and cannot be edited here.
+ QString m_generated;
+ bool m_flat = false;
+
+ 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;
+};