| Age | Commit message (Collapse) | Author | Files | Lines |
|
Selecting a thread root used to render the whole conversation, stubs
plus the last messages expanded, but only until the thread had been
expanded once. After that the identical click rendered a single message.
The user reported the inconsistency and asked for the single-message
behaviour throughout, and for the conversation view to go.
The cause was a timing one, not a race. The root card stands for the
thread's first message and onThreadSelected already preferred to load
just that, but the model learned the id only when the replies arrived,
so a fresh row fell through to a whole-thread render.
ThreadSummary now carries firstMessageId from the query itself, so the
id is known before any expansion and the fallback is unreachable. It is
free: notmuch_thread_get_toplevel_messages reads the index, not the
message files, and a walk with it is indistinguishable from one without
over a 36,615-thread database. Contrast recipients, which reads every
file and stays Sent-only.
The Sent view keeps showing what the user sent rather than the thread's
opening message, which is often someone else's. There is no
matched-messages iterator in libnotmuch, only a count, so that branch
walks oldest-first to the first NOTMUCH_MESSAGE_FLAG_MATCH and stops:
0.146s against a 0.143s baseline over 4,515 threads.
onThreadLoaded merges into renderMessages, since onMessageLoaded was
already delegating to it for the actual painting. It still takes a list
because MessageView renders a list; collapsing that is a separate change
to a class with its own tests.
NotmuchWorker::loadThread is kept and documented as having no UI caller.
It is a tested way to read a thread's messages with the match set
resolved, used as a helper by the worker's own tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Item 36's section moves to the closed file and its three traps go into
CLAUDE.md, which is where they will be read: the worker is unreachable
by findChild, rowCount on an unexpanded thread row is 0 by design, and
currentThreadId reports intent rather than content.
The claim that this class of bug cannot be reproduced in test_mainwindow
is now false, so it is corrected rather than left standing beside its
replacement. One in-test reference to item 36 as a permanent limitation
is reworded: bare-window cases still have no worker, but that is now a
choice per case rather than a property of the binary.
Item 66 stays open with the simple case ruled out. The negative result
sharpens this entry's own candidate rather than contradicting it: the
test drives setCurrentIndex, which updates the selection model
synchronously, while the suspect guard turns on a real click not having
done so yet. Two cheaper conditions are named as still unexcluded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
WorkerBackedWindow builds a throwaway notmuch database, writes a config
pointing at it, and hands back a Config. Opt-in per test: the fifty-odd
existing cases construct a bare window and pay nothing.
No production change. wireWorker() already builds the worker from
m_config.notmuchConfig(), so a written qtmaildir.conf exercises the
shipping path rather than a parallel one built for tests.
Three findings, each of which had made an earlier version of this test
worthless:
The worker is unreachable by findChild. wireWorker() creates it
parentless and moves it to its own thread, so the tests wait with
QTRY_VERIFY on observable state instead of on worker signals.
rowCount on a thread row is 0 until the thread is expanded, since
children are populated by the expansion. hasChildren is the correct
pre-expansion assertion and also proves the two messages threaded.
currentThreadId() is assigned synchronously in the selection handler,
before any worker round-trip, so it reports intent rather than content:
the test passed with onThreadLoaded() disabled entirely. It asserts on
MessageView::showingPlaceholder() now, checked blank before the click so
the check after it means something, and mutation-confirmed to fail when
the load is disabled.
Item 66 does NOT reproduce here: selecting an unexpanded thread root in
a two-message thread shows the message. Recorded rather than widened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Item 84's section moves to the closed file, recording that the fix was
a split rather than a suppression, and that its mutation check
reproduces the original hang at exit 124 rather than merely failing.
Item 36 is specced and no longer "on demand": item 66 needs it. Two
findings while writing it. The deliverable is a RED reproduction of 66,
with the fix deliberately excluded, since that defect has never been
isolated and designing a fix beside a hypothesis is how a wrong one gets
locked in. And the item is smaller than it has read since 2026-08-04:
wireWorker() already builds the worker from a config key, so a test
writes a config pointing at the fixture and nothing in src/ changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
showWarnings() did two separable things and one of them could not be
reached from a test. It set the status label, which is harmless, and it
raised a QMessageBox from the MainWindow constructor, which under the
offscreen platform nothing can dismiss: the constructor never returned
and the suite hung with no output, reading as an infrastructure failure
rather than a test one.
It splits in two. applyWarnings() keeps the status label and stays in
the constructor. configProblems() returns the list, and main.cpp raises
the dialog after show(), which also gives it a visible parent to sit on.
The distinction between warnings and problems is preserved exactly: a
keybinding being ignored interrupts startup, "no sync command
configured" does not.
The warning path now has its first test, using the config shape that
caused the original hang. Mutation checked by putting the modal back in
the constructor: the test times out at 124 rather than failing, which is
the behaviour this removes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Section moved to the closed-items file on the commit that closes it,
per the backlog's own rule. It records the two decisions that are not
recoverable from the code, and that the plan under-counted the
signature change: three test files drive these signals, not one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Third entry in the message pane's submenus and in each details row,
greyed rather than hidden when the query bar is empty, so the feature
stays visible to someone exploring a fresh window.
requestSearch refuses an Exclude with no query as well, so the guard
does not rest on the menu's enabled state alone. Mutation checked:
disabling that condition fails the new test on the blocked emit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
The menus cannot read the query bar and must not. MainWindow already
watched textChanged for the Save button; the same lambda now also tells
MessageView, which passes it to the details dialog at construction,
where it cannot go stale.
Nothing consumes it yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Four signatures, no behaviour change: the two shipped operations map to
Replace and Narrow. runSearchFromPane becomes a switch and gains the
Exclude arm, which nothing can reach until the menu entry exists.
Seven call sites across three test files moved with it, two more than
the plan predicted: test_messageview and test_mainwindow also drive
these signals directly.
mainwindow.h and messagedetailsdialog.h now include searchterm.h for
the type; messageview.h already did.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Introduced alone, ahead of the four signatures that change to it, so
that change lands as one mechanical commit with no behaviour difference.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Parenthesises both sides, as extend() does: unparenthesised, a
disjunction in the query bar binds so the exclusion covers only its last
term and leaves the excluded mail on screen, with nothing reporting an
error.
An empty existing query returns empty rather than the addition alone,
which is where this deliberately differs from extend(). Excluding from
nothing means the whole Maildir minus one value; the menus will grey the
entry out and this is the second layer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Ordered so each task leaves the suite green: the grammar first, then
the SearchMode enum alone, then the four signatures as one mechanical
change with no behaviour difference, then the guard, then the menus.
Two traps carried from item 85 into the task that inherits them. The
details dialog's mutation check hangs rather than fails, and a hung
binary is re-run stale by a later ctest. The mutation revert is by hand,
never from a backup file, which silently reverted a fix last session.
Construction order verified rather than left to the implementer:
registerActions() runs after buildUi(), so both pointers are live and
no null guard belongs in the lambda.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Two decisions the user made, both recorded with their rejected
alternatives so they are not revisited.
Excluding from an empty query would mean the whole Maildir minus one
value. The menu entry is greyed rather than hidden when the query bar is
empty, so the feature stays visible to someone exploring a fresh window,
and SearchTerm::exclude returns empty for that case as a second layer
against a caller that forgets the guard.
The menus cannot see the query bar, so MainWindow pushes the fact down
through MessageView::setHasQuery from the textChanged lambda it already
runs for the Save button. A callback was rejected as an indirection with
one implementation; silently doing nothing was rejected because a live
menu entry that does nothing is worse than a greyed one.
The backlog entry loses its inline approach and points at the spec,
carrying the three constraints that decide whether it can be picked up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
The 2026-08-14 reconciliation against the user's notes found one entry
with no item here: the right-click search offers "search for this" and
"add to search" but no way to add negatively.
Cause verified in the code rather than copied from the note. Item 85
shipped the two operations as a single bool, built identically in
messageview.cpp:566 and messagedetailsdialog.cpp:92 and branched on in
mainwindow.cpp:1654, and SearchTerm has no exclusion form at all. So
this is not a missing menu entry over an existing capability; there is
no third state for an entry to select.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
Five surfaces in the message pane offer a search built from what they show:
the header's subject and date, its sender and recipients on a single-message
thread, a tag chip, a body selection, and every header per message in the
details dialog. Each offers Search for this, which replaces the query, and
Add to search, which narrows it.
The details dialog became labelled rows along the way, which the user wanted
independently of this feature.
Hand tested through every surface, including the case the parenthesising
exists for: adding a sender to 'tag:inbox or tag:flagged' narrows it rather
than widening it.
|
|
Five surfaces in the message pane offer a search built from what they show,
replacing the query or narrowing it. The details dialog became rows along the
way, which the user wanted independently of this feature.
Item 78 is narrowed to the rule shortcut alone and drops to S: item 85 built
the menus and item 81 the seeded dialog, so both halves already exist. Its
approach text is corrected too, since it claimed the thread list holds a usable
sender and notmuch_thread_get_authors returns a display summary, not an address.
Three traps recorded in CLAUDE.md: a modal dialog must close before the action
it asked for runs, Qt::RFC2822Date validates the weekday against the date, and
every query goes through SearchTerm so five surfaces cannot grow five quoting
rules.
|
|
The connection is direct, so emitting first runs the query synchronously: the
model clears and the pane blanks while the modal dialog is still up, holding
the m_items it was built from. Closing first leaves no window in which the
dialog describes a thread the pane has already dropped.
|
|
The panes carry a finished query and know nothing of the query bar; the window
sets the field and calls the existing runner, so the account scope and the
generation counter keep working as they do for a typed query.
Narrowing combines here rather than in a pane, because only the window can see
what the bar currently holds. The tag strip's chips join the header, the body
selection and the details dialog as a fourth surface.
Also fixes the details dialog to actually close when a search is chosen: the
comment above the connection already described this requirement, but nothing
called accept() or reject(), so the dialog stayed open, the query ran behind
it, and the modal exec() never returned. This hung the whole test suite on
QT_QPA_PLATFORM=offscreen once a covering test was added.
|
|
A text box could not carry a per-value context menu without parsing displayed
text back into structure, and the user did not want a text box. Each row now
holds its own value, its message index and its query, built from the parsed
message.
Every value label states Qt::PlainText. The QPlainTextEdit this replaced was
plain by design rather than by style: header values come from strangers, and a
QLabel guesses the format under AutoText.
|
|
selectedText() reads the selection with no script injection; JavaScript stays
disabled in the profile. The page's standard menu is kept and the entries are
added to it below a separator.
The quoting is tested through a function taking the text, so it needs no live
web engine: a selection is arbitrary prose and can carry quotes, newlines and
query syntax, none of which notmuch reports as an error.
|
|
The menu lists what is searchable rather than hit-testing which line of a
rich-text label was clicked, which breaks as soon as the label wraps. The
values are collected by the pass that renders the header, so nothing parses
the markup back into structure.
From, To and Cc appear only for a single-message thread, sharing the condition
with the header's own display: a thread's recipient differs message to
message, and the menu must not offer what the header is not stating.
The test fixture's Date: header named the wrong weekday, which Qt::RFC2822Date
rejects outright, so no date offer would have been produced from it.
|
|
The loop was assigning a variable it never read, overwritten on every pass and
used only after, which reads as an accumulator and is not one. The overflow
chip's position is derived where it is used instead.
|
|
chipRectAt() is the single source of a chip's geometry, used by paintEvent and
by the hit test, so the drawn chip and the clickable chip cannot drift.
The +N chip yields nothing: it stands for a list of tags rather than one, so
there is no single value a search could be built from.
|
|
Qt::RFC2822Date validates the weekday against the date, so 'Thu, 14 Aug 2026'
parses as invalid: that day is a Friday. Task 2 hit it and Task 6 carried the
same wrong data. The failure is indistinguishable from the timezone-comment
trap the date parse exists to handle, so the plan now names it.
|
|
The date search needs it and the logic already existed inside a file-local
function, including the fix for Qt::RFC2822Date rejecting a string that
carries a trailing timezone comment. Extracted rather than rewritten, so the
second caller cannot end up without that fix.
|
|
One place for the query grammar behind every search surface, with no widget
involved so it is tested without a painter or a web engine.
extend() parenthesises both sides. The query bar may hold a hand-written
disjunction, and 'a or b AND c' binds as 'a or (b AND c)', which widens a
search meant to narrow it and reports nothing.
|
|
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>
|