aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-13 20:07:31 +0200
committerDanilo M. <danix@danix.xyz>2026-08-13 20:07:31 +0200
commita7872cf56b7f313881f0d5e50d548ffdb5ba68b9 (patch)
tree569a295cc9332b13d19543faf8342c2a461db806 /src
parent97c81f8cad571ce9ce724ddab8269e911df05a7c (diff)
downloadqtmaildir-a7872cf56b7f313881f0d5e50d548ffdb5ba68b9.tar.gz
qtmaildir-a7872cf56b7f313881f0d5e50d548ffdb5ba68b9.zip
feat(queries): edit, pin and delete a saved query from the UI
Item 82. Saving a query worked and nothing else did: changing one field meant retyping the whole query under the same name, and deleting one meant editing the file by hand. An action that creates something the UI cannot then change or remove is incomplete, and the user hit it within minutes of the first hand test. Right-clicking a saved query, on its button or its menu entry, now offers Edit, Move to menu / Show as a button, and Delete. Every path funnels through one replaceSavedQuery(), which matches on the name the dialog was OPENED with rather than the one it returns, so a rename replaces the entry instead of leaving the original behind beside a new one, and which merges the stored entry's unknown fields in a single place rather than in three. Delete confirms first: the rule against confirmation dialogs covers tag mutations, which the undo stack can take back, and this writes user config that it cannot. Two cases the item did not anticipate. A generated entry has no query to edit, so the dialog shows its composed query read-only rather than offering a field that changes nothing, and carries `generated` and `flat` through an edit rather than letting it decay into a plain entry holding a snapshot of what it resolved to today. And the overwrite notice had to learn to ignore the entry being edited, since warning that "Inbox" already exists while editing Inbox is noise. This also fixes a defect that predated it and was already reachable from the save path. rebuildSavedQueryRow() called deleteLater() on the old row, which defers destruction to the event loop, so the stale row went on answering findChild() and every lookup after a rebuild reported the state from before the edit. Nothing looked wrong on screen, which is why it surfaced only as three tests failing against a row that had in fact been rebuilt correctly. Five tests, three mutations. Matching on the returned name fails two, never writing the file fails three, and dropping the unknown-field merge fails one. That last one initially proved nothing: it drove UNPIN, which copies the stored entry and so carries `unknown` along by itself, and passed with the merge deleted. It now goes through the edit path with a replacement that has none, which is what the dialog actually returns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/mainwindow.cpp113
-rw-r--r--src/mainwindow.h36
-rw-r--r--src/savequerydialog.cpp60
-rw-r--r--src/savequerydialog.h16
4 files changed, 218 insertions, 7 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 62f4751..14e4202 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -1698,6 +1698,7 @@ void MainWindow::buildSavedQueryRow(QWidget *parent, QVBoxLayout *layout)
button->setObjectName(QStringLiteral("sentButton"));
connect(button, &QPushButton::clicked, this,
[this, saved]() { runSavedQuery(saved); });
+ addSavedQueryActions(button, saved);
box->addWidget(button);
}
@@ -1718,6 +1719,12 @@ void MainWindow::buildSavedQueryRow(QWidget *parent, QVBoxLayout *layout)
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);
@@ -1733,6 +1740,105 @@ void MainWindow::buildSavedQueryRow(QWidget *parent, QVBoxLayout *layout)
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
@@ -1817,6 +1923,13 @@ void MainWindow::rebuildSavedQueryRow()
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);
diff --git a/src/mainwindow.h b/src/mainwindow.h
index e6ee322..6e8501a 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -125,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()
@@ -253,6 +272,21 @@ private:
/// 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); }
@@ -628,6 +662,8 @@ private:
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
index 1ea97c2..2d09986 100644
--- a/src/savequerydialog.cpp
+++ b/src/savequerydialog.cpp
@@ -42,19 +42,51 @@ SaveQueryDialog::SaveQueryDialog(const Config &config, const QString &query,
: 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(this);
+ 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(query, this);
+ m_query = new QLineEdit(initial.query, this);
m_query->setObjectName(QStringLiteral("saveQueryQuery"));
- form->addRow(tr("Query"), m_query);
+ 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
@@ -63,15 +95,15 @@ SaveQueryDialog::SaveQueryDialog(const Config &config, const QString &query,
m_account = new QComboBox(this);
m_account->setObjectName(QStringLiteral("saveQueryAccount"));
m_account->addItem(tr("All accounts"), QString());
- for (const Account &account : config.accounts())
+ for (const Account &account : m_config.accounts())
m_account->addItem(account.key, account.key);
- const int index = m_account->findData(accountKey);
+ 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(true);
+ m_pinned->setChecked(initial.pinned);
form->addRow(QString(), m_pinned);
layout->addLayout(form);
@@ -108,7 +140,14 @@ void SaveQueryDialog::updateOkState()
const bool usable = !name.isEmpty() && !m_query->text().trimmed().isEmpty();
m_ok->setEnabled(usable);
- if (!name.isEmpty() && namesAnExistingQuery(m_config, name)) {
+ // 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));
@@ -124,5 +163,12 @@ SavedQuery SaveQueryDialog::savedQuery() const
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
index be5a2af..d859254 100644
--- a/src/savequerydialog.h
+++ b/src/savequerydialog.h
@@ -42,6 +42,11 @@ public:
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;
@@ -51,8 +56,19 @@ public:
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;