From 16bbd9a164ae4c4afc8914199c37386ed6914582 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 24 Aug 2026 21:33:52 +0200 Subject: docs(signatures): document the directory and the three keys Part of item 152. --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) (limited to 'CHANGELOG.md') diff --git a/CHANGELOG.md b/CHANGELOG.md index 1144402..38d2a58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,13 @@ point at which they are stable. - The notmuch hooks that auto-tag incoming mail now live in this repository, under `assets/hooks/`. They moved from the companion `mailctl` project, which is being retired. +- Signatures. Markdown files in `~/.config/qtmaildir/signatures/`, one per + signature, chosen from a control on the composer's editor bar. One + signature serves both the plain-text and the HTML part of a message, since + the HTML is rendered from the same markdown. `[compose] signature` seeds a + new message, `[account.] signature` overrides it per account, and + `[compose] signature_position` puts a new one at the end of the message + (the default) or above the quoted text in a reply. ### Changed -- cgit v1.2.3 From 0a26961f9a7ae6ab98051e182b92e64165758cf1 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 25 Aug 2026 09:12:29 +0200 Subject: fix(drafts): list drafts as messages, not threads The Drafts filter shipped threaded in item 138, reasoning that a draft reply belongs with the conversation it answers. That reasoning cost the feature: a thread row stands for its first matched message, which for a draft reply is the message being replied to, so the draft itself had no row of its own and double-clicking the conversation opened nothing. Reversed with the user. Drafts now follows Sent; Trash deliberately does not, since a deleted message still belongs to its conversation and nothing there has to be reachable for editing. The view mode was decided in three places that each compared against "sent" and had to agree: builtinFilter(), the reader that reapplies the mode, and the writer that skips storing what the generator implies. generatorIsFlat() is now the one closed set they share, and builtinFilter() sets flat from it rather than inside a branch so the set cannot drift from the labels. Setting only the branch would have looked correct. Its save/load pair survives by accident, because the writer's skip knew only "sent" and so would have stored the key for drafts. The gap is the reader's fallback, for a file carrying no flat key at all: an older build, a migration or a hand edit comes back threaded against a flat button, and the next save persists the disagreement. theDraftsFilterIsThreadedNotFlat is inverted rather than deleted, keeping its history, and now also pins Trash as threaded. The round trip is covered by extending aGeneratedEntryWritesNoRedundantKeys, which already asserted that property for Sent. Mutation-checked: reverting generatorIsFlat() to "sent" alone fails both. Suite 37 of 38; undoMovesTheMessageBack is item 136, pre-existing and on an unrelated path. Closes item 159. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8 --- CHANGELOG.md | 4 +- .../2026-08-03-post-0.1.0-usability-closed.md | 65 ++++++++++++++++++++++ .../plans/2026-08-03-post-0.1.0-usability.md | 2 + src/config.cpp | 37 ++++++++---- tests/test_config.cpp | 36 +++++++++--- 5 files changed, 124 insertions(+), 20 deletions(-) (limited to 'CHANGELOG.md') diff --git a/CHANGELOG.md b/CHANGELOG.md index 38d2a58..fee2199 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,9 @@ point at which they are stable. - **A Drafts filter** in the query row, beside Sent and Trash. It matches each account's `drafts` folder, so it finds what the composer actually writes rather than trusting a flag. An account that configures no drafts folder - contributes nothing and shows no button. + contributes nothing and shows no button. Like Sent, it lists messages rather + than threads: a draft reply gets a row of its own instead of being folded + into the conversation it answers, where it could not be opened. - `Ctrl+W` closes a composer, the way it closes a window elsewhere. The draft is saved or discarded exactly as it is when the window is closed by any other route. diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md index d6adc98..7a52984 100644 --- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md +++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md @@ -7548,3 +7548,68 @@ needed. The drafts view is path-based, so zero tags is exactly enough. **Size: S.** One worker slot, one signal, and their tests. **Closed 2026-08-24** (unreleased). See the status table row for the outcome. + + +## 159. The Drafts view lists threads, so a draft is unreachable by double-click + +**Observed (user, 2026-08-25):** "drafts should be treated like \"Sent\", +listing only actual draft messages and not threads, otherwise I can double +click on a thread message and nothing happens." + +**Cause (verified in code):** `Config::builtinFilter()` in `src/config.cpp` +sets `filter.flat = true` for the `sent` generator only, at line 1011. The +`drafts` branch below it leaves the default `false` with a comment stating the +choice explicitly: "NOT flat, like Trash and unlike Sent: a draft reply +belongs with the conversation it answers." That was item 138's decision and it +is the thing the note contradicts. + +The consequence the user reports follows from it. A thread row stands for +`ThreadSummary::firstMessageId`, which in a Drafts view is the first MATCHED +message of the conversation, and that is not necessarily the draft. Item 153 +gated `edit_draft` on the file living in a configured drafts folder precisely +so that opening ordinary mail this way cannot make the first autosave delete a +received message, so the row is inert rather than harmful. Inert is still +"nothing happens". + +**Built 2026-08-25**, after confirming the reversal with the user. + +**Not one line, and the reason is the part worth keeping.** The obvious fix is +`filter.flat = true` in the `drafts` branch. That ships a defect: the view mode +was decided in THREE places that each hardcoded a comparison against `"sent"`, +and they have to agree. + +- `builtinFilter()` sets it for the button. +- `loadSavedQueries()` reapplies it on read, so a hand-edited or migrated file + cannot produce a threaded Sent view. +- `saveSavedQueries()` SKIPS writing it when the generator already implies it, + because a key carrying no information is one a hand-editor must read past. + +Setting only the first does not break the save/load pair, and it is worth being +exact about why: the writer's skip knew only about `sent`, so it would have +STORED `"flat": true` for drafts, and the reader would have honoured it. That +round trip survives by accident. + +What does NOT survive is a file that carries no `flat` key: one written by an +older build, migrated from elsewhere, or hand-edited, which is the case the +reader's fallback exists for. It comes back THREADED against a flat button, and +the writer then persists that disagreement on the next save. The reader is the +load-bearing site, and it is the one a per-branch fix leaves untouched. + +`generatorIsFlat()` is the fix: one closed set beside `generatorTag()`, called +from all three sites. `builtinFilter()` sets `filter.flat` once from it rather +than inside a branch, so the set cannot drift from the labels below it. + +**Trash deliberately did not follow.** A deleted message still belongs to its +conversation, and nothing in the trash has to be reachable for editing. The +test asserts this, so a future change that flattens every folder filter fails +rather than passing quietly. + +**Testing.** `theDraftsFilterIsThreadedNotFlat` asserted the old behaviour and +is inverted rather than deleted, keeping the history in its comment. The +round-trip is covered by extending `aGeneratedEntryWritesNoRedundantKeys`, +which already asserted exactly that property for `sent`, rather than by a +second test that would have restated it. Mutation-checked: reverting +`generatorIsFlat()` to `sent` alone fails both. + +Suite 37 of 38; the failure is `undoMovesTheMessageBack`, item 136, +pre-existing and on an unrelated path. 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 2d99704..4aeff26 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 @@ -230,6 +230,8 @@ taking that too literally. | 158 | A freshly saved draft is invisible until a sync indexes it | defect | S | **done** 2026-08-24, unreleased. `saveDraftNow()` emits `draftSaved`, which `MainWindow` connects to a new `NotmuchWorker::indexDraftFile()` that indexes the one file (previous revision removed, so a rewrite leaves no ghost), and `draftRemoved` drops the entry when a sent draft is unlinked. Measured: `index_file` assigns NO tags, so no stripping and no tag:inbox leak. See the section | +| 159 | The Drafts view lists threads, so a draft is unreachable by double-click | defect | S | **done** 2026-08-25, unreleased. Reverses item 138's own decision, confirmed with the user. `generatorIsFlat()` in `config.cpp` is now the single closed set of flat generators, replacing three hardcoded comparisons against `"sent"`: the built-in filter, the reader that reapplies the mode, and the writer that skips storing what the generator implies. Those three had to agree and nothing made them; a `drafts` entry saved and reloaded would otherwise have come back THREADED while the button was flat. `builtinFilter()` sets `flat` once from the helper rather than in a branch, so the set cannot drift from the labels | + Sizes are rough: XS under an hour, S a sitting, M a session. --- diff --git a/src/config.cpp b/src/config.cpp index 534ba72..d91259a 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -80,6 +80,18 @@ QString generatorTag(const QString &generator) return QString(); } +/// Whether a generator lists MESSAGES rather than threads. "sent" folds a +/// user's own message back into the conversation it answers, and "drafts" is +/// worse: a thread row stands for its first matched message, which for a draft +/// reply is the message being replied TO, so the draft itself is unreachable. +/// "trash" stays threaded, since a deleted message still belongs to its +/// conversation. Closed set, and the one place the three views are decided. +bool generatorIsFlat(const QString &generator) +{ + return generator == QStringLiteral("sent") + || generator == QStringLiteral("drafts"); +} + } // namespace QString Account::scopedQuery(const QString &query) const @@ -838,14 +850,14 @@ void Config::loadSavedQueries(const QString &configPath, QSettings &settings) query.query = object.value(QStringLiteral("query")).toString(); 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 + // A generator carries its own view mode, so a flat one 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"); + || generatorIsFlat(query.generated); if (query.isGenerated() && !kQueryGenerators.contains(query.generated)) { @@ -905,7 +917,7 @@ bool Config::saveSavedQueries() const 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")) + if (query.flat && !generatorIsFlat(query.generated)) object.insert(QStringLiteral("flat"), true); for (auto it = query.unknown.begin(); it != query.unknown.end(); ++it) object.insert(it.key(), it.value()); @@ -987,6 +999,9 @@ SavedQuery Config::builtinFilter(const QString &generator) SavedQuery filter; filter.generated = generator; + // One source for the view mode, shared with the saved-query round trip, so + // a branch below cannot disagree with what loadSavedQueries reapplies. + filter.flat = generatorIsFlat(generator); // Translated, because these are the labels on the buttons. The GENERATOR // name is not: it is stored in queries.json and matched against a closed @@ -1005,16 +1020,18 @@ SavedQuery Config::builtinFilter(const QString &generator) filter.name = tr("Important"); } else if (generator == QStringLiteral("sent")) { filter.name = tr("Sent"); - // Messages rather than threads, and the only filter that sets this. A - // thread would fold the user's sent message back into the conversation - // it belongs to, which is item 63's finding. - filter.flat = true; + // Flat, per generatorIsFlat(): a thread would fold the user's sent + // message back into the conversation it belongs to, item 63's finding. } else if (generator == QStringLiteral("drafts")) { // The LABEL is translated; the generator stays `drafts`, which is what // queries.json stores and what a closed set is matched against. filter.name = tr("Drafts"); - // NOT flat, like Trash and unlike Sent: a draft reply belongs with the - // conversation it answers. + // Flat, per generatorIsFlat(). Item 138 chose threaded, reasoning that + // a draft reply belongs with the conversation it answers; item 159 + // reversed it on what that cost. A thread row stands for its first + // MATCHED message, which for a draft reply is the message being + // replied TO, so the draft itself had no row of its own and + // double-clicking the conversation opened nothing. } else if (generator == QStringLiteral("trash")) { filter.name = tr("Trash"); // NOT flat, unlike Sent. A deleted message still belongs to its diff --git a/tests/test_config.cpp b/tests/test_config.cpp index 17b8e1d..e69a073 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -122,7 +122,7 @@ private slots: void anAccountWithoutATrashFolderWarns(); void theDraftsFilterComposesPerAccount(); void theDraftsFilterMatchesNothingWithoutAFolder(); - void theDraftsFilterIsThreadedNotFlat(); + void theDraftsFilterIsFlatLikeSent(); void theTrashFilterComposesPerAccount(); void theTrashFilterMatchesNothingWithoutAFolder(); void anAccountWithoutASendCommandIsReceiveOnly(); @@ -1076,17 +1076,24 @@ void TestConfig::theDraftsFilterMatchesNothingWithoutAFolder() Config::matchNothingQuery()); } -void TestConfig::theDraftsFilterIsThreadedNotFlat() +void TestConfig::theDraftsFilterIsFlatLikeSent() { - // Unlike Sent, and deliberately. Sent is flat because a thread would fold - // the user's own message back into the conversation it answers, which is - // item 63's finding. A draft reply belongs with its conversation for the - // same reason a trashed message does, so drafts follow trash here. + // Item 138 shipped this THREADED, reasoning that a draft reply belongs + // with the conversation it answers. Item 159 reversed it on what that + // cost: a thread row stands for its first MATCHED message, which for a + // draft reply is the message being replied TO, so the draft had no row of + // its own and double-clicking the conversation opened nothing. const SavedQuery drafts = Config::builtinFilter(QStringLiteral("drafts")); - QVERIFY2(!drafts.flat, "the drafts filter is flat, like Sent"); + QVERIFY2(drafts.flat, "the drafts filter went back to threaded, so a draft " + "reply has no row of its own (item 159)"); const SavedQuery sent = Config::builtinFilter(QStringLiteral("sent")); QVERIFY2(sent.flat, "Sent stopped being flat, which item 63 requires"); + + // Trash deliberately did NOT follow. A deleted message still belongs to + // its conversation, and nothing has to be reachable for editing there. + const SavedQuery trash = Config::builtinFilter(QStringLiteral("trash")); + QVERIFY2(!trash.flat, "trash became flat; only sent and drafts should be"); } void TestConfig::theTrashFilterComposesPerAccount() @@ -2357,6 +2364,7 @@ void TestConfig::aGeneratedEntryWritesNoRedundantKeys() "version": 1, "queries": [ { "name": "Sent", "generated": "sent", "pinned": true }, + { "name": "Drafts", "generated": "drafts", "pinned": true }, { "name": "Inbox", "query": "tag:inbox", "pinned": true } ] })")); @@ -2381,18 +2389,28 @@ void TestConfig::aGeneratedEntryWritesNoRedundantKeys() QVERIFY2(!sent.contains(QStringLiteral("flat")), "the sent generator implies flat; storing it says nothing"); + // Drafts is the second flat generator (item 159) and must be skipped by + // the same rule, not by a second one that could disagree with it. + const QJsonObject drafts = array.at(1).toObject(); + QCOMPARE(drafts.value(QStringLiteral("generated")).toString(), + QStringLiteral("drafts")); + QVERIFY2(!drafts.contains(QStringLiteral("flat")), + "the drafts generator implies flat; storing it says nothing"); + // The ordinary entry is untouched by any of that. - const QJsonObject inbox = array.at(1).toObject(); + const QJsonObject inbox = array.at(2).toObject(); QCOMPARE(inbox.value(QStringLiteral("query")).toString(), QStringLiteral("tag:inbox")); // And it all still reads back the same. Config reloaded; reloaded.load(path); - QCOMPARE(reloaded.savedQueries().size(), 2); + QCOMPARE(reloaded.savedQueries().size(), 3); QVERIFY(reloaded.savedQueries().at(0).isGenerated()); QVERIFY2(reloaded.savedQueries().at(0).flat, "flat must come back from the generator, not from the file"); + QVERIFY2(reloaded.savedQueries().at(1).flat, + "drafts must come back flat too, from the same rule"); } void TestConfig::anAccountWithoutASendCommandIsReceiveOnly() -- cgit v1.2.3