| Age | Commit message (Collapse) | Author | Files | Lines |
|
Eight TDD tasks. SearchTerm and the date parse first, since both are pure
logic testable without a widget; then one surface per task, the window's
wiring, and the suite check.
Two findings while planning, both now in the spec. The Date: header parse
already exists inside a file-local function in mimeparser.cpp, complete with
the fix for Qt::RFC2822Date rejecting a trailing timezone comment, so it is
extracted rather than rewritten. And queryRequested is the right precedent
but the wrong signature: these actions carry a replace-or-narrow flag, so a
second signal sits beside it and the placeholder links keep their gate.
|
|
Item 78 asked for a tagging rule built from something visible in a message.
Brainstorming narrowed it: a saved query can already be promoted to a rule,
so the road from "I see something interesting" to "a rule tags it" exists as
search, save, promote. Searching is the missing step and the safe one, since
a query costs nothing when it is wrong while a rule runs unattended against
real mail.
The search half splits out as item 85. Five surfaces gain a context menu with
Search for this and Add to search: subject, date and From/To/Cc in the header,
tag chips, body selection, and every header per message in the details dialog,
which is rebuilt as rows rather than one text box.
Item 78 stays open carrying the rule shortcut alone.
|
|
|
|
Cause verified by attaching gdb to the hung process rather than
inferred: showWarnings() raises QMessageBox::warning from the MainWindow
constructor, and nothing offscreen can dismiss it, so any config problem
in a test's fixture blocks the constructor forever.
Not a defect in the application. The modal is right for a person and the
code says why; the defect is that a test cannot dismiss it and the
resulting failure is a silent hang rather than an error naming the
cause.
Corrects item 81's closing note, which blamed the missing maildir key
itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Also records item 83's fix and the warning banner in the changelog,
which the earlier commit did not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Both halves asserted together, plus a guard proving the generated button
carries a menu at all: a test for the absence of a menu item passes
against no implementation, which item 82 recorded the hard way.
The account section is load-bearing rather than scenery. A generated
entry resolving to an empty query is skipped entirely, so without a
configured maildir and sent folder the Sent button is never built and
the assertion would pass by finding nothing. An account section missing
maildir hangs the test outright, which is how this was found.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Right-click a stored saved query and the rules dialog opens on a new
rule carrying its query, with the tags left empty and focused. Generated
entries are excluded: their query is composed from the accounts, so a
rule made from one would freeze a snapshot that goes stale.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
The dialog is non-modal and single-instance, so a second Create tagging
rule reaches one that is already up. Seeding it beats dropping the
request, which would read as a broken menu item.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
The name comes from queries.json, so nothing had ever checked it against
the ids in rules.json. Replacing the matched rule would retag mail
against a query the user never associated with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
The seed is a whole TagRule rather than a query string, so item 78 can
reuse the same path to seed from a sender. It is a pending edit like one
made with Add rule: appended, selected, Add tags focused, and written
only on Save.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Six tasks, TDD, one commit each. Two things the spec did not anticipate
and the plan has to carry: the rules dialog is non-modal and
single-instance, so a second request must seed the dialog already open
rather than being dropped, and the seeded id is uniqued against the
working list rather than the file, since that list can hold unsaved
rules whose ids collide just as hard.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Item 81. A context-menu action on a stored saved query, seeding the rules
dialog with the query and a sanitised id, tags left empty and focused.
Three decisions worth the record. The backlog's own proposal, a checkbox
in the Save query dialog, is rejected: it would make one dialog write both
queries.json and the shared rules.json, and SaveQueryDialog is
deliberately pure UI that writes nothing. Generated entries are excluded,
since their query is composed from the accounts at runtime and a rule made
from one would freeze a snapshot that goes stale when an account is added.
And the empty tags are load-bearing rather than an omission: validate()
refuses a rule that tags nothing, so the one field the user must supply is
the one the dialog opens on.
This also turns out to be a single-repo change. The rule it creates is an
ordinary one, so mailrules.py is untouched; the backlog's note that item
81 spans two repos was about the file it lands in, not the work.
Item 78 becomes a second caller of the same seeded-dialog path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
A rule named "justeat orders" in the field labelled Name was written to
rules.json correctly and then dropped by every reader, because load()
required ^[a-z0-9][a-z0-9-]*$ and the save path validated nothing. The
rule stayed in the file, invisible in the dialog, never applied by the
post-new hook, and the next save from the dialog would have deleted it
outright.
The asymmetry was the defect, not the pattern. TagRules::validate() is
now the single predicate: the dialog refuses to save against it, and
load() uses it to repair rather than drop, so a rule that fails is
visible and fixable instead of silently discarded.
- The typed name is sanitised into an id when the field is committed,
so the field shows what will reach the file. uniqueId() suffixes a
collision, since sanitising is many-to-one and can manufacture the
duplicate that load() then drops.
- An already-legal id is never rewritten, including one like "a---b"
that sanitising would otherwise collapse. Rewriting valid ids would
churn a file mailctl also reads.
- A bad id loads repaired, with the warning kept: what is on disk is
not what the hook runs until the file is saved back.
Deliberately not mirrored into mailrules.py. The hook tags real mail
unattended every ten minutes, where silently renaming an id is worse
than dropping the rule; the file converges as soon as the dialog saves.
No format change, so no version bump and no two-repo commitment.
The load warning was not missing: it had been showing "1 rule could not
be read and was skipped" on every open, in the same font and colour as
the intro prose two lines above it, and read as more explanation. It is
now a red banner beside Save, with an icon and a dismiss button, and it
says the rules need attention rather than that they were skipped, which
is no longer true. Dismissal is per-appearance only; a persistent one
would re-hide the problem that went unnoticed for a session.
Both new dialog tests were confirmed to fail with the sanitiser
reverted, and the banner's styling, position and dismissal each fail
under mutation. 20 of 20 suites green, 34 tests in test_tagrules.
Closes item 83.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
Items 23 and 82. Saved queries move out of the [queries] INI section into
~/.config/qtmaildir/queries.json, gaining the three things the INI could not
express: an order, a pinned flag, and a per-query account scope. They can now be
created, edited, reordered, unpinned and deleted from the UI rather than only by
hand-editing a config file.
The INI could not carry order at all: QSettings reads a section through
childKeys(), which sorts alphabetically and never follows the file, so the
buttons could not be arranged. Migration reads the old section once, marks every
entry pinned so nothing moves on the first launch, and leaves the config file
byte-identical, since rewriting it with QSettings would drop the user's comments
and key order.
Sent stops being a hardcoded button and becomes an ordinary entry carrying
"generated": "sent". Its query is still composed from the accounts every time
it runs, so correcting a folder name still needs no edit here, but the row now
follows one rule instead of having one member the user could not move, rename or
remove.
Item 82 was found by hand-testing item 23 and fixed on the same branch: saving
worked and nothing else did, so a saved query could be created and never
changed. It also uncovered a defect that predated it, where rebuilding the row
with deleteLater() left the stale row answering findChild(), which was already
reachable from the save path.
Hand-tested throughout: the migration against the real config, saving, unpinning,
deleting, reordering, the read-only query field on a generated entry, and an
unpin surviving a restart.
|
|
Saving a generated entry wrote `"query": ""` and `"flat": true` alongside its
generator. Both reload correctly, so nothing was broken, but queries.json is
meant to be hand-edited and each redundant key is one more thing to read past.
A generated entry has no query of its own, and the sent generator already
implies flat.
Written now only when they say something, which is the rule `pinned` and
`account` already followed: `query` is skipped for a generated entry in favour
of `generated`, and `flat` is skipped when the generator implies it.
Omitting `flat` is only safe because loadSavedQueries() reapplies it from the
generator, so the two are coupled: the mutation that stops reapplying it fails
this test and one other, in both suites. That is deliberate, since a round-trip
test can otherwise pass while quietly writing less than it reads.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
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>
|
|
The user asked whether the default queries could be unified with Sent. The
answer runs the other way: Sent joins the saved queries rather than the saved
queries becoming hardcoded.
Inbox, Unread and Important are complete strings that depend on nothing and can
never go stale, so generating them would buy nothing and would cost the four
things the file just gained: reordering, unpinning, renaming and deleting.
Hardcoding them would also make them undeletable, which is a regression for
anyone who does not want one of them.
Sent is different only in that its query CANNOT be stored: it is composed from
every account's `sent` key, so a stored copy goes stale the moment a folder is
renamed. That is a property of Sent, not of "default queries". Storing the
GENERATOR rather than its output keeps both halves: `"generated": "sent"` still
resolves from the accounts at click time, and the entry is an ordinary row that
can be reordered, renamed, unpinned or removed. The row now follows one rule
instead of carrying one member the user did not own.
Two properties had to travel with the entry. The composed query, resolved
through Config::resolvedQuery() so what lands in the bar is what actually ran;
and FLAT mode, since a sent view lists messages and a threaded one folds every
reply back into the conversation the user sent one message into. The sent
generator implies flat rather than trusting the file to say so, because a
hand-edited row would otherwise produce a threaded sent view.
An unknown generator is reported but the row is KEPT: a later build may know it,
and dropping it here would delete it from the file on the next save, which is
the same data loss the unknown-field handling exists to prevent. A generator
whose accounts configure nothing is skipped entirely, exactly as the hardcoded
button was hidden rather than offering one that finds nothing.
Eight new tests. The four pre-existing Sent tests reach this through migration
and were left alone, which is what proves the migrated path still behaves; the
new ones cover a STORED file, which is the path every launch after the first
takes. Mutations: a generator resolving to nothing fails three, ignoring flat
fails two, and not skipping an empty generator fails one. A rename test guards
the property the change exists for, since anything keyed on the literal name
"Sent" would break it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
document-save is the floppy/disk shape. It means "write a file somewhere",
which leaves the user to guess what is being written, and next to a row of
saved-query buttons it reads as an unrelated control. Saving a query is
bookmarking a search, so bookmark-new is the icon every desktop already uses
for "keep this for later". Verified to resolve with real art in the desktop's
actual theme rather than assumed present.
The button also shows its label now instead of the icon alone. It sits among
text buttons, and an icon on its own next to them reads as a different kind of
control; it is also the one action whose meaning an icon cannot carry, since
"save" is a familiar shape whose question is always "save what?". The toolbar
is unaffected and still follows the desktop's own button style.
The label is the button's own text rather than the action's. "&Save query..."
is menu phrasing, and setDefaultAction copies it verbatim, so the button
rendered an accelerator ampersand and the ellipsis that promises a dialog. The
action keeps both for the menu it lives in, and the test asserts the override
survives setDefaultAction rather than trusting that it does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
nothing is pinned
The saved-query buttons are the row's content and read as a set; the overflow
menu is a control over that set, so it belongs apart from them rather than
trailing the last button. Moving the stretch above it pushes it to the right
edge.
Doing that exposed a latent defect in the same function. The row hid itself when
its layout held nothing but the stretch, which was written as a count of one and
happened to be right only because the stretch went last. With the stretch moved
the count changes, and the condition as written would have hidden a row holding
only the menu: a config with saved queries but none pinned would have had no
route to any of them, the menu buried along with the row. The check now counts
the content added before the stretch and treats an unpinned query as content in
its own right.
Both are mutation-checked. Putting the stretch back at the end fails the
alignment test, and restoring the old hide condition fails the new one, which
asserts the row survives with nothing but unpinned queries in it. The alignment
is asserted on the layout's own ordering rather than on x coordinates, since a
geometry assertion would also pass for a row that merely ran out of width.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
The spec asked for "a Save query button beside the search bar" and what shipped
was a menu entry and Ctrl+S. The user went looking for the button where the
design said it would be and did not find it.
Saving is a thing you decide on while looking at the results, so it belongs
where the results came from rather than behind a menu or a remembered chord.
The button takes the action through setDefaultAction rather than a second
connect, so it inherits the text, icon, tooltip and enabled state and cannot end
up offering to save an empty query while the menu entry correctly refuses. The
mutation that replaces it with a plain clicked() connection fails the test.
Also records item 82: a saved query cannot be edited, unpinned or deleted from
the UI. Item 23 specified saving and nothing else, and that is exactly what was
built, so the only way to unpin a query is a text editor or retyping it in full
under the same name. An action that creates something the UI cannot then change
or remove is incomplete, and this was found within minutes of the first hand
test. It is filed as a defect rather than an enhancement, and the spec now says
so where a reader would otherwise take the design for complete.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Second half of item 23, on top of the storage change. A query can now be kept
without hand-editing a file, and the row of buttons no longer grows without
bound.
Ctrl+S opens a dialog on whatever is in the query bar, taking a name, an
optional account scope and whether the query is pinned. It preselects the
account already chosen in the dropdown, since that is the scope the user is
looking at, and it says so when a name is about to replace an existing query
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. Saving
over an entry keeps the stored entry's unknown fields rather than the dialog's
fresh value, so a field written by a later build survives being edited here.
The saved queries move to a row of their own beneath the query bar, pinned ones
as buttons and the rest behind a More queries menu that only exists when
something is in it. The ponytail note that stood in the query row predicted
exactly this: an unbounded list of buttons sharing the row squeezed the field.
Sent moves down with them and is still not a saved query, for the reason already
recorded there.
A saved query's account scope goes through the account DROPDOWN rather than
being baked into the query text. runQuery() already wraps the query in the
selected account's path, so pre-scoping here would apply it twice, and setting
the dropdown also shows the user which scope they are in. An unscoped query
clears the selection rather than inheriting whatever the last one left, which is
the same defect the rules preview had.
Seven tests, three mutations. Ignoring the pinned flag fails two of them,
pre-scoping the text instead of setting the dropdown fails two, and letting an
unscoped query inherit the previous account fails one. The menu-absence test
initially passed against no implementation at all, since it only asserted a
widget was missing; it now proves the row was populated first, which is the
guard that class of test needs.
Two existing invariants caught real omissions rather than needing adjustment:
every registered action must appear in KeyMap::knownActions(), which is what
gives it a configurable binding, and every action needs its own icon.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
First half of item 23. The storage moves out of the [queries] INI section into
~/.config/qtmaildir/queries.json; the UI that writes it comes next.
The INI could not express order. QSettings reads a section through childKeys(),
which sorts alphabetically and never follows the file, so the saved-query
buttons could not be arranged and config.cpp carried a comment saying a
hand-rolled parser would be needed to change that. queries.json is an ordered
array and nothing sorts it on load. That also makes room for the two fields the
save dialog needs: pinned, which decides whether a query is a button or a menu
entry, and account, which scopes it.
account stores the account KEY, not the maildir path, so it does not duplicate
config that already lives in the account section and go stale when the user
edits it. Config::resolvedQuery() 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. A key naming an account that no longer exists resolves to the bare
query rather than a scope built from an empty maildir, which would be path:"/**"
and match everything.
Migration reads [queries] once, when queries.json is absent, marks every entry
pinned so the query row does not empty on the first launch after an upgrade, and
leaves the INI section untouched. Stripping it would mean rewriting a
hand-edited file with QSettings, which drops comments and key order across the
whole file.
The format follows rules.json in shape only: a version and unknown fields
preserved at both levels, so a file written by a later build survives a save
from this one. None of its two-implementation machinery is here, because queries
have exactly one reader; the version constant says so where a future reader will
look. A file whose version this build does not know is refused AND blocks the
save, so a newer document is never overwritten with a lossy reading of itself.
Twelve tests, each checked against a mutation that puts the corresponding bug
back: sorting on load fails three of them, stripping the INI section after
migration fails the byte-identical assertion, concatenating the scope without
parentheses fails the disjunction test, and dropping unknown-field preservation
fails the round trip. The migration test compares the INI file's BYTES rather
than re-reading it through QSettings, which would have passed against a rewrite
that kept every value while dropping the comments.
startup_query still resolves by name, but its fallback now returns the first
entry in the user's own order rather than the alphabetically first one. That is
user-visible for a config whose startup_query matches nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Item 23 had grown past what a backlog entry should hold: a storage format, a
migration, a dialog and a layout change. This document's own rule says a fully
specified item moves to specs/ and leaves behind the two or three things that
decide whether it can be picked up, the way items 53, 63 and 76 went. The entry
now carries the observation, the three deciding constraints and the relation to
item 10, and points at the spec for the rest.
The spec pins what was still loose. The JSON is an ordered array, since the
ordering is the whole reason for moving off [queries], and nothing may sort it
on load. A query's account scope stores the account KEY, the INI group suffix,
rather than the maildir path, so it does not duplicate config that already lives
in one place and go stale when the user edits it; the scope then composes
through Account::scopedQuery(), whose parenthesisation is load-bearing for the
same reason it is in the rules hook, an unparenthesised disjunction escapes its
scope and matches every account.
Migrated entries are pinned, so the query row does not silently empty on the
first run after upgrade, and migration order is alphabetical because that is
genuinely all the INI knows. Sent stays out of the file: it is generated from
allSentQuery() rather than stored, and folding it in would mean writing a
per-account path query into stored config, which is the duplication the
account-key decision just rejected.
The testing section is written against the traps already recorded in CLAUDE.md.
The migration test asserts the INI file is byte-identical rather than re-reading
it through QSettings, which would pass against a rewrite that preserved values
while dropping comments; the round-trip test asserts order, which is the
property the INI could not provide; and the dialog is left to a hand test,
because the offscreen platform cannot assert sizing at all and a Cancel goes
through done(int) rather than closeEvent.
Every code reference in the spec was checked against the files rather than
copied from the entry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
The user proposed managing saved queries the way the tagging rules are managed,
in a JSON file rather than in the INI. It is a better answer than either option
the entry had been weighing, a per-query pinned flag or a [general]
pinned_queries list, because those each solved one problem and this solves
three.
Order is the one that could not be solved any other way. [queries] is read
through childKeys(), which returns keys alphabetically rather than in file
order, so the saved-query buttons appear alphabetically today and there is no
way to arrange them; config.cpp already carries a comment saying a hand-rolled
parser would be needed to change that. A JSON array is ordered intrinsically. On
top of that the document has room for the pinned flag the two tiers need, and
for the per-account scope the save dialog wants, which SavedQuery has nowhere to
put: it is {name, query} and nothing else.
The entry takes the shape of rules.json but explicitly not its machinery.
rules.json is JSON because two independent implementations have to agree on it,
this repo and mailctl's mailrules.py, and the unknown-field preservation and
version handshake exist to keep them from destroying each other's writes.
Queries have one reader, so only the versioned-document-with-unknown-fields part
is worth carrying over.
Migration reads [queries] once when queries.json is absent, writes the JSON, and
leaves the INI section in place. Stripping it would mean rewriting a hand-edited
file with QSettings, which drops comments and key order across the whole file
and is the exact loss this decision was made to avoid; leaving it costs a few
stale lines and keeps a downgrade working. Reading both forever was rejected as
two sources of truth for one thing.
This also retires the open question the entry had carried since 2026-08-04,
where the write should go. Both of the original answers were poor, one
machine-writing the user's hand-edited config and the other filing user intent
as window state under ~/.local/state. A machine-written JSON document beside the
hand-written INI is the cleaner split, in ~/.config so it lands in a config
backup.
Two constraints recorded that the build would otherwise meet late. startup_query
names a saved query and must keep resolving, and its "first entry" fallback
quietly changes meaning from alphabetically-first to first-in-the-user's-order,
which is user-visible and belongs in the changelog. And the README documents
[queries] in three places, one of which explains the alphabetical button order
this change removes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Item 23 said only that a query could not be saved from the UI, and left the
presentation as a one-line sketch. The user described what they actually want:
a Save query button beside the search bar, opening a dialog that takes a name
and an account scope; saved queries split into two tiers, with a few kept
visible as buttons and user-made ones behind a menu; and the button row moved
onto a row of its own once it no longer holds everything.
Two things came out of writing it down. There is no built-in default query set
in the code at all: every entry in [queries] is user-written and renders
identically, and Sent is the lone exception because it is built from
allSentQuery() rather than living in [queries]. The two tiers therefore need a
mechanism that does not exist yet, either a per-query pinned flag or a
[general] pinned_queries list, and that config format choice is the one decision
left on the item. The design also settles the question the entry had left open:
a query the user named and scoped in a dialog is intent rather than machine
state, so it goes in qtmaildir.conf beside the hand-written ones, at the cost of
QSettings reformatting a hand-edited file on first save. The README has to say
so.
The save-as-a-filter half is split out as item 81. A saved query is a view and
costs nothing if it is wrong; a rule is applied to real mail by the post-new
hook every ten minutes and lives in the rules file that this repo and mailctl
implement independently. Folding it into 23 would make a presentation change
carry a two-repo commitment, so 23 can now ship without it. Item 81 records the
constraints it will hit: a stored query carries no scope and the hook
parenthesises it, which matters more here than usual because a query saved for
a view is often a disjunction, and the hook refuses to remove unread or inbox,
so the dialog must say so rather than failing silently.
Item 23's relation to item 10 firms up as a result. Account scope in the save
dialog answers item 10's remaining half as a side effect, so the entry now says
that outright, and says not to reopen item 10 to do it: the user postponed it
and asked that the rest not be proposed unprompted.
Also corrects a stale reference. The entry claimed a SavedQueryBar class shows
the saved queries; no such class exists or ever has, and the buttons are built
inline in MainWindow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Item 73. The backlog kept every item's full Observed/Cause/Approach section
forever, including the sixty-eight that are closed, and had reached 5056 lines:
past the point where it could be read in one pass, and past the point where a
tool could open it at all.
The closed sections move to 2026-08-03-post-0.1.0-usability-closed.md, taking
the backlog to 570 lines. The status table stays where it was and remains the
index of all 80 items, so a closed item keeps its row, its date and its outcome
beside the open ones; only its evidence moved. Nothing was renumbered and
nothing was deleted, which the item required: the numbering is cited from commit
messages, from CLAUDE.md and from the specs, and both files share one sequence,
so item 42 is `## 42.` in whichever file holds it.
The split was done by script and verified by set difference rather than by
reading: every non-blank line of the original appears in one of the two files,
zero missing, and the only lines not in the original are the new file's header.
All 80 numbers resolve, every open item has its section in the backlog, every
closed one in the archive, with no duplicates and no orphans.
Two things the item's own approach did not anticipate. Three cross-references
said "see below" and their targets had just moved, so rows 60 and 75 and the
header's note on item 20's parked branch now say where the entry went. And the
cause was never the fifty done sections, it was that nothing moved a section on
the day its item closed; doing this once buys a few months and then item 73
returns. The rule in "Adding to this document" now requires the move on the
closing commit, and CLAUDE.md tells a future session that grepping the backlog
for a closed item's evidence will find the table row and nothing else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Item 76 replaces the four free-text fields with a row builder: field and
operator dropdowns per condition, +/- to add and remove them, a match
all/any choice, and a separate "but not" block. The stored format does
not change, so mailctl needs no edit. The query string stays
authoritative and remains visible, and a rule the builder cannot
represent opens in a text mode every rule carries.
Along the way, items 75, 77 and 80, and a data-loss defect released in
0.16.0 (item 79): opening the dialog and pressing Save destroyed the
first rule with nothing edited. That one damaged a real rule in the
user's own file, which was repaired by hand.
Four defects in this work were found by hand rather than by the suite,
and each is recorded where it was missed: a lost note, a one-way text
mode toggle, a geometry save on a path neither button takes, and a rule
list squeezed to one row by a long rule. Two Qt traps and one about the
user's compositor went into CLAUDE.md.
|
|
Item 77. The dialog could say how many messages a rule matched and not
which ones. A Preview in list button now runs the selected rule's query
in the main window; the dialog stays open, since comparing the rule
against its results is the point.
Two constraints from the backlog entry, both now asserted and both
mutation-checked.
The query runs exactly as stored, with no tag:new and no wrapping
parentheses. The post-new hook adds those when it applies a rule, and a
preview that copied them would match nothing outside a sync window,
since tag:new is set only on mail that has just arrived.
The account selector is cleared first. runQuery() wraps the bar's text
in the selected account's scope, and a rule query usually names its own
path already, so previewing one with an account selected would scope it
twice and show an empty list, which reads as "this rule collects no
mail".
The second mutation only fails once the test's config has an account to
select: with the default empty config the selector sits on "All
accounts" anyway, and asserting that a preview leaves it there passed
against the mutation. Recorded in the test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Item 80. A rule with eight From conditions left the list showing about
one and a half rows.
The list was added with stretch 1 and the form below it with none, which
looks decisive and is not: a stretch factor only distributes space above
each widget's minimum, and the form's minimum grew with every condition
row, so each row came straight out of the list. The builder asked for
120px with one row and 414px with eight.
A QSplitter now divides the list from the editor, so the balance is the
user's and is saved beside the column widths, and the condition rows sit
in a QScrollArea capped at 190px so the editor cannot grow without bound
however the splitter is set. The scroll area is what text mode hides;
hiding the builder inside it would leave an empty frame.
Three measures were tried in the test before one told the bug and the
fix apart, and two passed against broken code: the dialog's
minimumSizeHint does not track form rows and read 580 either way, and a
qMin against the scroll area's own hint read small whether or not the
cap was set, since an uncapped maximumHeight is QWIDGETSIZE_MAX. What
survives mutation is the editor pane's minimum inside the splitter, plus
the cap read directly, and both are asserted. A row's size hint is
invalid until the event loop runs, so the test calls processEvents after
selecting a rule or it measures the same height twice.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Item 75 shipped claiming the rules window remembers its size. It does
not, and no code here can make it.
Hyprland tiles the window to fill its slot, so the size dragged belongs
to the tile. saveGeometry stores frameGeometry beside normalGeometry and
restoreGeometry restores the normal one, which stays at whatever
resize() last set it to. Decoded from the real state file after a hand
test: frame 2248x806, normal 760x664. The dialog restores 760 correctly
and still opens tiled.
Three diagnoses were tried before this one and each was disproved by a
probe rather than argued away: that restoreGeometry rejected the blob as
off-screen, that the layout overrode a geometry applied before the first
show, and that a test could tell the broken and fixed versions apart.
The last one matters most: the offscreen platform returns an identical
frame for both, so a size assertion passed against the bug and a
mutation restoring it left the suite green. That assertion is not
reinstated.
The column widths, which are what actually works, keep their test. The
changelog and the backlog entry are corrected to say what ships, and
CLAUDE.md gains both the tiling-compositor trap and the rule that the
offscreen platform cannot test window sizing at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
The geometry was saved from closeEvent, and neither dialog button sends
one: Cancel calls reject(), Save calls accept(), and only the window
manager's X button produces a QCloseEvent. So the size and the column
widths were kept for the one route out of three that a user almost never
takes, and a resize followed by Cancel came back forgotten.
The save moves to a done(int) override, which both buttons funnel
through and which QWidget::close() also reaches.
The test that covered this passed against the bug because it asserted
with close(). It now drives all three routes rather than trusting one to
stand for the others, and shows the dialog before the close leg:
close() on a widget that was never visible returns early without
reaching done(), so that assertion would otherwise prove nothing.
Both traps recorded in CLAUDE.md, since neither is specific to this
dialog.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Item 75. saveGeometry() and the rule list header's saveState() go to
uistate.conf under keys of their own, written on closeEvent so a size
survives Cancel as well as Save. The 760x520 resize stays as the
first-run fallback.
The backlog's approach was wrong on one point and a test caught it. It
said to drop the resizeColumnToContents calls once a saved header state
exists, which fixes the restore and leaves the original defect standing:
with nothing saved, a width the user had just dragged was still
discarded by the next add or delete. Each column is instead auto-sized
once, on its first fill, after which the width belongs to the user
however it was set. Two flags, because the count column is filled later
by a reply from the worker.
The window stays a QDialog. Making it a top-level window needs the
unsaved-edit story that being modal currently sidesteps, and that is its
own decision rather than part of this item.
Both tests redirect XDG_STATE_HOME as well as XDG_CONFIG_HOME, so they
cannot write the real uistate.conf. The geometry is asserted on the
stored value rather than the reopened frame, per item 46: the offscreen
platform does not honour a resize.
Also corrects setFolders' doc comment, which still described the folder
list as coming from Config.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
The dropdown was built from config, which names one subtree per account
and nothing below it, so it offered five entries and no way to say
Drafts or Sent. A rule wants to target those as often as a whole
account.
NotmuchWorker gains requestFolders/foldersReady, walking the tree from
notmuch_database_get_path() and listing every directory holding cur/.
It belongs there because the database root is notmuch's database.path
and the worker owns the only handle that can answer for it; putting the
root in config would be the second source of truth the design refuses.
From the disk rather than from the index: a folder mbsync created and
nothing has landed in yet is still a folder a rule may target, and a
list derived from indexed message paths would not offer it.
The two tests build their own fixture rather than extending the shared
one, which needs a nested folder and would otherwise move seven count
assertions in unrelated tests. Mutation-checked: flattening the walk to
non-recursive fails the listing test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Ticking "Edit as text" was a one-way trip: the only way back to the rows
was closing the dialog and reopening it. The checkbox was parented to the
builder widget and sat on the match row, and switching to text mode hides
that widget, so the toggle disappeared along with the rows it governs.
Move it to the query row, which is visible in both modes.
The existing tests all passed against this, because they drove the toggle
through setChecked and then asserted on the checked STATE. A hidden
checkbox reports its state perfectly well, so every one of those
assertions held while the widget was unreachable. The new test asks the
question that matters, whether the toggle would be on screen, and it uses
isVisibleTo since nothing is isVisible on a dialog that was never shown.
Worth recording how close the mutation check came to endorsing this too.
Reparenting the checkbox alone left it in the query row's layout, so it
stayed visible and the test still passed. Only restoring the full shipped
shape, parent and layout together, reproduced the fault and failed the
test. A mutation that does not reproduce the original bug proves nothing
about the test that is meant to catch it.
The spec's layout sketch carried the same error and is corrected, with
the reason, so the next reader does not reintroduce it.
|
|
The first pass of the field repair restored the query and the tags and
stopped there. The note was also blank, which the user noticed: every
sibling account rule carries an identical note and only the damaged rule
had none.
The reason it was missed is worth keeping. The shell backup was read for
the tagging command, and the note comes from the comment block above it,
which the migration had given to all five account rules alike. The
handler at fault writes every field of a rule, so every field is equally
exposed, and a repair that checks only the fields that first drew
attention will leave some of the damage in place.
Restored from the four siblings, which are byte-identical, and the whole
file re-audited: no rule now has an empty query, id, note or tag list.
|
|
|
|
|
|
Leaving text mode with a query the builder cannot represent has to
refuse, since there are no rows that mean that query. It announced this
with a QMessageBox, which made the branch untestable: a modal blocks the
test that reaches it, so the one path that can strand a user was the one
path shipping unverified.
Say it in the warning label the dialog already has instead. That also
suits the moment better, since it does not interrupt someone mid-edit to
tell them something the label can hold while they keep typing, and it
matches how the tag dialog reports a bad tag.
Returning to the rows now calls showWarnings(), because the refusal
writes into the same label the load warnings use and a stale complaint
would otherwise outlive the query that caused it.
The test drives the refusal and the recovery, and asserts the warning
appears and then clears. Verified by mutation: letting the checkbox clear
regardless fails it.
warningTextForTest uses isVisibleTo rather than isVisible. Every child of
a dialog that was never shown reports isVisible() false, so the seam
would have reported no warning whatever the label held, which is a probe
that cannot see the thing it checks.
|
|
|
|
Opening the tagging rules dialog and pressing Save destroyed the first
rule in the list, without any editing. The rule lost its query and its
tags, then vanished entirely on the next load, since a rule with an empty
query is dropped as malformed.
Reproduced against the released tag rather than the branch, in a
throwaway worktree at 9585674 with a two-rule fixture: constructing the
dialog and running its save path left one rule of two.
onSelectionChanged blocked signals for the note widget only, while
m_enabled::toggled two lines later reached applyEditsToCurrentRule, which
writes every field from widgets the loader has not filled yet. The
existing comment there shows the hazard was known for one widget and not
extended to the other.
The fix landed with the builder work: the reloading flag now covers the
whole load, and switchingRulesDoesNotLeakRowsBetweenThem is the
regression test, verified by mutation to fail without the guard.
The live rules file had one casualty, the account rule sitting first in
the list, with both its query and its tags empty while every sibling was
intact. Restored from the shell backup that the earlier migration kept
and verified through mailctl's own reader. The rule had stopped tagging,
but only one message had arrived meanwhile; that message is now tagged
and the account is complete again at 14969 of 14969.
|
|
Selecting a rule now parses its stored query and rebuilds the builder
rows from it, and a row edit compiles back onto the query line and into
the working copy.
Populating the form was already able to write the rule just loaded over
whichever rule is current: m_enabled's toggled runs
applyEditsToCurrentRule while m_query still holds the previous rule's
text, which emptied the first rule's query on open. The existing
m_reloading guard now covers the whole load rather than one signal
blocker on the note, which also covers the combo boxes rebuildRows
populates.
|
|
|
|
|
|
|
|
|
|
The spec listed from:(((( among the queries the parser must reject, and
the plan's Task 6 asserted that rejection. Probing the built parser shows
it accepts the query as a From row whose value is the literal text, and
compiles it back byte for byte.
That is correct behaviour, not a leak in the strictness rule. notmuch
reads those parens as characters to search for rather than as grouping,
so the query is meaningful and the row displaying it tells the truth.
Rejecting it would buy nothing and would push a representable rule into
text mode.
The distinction the documents were missing: a parenthesis inside a VALUE
is not a shape question at all, only a parenthesis in grouping position
is. Restate both documents accordingly, and replace the assertion with a
round-trip one, which is the property that actually matters here.
|
|
|
|
|