diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | src/config.cpp | 255 | ||||
| -rw-r--r-- | src/config.h | 86 | ||||
| -rw-r--r-- | src/keymap.cpp | 5 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 374 | ||||
| -rw-r--r-- | src/mainwindow.h | 60 | ||||
| -rw-r--r-- | src/savequerydialog.cpp | 174 | ||||
| -rw-r--r-- | src/savequerydialog.h | 79 |
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; +}; |
