summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-04 11:36:16 +0200
committerDanilo M. <danix@danix.xyz>2026-08-04 12:54:54 +0200
commit0a160651cfb9a0f580bcc446941058a339e55643 (patch)
tree911d60baf92426f5f32a662d5e9ebf6e72ae0cf2
parent4574a6e8d1253c0a70972f0c6e4d13d528ea6420 (diff)
downloadqtmaildir-0a160651cfb9a0f580bcc446941058a339e55643.tar.gz
qtmaildir-0a160651cfb9a0f580bcc446941058a339e55643.zip
feat(tags): add an Edit tags dialog on Ctrl+T
Five hardcoded tags were the only ones reachable from the UI: archive, delete, spam, flag and toggle_unread. For an application whose purpose is organising mail by tag, applying any other one meant leaving for a terminal. Item 26 of the usability backlog, raised by the user asking how to add a tag and finding they could not. One dialog rather than separate add and remove actions, at the user's choice: filing something under a new tag while dropping inbox is one thought, not two. Type tags to add or remove, comma separated, or clear a checkbox to drop a tag already on the selection without retyping its name. Both fields complete against the tag list MainWindow already holds for the query completer. Completion is a guard against typing shoppping beside shopping, never a whitelist: inventing a tag is the entire point, so any valid name goes through whether or not it exists yet. Tri-state checkboxes carry the multi-thread case, and are where the risk is. A tag on some selected threads shows partially checked, and leaving it alone changes nothing; the opposite reading would silently tag threads the user never looked at. A tag already on every thread and left checked is likewise not a change and is not sent as one. Tag names are validated before anything is applied, through a free function so the rules are testable on their own. Empty, a leading dash (notmuch's CLI reads it as removal, making such a tag a trap), whitespace and control characters are refused by name and reason. Nothing is applied until the whole set passes, since the user cannot tell which half of a partial change landed. TagDialog is pure UI: handed the vocabulary and the current state, returning two lists, contacting no worker. That is what lets its fifteen tests run without a notmuch database. Integration is a single call to the existing tagSelected(), so undo, the optimistic model update, the combined multi-row query and the completer refresh for a brand-new tag all come for free. One test assumption was wrong and the code was right: a case asserted that QStringLiteral("null\0byte") truncates at the null and reads as empty. It does not, so the null is caught as a control character. The test was corrected rather than the validator. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--README.md22
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md34
-rw-r--r--src/CMakeLists.txt1
-rw-r--r--src/keymap.cpp2
-rw-r--r--src/mainwindow.cpp41
-rw-r--r--src/mainwindow.h6
-rw-r--r--src/tagdialog.cpp237
-rw-r--r--src/tagdialog.h102
-rw-r--r--tests/CMakeLists.txt1
-rw-r--r--tests/test_tagdialog.cpp243
10 files changed, 688 insertions, 1 deletions
diff --git a/README.md b/README.md
index a52a211..ec4c22a 100644
--- a/README.md
+++ b/README.md
@@ -248,6 +248,27 @@ an attachment, so it is visible without opening the thread. It comes from the
`attachment` tag notmuch applies while indexing, not from parsing the message,
and costs no extra query.
+## Tagging
+
+Archive, delete, spam, flag and toggle-unread write fixed tags. For anything
+else, **Ctrl+T** opens a dialog over the selected threads: type tags to add or
+remove, separated by commas, or clear a checkbox to drop a tag already present.
+
+Both fields complete against every tag in your database, which is a guard
+against typing `shoppping` beside `shopping`, not a restriction: a tag that does
+not exist yet is exactly what the dialog is for, so any valid name is accepted.
+
+With several threads selected, a tag on only some of them shows a partially
+checked box saying how many. **Leaving it alone changes nothing.** Check it to
+apply it to all, clear it to remove it from all.
+
+Tag names are rejected if empty, if they start with `-` (notmuch reads that as
+"remove this tag"), or if they contain spaces or unprintable characters. The
+dialog says which name was refused and why, and applies nothing until the whole
+set is valid.
+
+Every change goes on the undo stack, so `Ctrl+Z` reverses a mistyped tag.
+
## Unsynced changes
Tagging changes the notmuch index at once, but the mail store only learns about
@@ -311,6 +332,7 @@ Defaults, all rebindable through `[keys]`:
| `Ctrl+Space` | `complete_query` | Focus the query bar and offer completions |
| `Ctrl+H` | `toggle_html` | Switch the thread between HTML and plain text |
| `Ctrl+M` | `load_remote` | Load remote images for the current thread |
+| `Ctrl+T` | `edit_tags` | Add or remove any tag on the selected threads |
| `Ctrl+Shift+D` | `message_details` | Show the full headers of every message in the thread |
| `Ctrl+Z` | `undo` | Undo the last tag change |
| `Ctrl+G` | `sync` | Run the configured sync command |
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
index 6fd40c1..4df48dd 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -64,7 +64,7 @@ taking that too literally.
| 23 | No way to save a search query from the UI | workflow | M | open |
| 24 | No right-click actions on the thread list | discoverability | S | open |
| 25 | No select-all, and bulk actions are undiscoverable | workflow | S | open |
-| 26 | No way to add or remove an arbitrary tag from the UI | workflow | S | open |
+| 26 | No way to add or remove an arbitrary tag from the UI | workflow | S | **done** |
Sizes are rough: XS under an hour, S a sitting, M a session.
@@ -1160,6 +1160,38 @@ dialog and two actions, not new plumbing.
refreshes the list when a mutation introduces an unknown tag, so that path is
in place and should be relied on rather than duplicated.
+### Outcome (done)
+
+Built as the user chose: one **Edit tags** dialog on `Ctrl+T` rather than
+separate add and remove actions, since filing something under a new tag while
+dropping `inbox` is one thought.
+
+`TagDialog` is pure UI in `qtmaildir_lib`. It is handed the vocabulary and the
+selection's current tags and returns two lists; it contacts no worker and holds
+no database handle, which is what lets fifteen tests run without a notmuch
+database. Integration is one call to the existing `tagSelected()`, so undo, the
+optimistic model update, the one-query multi-row resolution and the completer
+refresh all come for free.
+
+**Tri-state is the part that needed the tests.** With several threads selected a
+tag can be on some, and `PartiallyChecked` means "leave alone" rather than
+"apply to all". The opposite reading silently tags threads the user never
+looked at. `m_fullyTagged` exists for the neighbouring case: a tag already on
+every thread and left checked is not a change and must not be sent as one.
+
+**Validation is a free function** so the rules are testable directly. It rejects
+empty, a leading `-` (notmuch's CLI reads that as removal, so such a tag is a
+trap), whitespace, and control characters. Nothing is applied until every name
+passes, since a half-applied change is worse than none: the user cannot tell
+which half landed.
+
+**One test assumption was wrong and the code was right.** A case asserted that
+`QStringLiteral("null\0byte")` truncates at the null and reads as Empty. It
+does not; the literal is kept whole, so the null is caught as a control
+character. The test was corrected, not the validator.
+
+Rendered and inspected rather than only asserted.
+
---
## Deferred, unsized, or split out
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index de19c8f..5214f42 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -8,6 +8,7 @@ add_library(qtmaildir_lib STATIC
notmuchworker.cpp
tagchip.cpp
tagcolors.cpp
+ tagdialog.cpp
tagstrip.cpp
threadlistmodel.cpp
mailsync.cpp
diff --git a/src/keymap.cpp b/src/keymap.cpp
index b18f5d5..0901cfb 100644
--- a/src/keymap.cpp
+++ b/src/keymap.cpp
@@ -31,6 +31,7 @@ QStringList KeyMap::knownActions()
QStringLiteral("delete"),
QStringLiteral("spam"),
QStringLiteral("toggle_unread"),
+ QStringLiteral("edit_tags"),
QStringLiteral("flag"),
QStringLiteral("focus_query"),
QStringLiteral("complete_query"),
@@ -65,6 +66,7 @@ QList<QPair<QString, QString>> KeyMap::defaultBindings()
{ QStringLiteral("Ctrl+Shift+S"), QStringLiteral("spam") },
{ QStringLiteral("Ctrl+U"), QStringLiteral("toggle_unread") },
{ QStringLiteral("Ctrl+I"), QStringLiteral("flag") },
+ { QStringLiteral("Ctrl+T"), QStringLiteral("edit_tags") },
{ QStringLiteral("Ctrl+L"), QStringLiteral("focus_query") },
// Ctrl+Space is the completion idiom users already carry over from
// shells and editors, and it is a named key rather than a symbol, so
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index d8fba3e..000be85 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -52,6 +52,7 @@
#include "notmuchworker.h"
#include "querycompleter.h"
#include "tagchip.h"
+#include "tagdialog.h"
#include "threadlistmodel.h"
#include "version.h"
@@ -548,6 +549,10 @@ void MainWindow::registerActions()
else
tagSelected({ QStringLiteral("unread") }, {}, tr("Mark unread"));
});
+ addAction(QStringLiteral("edit_tags"), tr("Edit &tags..."),
+ tr("Add or remove any tag on the selected threads"), [this]() {
+ editTagsOnSelection();
+ });
addAction(QStringLiteral("toggle_html"), tr("Toggle &HTML"),
tr("Switch the thread between HTML and plain text"), [this]() {
m_messageView->toggleHtml();
@@ -634,6 +639,7 @@ void MainWindow::buildMenus()
messageMenu->addAction(m_actions.value(QStringLiteral("spam")));
messageMenu->addSeparator();
messageMenu->addAction(m_actions.value(QStringLiteral("toggle_unread")));
+ messageMenu->addAction(m_actions.value(QStringLiteral("edit_tags")));
messageMenu->addAction(m_actions.value(QStringLiteral("flag")));
auto *viewMenu = menuBar()->addMenu(tr("&View"));
@@ -1152,6 +1158,41 @@ void MainWindow::markCurrentThreadRead()
tr("Mark read"));
}
+void MainWindow::editTagsOnSelection()
+{
+ const QModelIndexList rows =
+ m_threadView->selectionModel()->selectedRows();
+ if (rows.isEmpty()) {
+ m_statusLabel->setText(tr("Select a thread first"));
+ return;
+ }
+
+ // How many of the selected threads carry each tag, which is what tells a
+ // tag that is on all of them from one that is on some.
+ QHash<QString, int> counts;
+ for (const QModelIndex &index : rows) {
+ const ThreadSummary thread = m_model->threadAt(index.row());
+ for (const QString &tag : thread.tags)
+ counts[tag] += 1;
+ }
+
+ // m_knownTags is the same list the query completer uses, so the dialog
+ // offers every tag in the database without a round trip.
+ TagDialog dialog(m_knownTags, counts, rows.size(), this);
+ if (dialog.exec() != QDialog::Accepted)
+ return;
+
+ const QStringList add = dialog.tagsToAdd();
+ const QStringList remove = dialog.tagsToRemove();
+ if (add.isEmpty() && remove.isEmpty())
+ return; // Applied with nothing changed.
+
+ // Straight through tagSelected(), so this inherits undo, the optimistic
+ // model update, the one-query multi-row resolution, and the completer
+ // refresh for a tag that did not exist before.
+ tagSelected(add, remove, tr("Edit tags"));
+}
+
void MainWindow::tagSelected(const QStringList &add, const QStringList &remove,
const QString &description)
{
diff --git a/src/mainwindow.h b/src/mainwindow.h
index b0a4cfd..7897038 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -134,6 +134,12 @@ private:
/// Redraws the unsynced-edits indicator from m_pendingEdits.
void updatePendingIndicator();
+ /// Opens the tag dialog on the current selection and applies its result.
+ ///
+ /// The only route to an arbitrary tag: every other tag action writes a
+ /// hardcoded name.
+ void editTagsOnSelection();
+
/// Set once the user has answered the exit prompt, or once a sync started
/// for exit has finished. Stops closeEvent asking a second time, and is
/// what lets the deferred close through.
diff --git a/src/tagdialog.cpp b/src/tagdialog.cpp
new file mode 100644
index 0000000..94d2e05
--- /dev/null
+++ b/src/tagdialog.cpp
@@ -0,0 +1,237 @@
+/*
+ * 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 "tagdialog.h"
+
+#include <QCompleter>
+#include <QCoreApplication>
+#include <QDialogButtonBox>
+#include <QFormLayout>
+#include <QHash>
+#include <QLabel>
+#include <QLineEdit>
+#include <QListWidget>
+#include <QMessageBox>
+#include <QPushButton>
+#include <QVBoxLayout>
+
+TagNameProblem validateTagName(const QString &tag)
+{
+ const QString trimmed = tag.trimmed();
+
+ if (trimmed.isEmpty())
+ return TagNameProblem::Empty;
+
+ // notmuch's CLI reads "-tag" as an instruction to remove that tag, so a tag
+ // actually named "-inbox" is a trap the user cannot easily undo later.
+ if (trimmed.startsWith(QLatin1Char('-')))
+ return TagNameProblem::LeadingDash;
+
+ for (const QChar c : trimmed) {
+ // Checked before the general control-character test, since space is not
+ // a control character but splits a tag just as effectively.
+ if (c.isSpace())
+ return TagNameProblem::ContainsSpace;
+ if (c.category() == QChar::Other_Control)
+ return TagNameProblem::ControlChar;
+ }
+
+ return TagNameProblem::Ok;
+}
+
+QString tagNameProblemText(TagNameProblem problem, const QString &tag)
+{
+ switch (problem) {
+ case TagNameProblem::Ok:
+ return {};
+ case TagNameProblem::Empty:
+ return QCoreApplication::translate("TagDialog",
+ "A tag name cannot be empty.");
+ case TagNameProblem::LeadingDash:
+ return QCoreApplication::translate(
+ "TagDialog",
+ "'%1' cannot start with '-': notmuch reads a leading dash as an "
+ "instruction to remove a tag.").arg(tag);
+ case TagNameProblem::ContainsSpace:
+ return QCoreApplication::translate(
+ "TagDialog", "'%1' cannot contain spaces.").arg(tag);
+ case TagNameProblem::ControlChar:
+ return QCoreApplication::translate(
+ "TagDialog", "'%1' contains a character that cannot be typed "
+ "back.").arg(tag);
+ }
+ return {};
+}
+
+namespace {
+
+/// Splits a line edit's contents into tag names, dropping empties.
+///
+/// Comma-separated, so several tags can be applied in one pass. A tag name
+/// cannot contain a comma once validateTagName() has run, which is what makes
+/// this unambiguous.
+QStringList splitTags(const QString &text)
+{
+ QStringList tags;
+ const QStringList parts = text.split(QLatin1Char(','), Qt::SkipEmptyParts);
+ for (const QString &part : parts) {
+ const QString trimmed = part.trimmed();
+ if (!trimmed.isEmpty())
+ tags.append(trimmed);
+ }
+ return tags;
+}
+
+} // namespace
+
+TagDialog::TagDialog(const QStringList &knownTags,
+ const QHash<QString, int> &currentTags,
+ int threadCount,
+ QWidget *parent)
+ : QDialog(parent), m_threadCount(threadCount)
+{
+ setWindowTitle(tr("Edit tags"));
+
+ auto *layout = new QVBoxLayout(this);
+
+ auto *heading = new QLabel(tr("%n selected thread(s)", "", threadCount),
+ this);
+ layout->addWidget(heading);
+
+ auto *form = new QFormLayout;
+
+ m_addEdit = new QLineEdit(this);
+ m_addEdit->setPlaceholderText(tr("tag, or several separated by commas"));
+ m_removeEdit = new QLineEdit(this);
+ m_removeEdit->setPlaceholderText(tr("tag, or several separated by commas"));
+
+ // Completion is a guard against typos, never a whitelist: a tag absent from
+ // this list is exactly what the dialog exists to create, so the completer
+ // suggests and does not constrain.
+ for (QLineEdit *edit : { m_addEdit, m_removeEdit }) {
+ auto *completer = new QCompleter(knownTags, edit);
+ completer->setCaseSensitivity(Qt::CaseInsensitive);
+ // Hierarchies are the reason this matters: typing "amazon" should find
+ // "shopping/amazon".
+ completer->setFilterMode(Qt::MatchContains);
+ edit->setCompleter(completer);
+ }
+
+ form->addRow(tr("Add:"), m_addEdit);
+ form->addRow(tr("Remove:"), m_removeEdit);
+ layout->addLayout(form);
+
+ // The tags already on the selection, so removing one does not require
+ // remembering its name. Tri-state, because with several threads selected a
+ // tag can be on some and not others.
+ m_currentList = new QListWidget(this);
+ QStringList sorted = currentTags.keys();
+ sorted.sort();
+ for (const QString &tag : sorted) {
+ const int count = currentTags.value(tag);
+
+ auto *item = new QListWidgetItem(tag, m_currentList);
+ if (count >= threadCount) {
+ item->setCheckState(Qt::Checked);
+ m_fullyTagged.append(tag);
+ } else {
+ item->setCheckState(Qt::PartiallyChecked);
+ // Say what "partial" means in numbers, rather than leaving the user
+ // to infer it from a shaded box.
+ item->setText(tr("%1 (on %2 of %3)")
+ .arg(tag).arg(count).arg(threadCount));
+ item->setData(Qt::UserRole, tag);
+ }
+ if (item->data(Qt::UserRole).isNull())
+ item->setData(Qt::UserRole, tag);
+ }
+
+ if (m_currentList->count() > 0) {
+ layout->addWidget(new QLabel(tr("Tags on the selection:"), this));
+ layout->addWidget(m_currentList);
+ } else {
+ m_currentList->hide();
+ }
+
+ auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok
+ | QDialogButtonBox::Cancel,
+ this);
+ buttons->button(QDialogButtonBox::Ok)->setText(tr("Apply"));
+ connect(buttons, &QDialogButtonBox::accepted, this, &TagDialog::accept);
+ connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
+ layout->addWidget(buttons);
+
+ m_addEdit->setFocus();
+}
+
+void TagDialog::accept()
+{
+ QStringList add = splitTags(m_addEdit->text());
+ QStringList remove = splitTags(m_removeEdit->text());
+
+ // Validate before applying anything: a partial change is worse than none,
+ // since the user cannot tell which half landed.
+ for (const QStringList &list : { add, remove }) {
+ for (const QString &tag : list) {
+ const TagNameProblem problem = validateTagName(tag);
+ if (problem != TagNameProblem::Ok) {
+ QMessageBox::warning(this, tr("Invalid tag"),
+ tagNameProblemText(problem, tag));
+ return; // Stay open, with the text still there to fix.
+ }
+ }
+ }
+
+ // Then the checkbox list. An item whose state the user did not touch must
+ // change nothing, which is why PartiallyChecked is skipped entirely: it is
+ // the "leave this alone" state for a tag that is on some threads only.
+ for (int row = 0; row < m_currentList->count(); ++row) {
+ QListWidgetItem *item = m_currentList->item(row);
+ const QString tag = item->data(Qt::UserRole).toString();
+
+ switch (item->checkState()) {
+ case Qt::Unchecked:
+ // Was on at least one thread, since it is in this list at all.
+ if (!remove.contains(tag))
+ remove.append(tag);
+ break;
+ case Qt::Checked:
+ // Only meaningful when it was NOT already on every thread; a tag
+ // already everywhere and left checked is not a change.
+ if (!add.contains(tag) && !m_fullyTagged.contains(tag))
+ add.append(tag);
+ break;
+ case Qt::PartiallyChecked:
+ break; // Untouched: do nothing, deliberately.
+ }
+ }
+
+ m_add = add;
+ m_remove = remove;
+ QDialog::accept();
+}
+
+QStringList TagDialog::tagsToAdd() const
+{
+ return m_add;
+}
+
+QStringList TagDialog::tagsToRemove() const
+{
+ return m_remove;
+}
diff --git a/src/tagdialog.h b/src/tagdialog.h
new file mode 100644
index 0000000..40eb089
--- /dev/null
+++ b/src/tagdialog.h
@@ -0,0 +1,102 @@
+/*
+ * 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 <QStringList>
+
+class QLineEdit;
+class QListWidget;
+
+/// Why a tag name was refused, or Ok when it was not.
+///
+/// A reason rather than a bool: a rejected tag has to say what was wrong with
+/// it, since silently dropping one leaves the user believing they tagged
+/// something they did not.
+enum class TagNameProblem {
+ Ok,
+ Empty, ///< Nothing, or only whitespace.
+ LeadingDash, ///< notmuch reads a leading '-' as "remove this tag".
+ ContainsSpace, ///< Splits into two tags, or fails the write outright.
+ ControlChar, ///< Not typeable, and unreadable once stored.
+};
+
+/// Whether `tag` is safe to hand to notmuch.
+///
+/// A free function so the rules can be tested without a widget. notmuch itself
+/// accepts a great deal, so this is deliberately narrow: it rejects only what
+/// produces a failed write or a tag the user cannot see they created.
+TagNameProblem validateTagName(const QString &tag);
+
+/// The message for a rejected tag, ready to show. Empty for Ok.
+QString tagNameProblemText(TagNameProblem problem, const QString &tag);
+
+/// Adds and removes tags across the selected threads.
+///
+/// One dialog rather than separate add and remove actions: the natural
+/// operation is "make these threads look like this", and filing something under
+/// a new tag while removing inbox is one thought, not two.
+///
+/// Pure UI. It contacts no worker and holds no database handle; it is handed
+/// the vocabulary and the current state, and returns two lists. That is what
+/// lets it be unit-tested without a notmuch database.
+class TagDialog : public QDialog
+{
+ Q_OBJECT
+public:
+ /// `knownTags` is the completion vocabulary, usually every tag in the
+ /// database. `currentTags` maps a tag to how many of the selected threads
+ /// carry it, which is what drives the tri-state checkboxes.
+ TagDialog(const QStringList &knownTags,
+ const QHash<QString, int> &currentTags,
+ int threadCount,
+ QWidget *parent = nullptr);
+
+ /// Tags to add. Empty when the user asked for nothing.
+ QStringList tagsToAdd() const;
+
+ /// Tags to remove.
+ QStringList tagsToRemove() const;
+
+ /// Reads both line edits and the checkbox list into the two lists,
+ /// reporting the first invalid name rather than applying a partial change.
+ ///
+ /// Public because QDialog::accept() is: a test drives it directly rather
+ /// than clicking a button, since which button carries the AcceptRole is
+ /// not what this class is for.
+ void accept() override;
+
+private:
+
+ QStringList m_add;
+ QStringList m_remove;
+
+ /// Tags that were already on EVERY selected thread when the dialog opened.
+ ///
+ /// Needed to tell "the user checked this box" apart from "this box was
+ /// checked all along": the first is an instruction, the second is not a
+ /// change and must not be sent as one.
+ QStringList m_fullyTagged;
+
+ int m_threadCount = 0;
+
+ QLineEdit *m_addEdit = nullptr;
+ QLineEdit *m_removeEdit = nullptr;
+ QListWidget *m_currentList = nullptr;
+};
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index c50c5e8..7f7caa5 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -20,3 +20,4 @@ add_qtmaildir_test(threadcidmap)
add_qtmaildir_test(mainwindow)
add_qtmaildir_test(messageview)
add_qtmaildir_test(querycompleter)
+add_qtmaildir_test(tagdialog)
diff --git a/tests/test_tagdialog.cpp b/tests/test_tagdialog.cpp
new file mode 100644
index 0000000..f41850e
--- /dev/null
+++ b/tests/test_tagdialog.cpp
@@ -0,0 +1,243 @@
+/*
+ * 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 <QtTest>
+
+#include <QCheckBox>
+#include <QLineEdit>
+#include <QListWidget>
+
+#include "tagdialog.h"
+
+class TestTagDialog : public QObject
+{
+ Q_OBJECT
+private slots:
+ void validNamesAreAccepted();
+ void emptyNameIsRejected();
+ void leadingDashIsRejected();
+ void whitespaceIsRejected();
+ void controlCharactersAreRejected();
+ void everyProblemHasAMessage();
+
+ void typedTagIsAdded();
+ void unknownTagIsStillAccepted();
+ void multipleTagsSeparateOnComma();
+ void uncheckingACurrentTagRemovesIt();
+ void aPartialTagLeftAloneChangesNothing();
+ void aPartialTagCheckedIsAddedEverywhere();
+ void nothingTouchedYieldsNoChange();
+};
+
+void TestTagDialog::validNamesAreAccepted()
+{
+ // Hierarchical tags are the common case here, and the '/' must survive:
+ // notmuch treats it as an ordinary character in a tag name.
+ for (const QString &tag : { QStringLiteral("inbox"),
+ QStringLiteral("shopping/amazon"),
+ QStringLiteral("mailing-list/SBo"),
+ QStringLiteral("2026"),
+ QStringLiteral("with.dots"),
+ QStringLiteral("under_score"),
+ // A dash anywhere but the front is fine.
+ QStringLiteral("half-done") }) {
+ QCOMPARE(validateTagName(tag), TagNameProblem::Ok);
+ }
+}
+
+void TestTagDialog::emptyNameIsRejected()
+{
+ QCOMPARE(validateTagName(QString()), TagNameProblem::Empty);
+ QCOMPARE(validateTagName(QStringLiteral("")), TagNameProblem::Empty);
+ // Whitespace only is empty in every sense that matters.
+ QCOMPARE(validateTagName(QStringLiteral(" ")), TagNameProblem::Empty);
+ QCOMPARE(validateTagName(QStringLiteral("\t")), TagNameProblem::Empty);
+}
+
+void TestTagDialog::leadingDashIsRejected()
+{
+ // notmuch's own CLI reads -tag as "remove tag". A tag named "-inbox" would
+ // therefore be a permanent trap for anyone who later types it at a prompt.
+ QCOMPARE(validateTagName(QStringLiteral("-inbox")),
+ TagNameProblem::LeadingDash);
+ // Also after trimming, or a leading space would smuggle one through.
+ QCOMPARE(validateTagName(QStringLiteral(" -inbox")),
+ TagNameProblem::LeadingDash);
+}
+
+void TestTagDialog::whitespaceIsRejected()
+{
+ // An embedded space is the failure that looks like it worked: the user
+ // believes they made one tag and notmuch sees something else.
+ QCOMPARE(validateTagName(QStringLiteral("two words")),
+ TagNameProblem::ContainsSpace);
+ QCOMPARE(validateTagName(QStringLiteral("tab\there")),
+ TagNameProblem::ContainsSpace);
+ QCOMPARE(validateTagName(QStringLiteral("new\nline")),
+ TagNameProblem::ContainsSpace);
+}
+
+void TestTagDialog::controlCharactersAreRejected()
+{
+ // A null is a control character like any other here. QStringLiteral keeps
+ // the whole literal rather than truncating at the null, so this is
+ // "null\0byte" in full and the null is what the check catches.
+ QString withNull = QStringLiteral("null");
+ withNull.append(QChar(0x00));
+ withNull.append(QStringLiteral("byte"));
+ QCOMPARE(validateTagName(withNull), TagNameProblem::ControlChar);
+
+ QString withBell = QStringLiteral("bell");
+ withBell.append(QChar(0x07));
+ QCOMPARE(validateTagName(withBell), TagNameProblem::ControlChar);
+}
+
+void TestTagDialog::everyProblemHasAMessage()
+{
+ // A rejection the user cannot read is the same as a silent one.
+ for (TagNameProblem problem : { TagNameProblem::Empty,
+ TagNameProblem::LeadingDash,
+ TagNameProblem::ContainsSpace,
+ TagNameProblem::ControlChar }) {
+ QVERIFY(!tagNameProblemText(problem, QStringLiteral("x")).isEmpty());
+ }
+ QVERIFY(tagNameProblemText(TagNameProblem::Ok,
+ QStringLiteral("x")).isEmpty());
+}
+
+/// Drives the dialog the way a user would, then accepts it.
+static void typeAndAccept(TagDialog *dialog, const QString &add,
+ const QString &remove)
+{
+ const QList<QLineEdit *> edits = dialog->findChildren<QLineEdit *>();
+ QVERIFY(edits.size() >= 2);
+ edits.at(0)->setText(add);
+ edits.at(1)->setText(remove);
+ dialog->accept();
+}
+
+void TestTagDialog::typedTagIsAdded()
+{
+ TagDialog dialog({ QStringLiteral("inbox") }, {}, 1);
+ typeAndAccept(&dialog, QStringLiteral("shopping/amazon"), QString());
+
+ QCOMPARE(dialog.tagsToAdd(), QStringList{ QStringLiteral("shopping/amazon") });
+ QVERIFY(dialog.tagsToRemove().isEmpty());
+}
+
+void TestTagDialog::unknownTagIsStillAccepted()
+{
+ // Completion is a guard against typos, NOT a whitelist. Inventing a tag is
+ // the entire point of the dialog, so a name absent from the vocabulary must
+ // go through untouched.
+ TagDialog dialog({ QStringLiteral("inbox") }, {}, 1);
+ typeAndAccept(&dialog, QStringLiteral("brand/new/tag"), QString());
+
+ QCOMPARE(dialog.tagsToAdd(), QStringList{ QStringLiteral("brand/new/tag") });
+}
+
+void TestTagDialog::multipleTagsSeparateOnComma()
+{
+ TagDialog dialog({}, {}, 1);
+ typeAndAccept(&dialog, QStringLiteral("one, two,three"), QString());
+
+ QCOMPARE(dialog.tagsToAdd(), QStringList({ QStringLiteral("one"),
+ QStringLiteral("two"),
+ QStringLiteral("three") }));
+}
+
+void TestTagDialog::uncheckingACurrentTagRemovesIt()
+{
+ // Every selected thread carries "inbox", so its box starts checked.
+ // Clearing it is how a user removes a tag without typing its name.
+ QHash<QString, int> current;
+ current.insert(QStringLiteral("inbox"), 3);
+
+ TagDialog dialog({ QStringLiteral("inbox") }, current, 3);
+
+ auto *list = dialog.findChild<QListWidget *>();
+ QVERIFY(list);
+ QCOMPARE(list->count(), 1);
+
+ QListWidgetItem *item = list->item(0);
+ QCOMPARE(item->text(), QStringLiteral("inbox"));
+ QCOMPARE(item->checkState(), Qt::Checked);
+
+ item->setCheckState(Qt::Unchecked);
+ dialog.accept();
+
+ QCOMPARE(dialog.tagsToRemove(), QStringList{ QStringLiteral("inbox") });
+ QVERIFY(dialog.tagsToAdd().isEmpty());
+}
+
+void TestTagDialog::aPartialTagLeftAloneChangesNothing()
+{
+ // THE case worth guarding. Two of three threads are unread, so the box is
+ // partially checked. Leaving it alone must mean "do not touch", never
+ // "apply to all": the second reading silently tags a thread the user never
+ // looked at.
+ QHash<QString, int> current;
+ current.insert(QStringLiteral("unread"), 2);
+
+ TagDialog dialog({ QStringLiteral("unread") }, current, 3);
+
+ auto *list = dialog.findChild<QListWidget *>();
+ QVERIFY(list);
+ QListWidgetItem *item = list->item(0);
+ QCOMPARE(item->checkState(), Qt::PartiallyChecked);
+
+ dialog.accept();
+
+ QVERIFY2(dialog.tagsToAdd().isEmpty(),
+ "a partial tag left alone was added to every thread");
+ QVERIFY2(dialog.tagsToRemove().isEmpty(),
+ "a partial tag left alone was removed from every thread");
+}
+
+void TestTagDialog::aPartialTagCheckedIsAddedEverywhere()
+{
+ // Deliberately checking a partial box is an instruction: give it to all.
+ QHash<QString, int> current;
+ current.insert(QStringLiteral("unread"), 2);
+
+ TagDialog dialog({ QStringLiteral("unread") }, current, 3);
+
+ auto *list = dialog.findChild<QListWidget *>();
+ QVERIFY(list);
+ list->item(0)->setCheckState(Qt::Checked);
+ dialog.accept();
+
+ QCOMPARE(dialog.tagsToAdd(), QStringList{ QStringLiteral("unread") });
+ QVERIFY(dialog.tagsToRemove().isEmpty());
+}
+
+void TestTagDialog::nothingTouchedYieldsNoChange()
+{
+ QHash<QString, int> current;
+ current.insert(QStringLiteral("inbox"), 2);
+ current.insert(QStringLiteral("unread"), 1);
+
+ TagDialog dialog({ QStringLiteral("inbox") }, current, 2);
+ dialog.accept();
+
+ QVERIFY(dialog.tagsToAdd().isEmpty());
+ QVERIFY(dialog.tagsToRemove().isEmpty());
+}
+
+QTEST_MAIN(TestTagDialog)
+#include "test_tagdialog.moc"