| Age | Commit message (Collapse) | Author | Files | Lines |
|
Answers "start me in work - Inbox rather than All accounts - Inbox". The key
names an account by its [account.<key>] suffix and the dropdown is set to it
before the startup query runs; because a built-in filter composes with the
dropdown, that is the whole mechanism and the key never reaches a query
builder. Validated on load: a name matching no account is reported and cleared,
since the dropdown has no entry for it and would sit on All accounts without
saying why.
Which side applies the scope depends on what the startup entry is, and getting
it wrong is silent in both directions. A generated filter comes back from
resolvedQuery() already scoped, so letting runQuery() apply the dropdown again
gives path:"work/**" and (path:"work/**" and (tag:inbox)). A saved query does
not, because resolvedQuery() ignores the account key for one, so claiming it
was already scoped leaves it unscoped with the dropdown pointing at Work.
The first of those shipped in this session's working tree and passed its test,
because the assertion used contains() and the double-scoped string contains the
scope too. It asserts the exact query now. The second was found by writing the
test for the case rather than by reading, and is covered by
aStartupAccountAlsoScopesASavedStartupQuery.
The README's startup_query documentation was wrong on two counts after the
previous commit: the fallback is the Unread filter rather than the first query
in the file, and the name can now match a built-in filter.
|
|
Two defects, both reachable only after item 93.
startupSavedQuery() searched the saved queries alone, so a startup_query of
"Inbox" matched nothing once Inbox shipped as a built-in filter and the
duplicated entry was removed from queries.json. It then fell back to
m_savedQueries.first(), which is an arbitrary choice that used to look
reasonable while every install carried an Inbox entry: with the duplicates gone
it opened on a leftover search for one sender, and an empty queries.json opened
on nothing at all. The search now covers the saved queries first, so the user's
own entry wins a name collision, then the built-in filters; the fallback is the
Unread filter, which is always present.
The default startup name has always been "Unread" and now resolves for the
first time: before this it named nothing unless the user happened to have such
an entry.
The constructor also read startup.query directly, and a generated entry stores
no query at all, so even a matching filter opened an empty bar. It resolves
through Config::resolvedQuery() now, unscoped, since the account dropdown
starts on "All accounts".
Icons per the user's choices: a star for Important rather than the flag
action's own icon, since on the query row an icon reads as a category rather
than as an instruction, and mail-folder-sent rather than mail-sent.
Three tests changed rather than adapted, because their premises were the
defect. Two asserted the first-saved-query fallback.
aCronSyncDoesNotRefreshBeforeAnyQueryHasRun assumed a fresh window had run no
query, which is no longer true; it is now
aCronSyncRefreshesTheLastRunQueryNotTheQueryBar and asserts the property that
actually matters on a cron timer, through a new lastRunQueryForTesting() seam,
since a legitimate refresh bumps the generation and the counter cannot tell the
two apart.
|
|
Item 57 renamed the `flag` action to "Important" in 0.14.0, chosen over
"Starred" partly because &I was free where &S collided with Mark spam. Item 93
then shipped the filter for the same tag as "Flagged", so one window offered
both names for one thing. The generator keeps its own name, `flagged`: that
string is stored in queries.json and matched against a closed set, so it is
wire format rather than a label.
The filters are QToolButtons now, like the Save button at the other end of the
row, carrying a themed icon with the text beside it. Icon AND text for the
reason the Save button already records: this row is a row of text buttons, so
an icon alone reads as a different kind of control than it is. Theme icons
rather than the shipped SVGs in Marks, because item 70's split is that the
panes are ours and the chrome is the system's, and the query row is chrome.
mail-mark-important matches the `flag` action's own icon, since the filter
finds what the action marks.
The icon test asserts a NAME was requested rather than that the icon resolved:
QIcon::fromTheme returns null where no icon theme is installed, so isNull()
would fail for a reason unrelated to this code. Dropping the setIcon call fails
it.
Widening the buttons to QToolButton broke eleven tests that reached them
through findChild<QPushButton *>, which does not match a sibling type. The
helpers and the filter lookups take QAbstractButton; savedQueryButton() stays
on QPushButton, since the user's own queries really are those.
|
|
|
|
Items 93, 95 and 90. The query row starts with Unread, Inbox, Flagged and Sent,
shipped by the application rather than pinned by the user, and they compose with
the account dropdown instead of resetting it. Item 90 is absorbed: the button
that cleared the account selection is no longer a saved query at all.
Item 95 was found by hand testing this and is not caused by it: no entry in the
saved query overflow menu had ever been runnable, because Qt emits no triggered
for an action that owns a submenu.
|
|
Item 93 ships four built-in filters composing with the account dropdown, and
absorbs item 90. Item 95 is the overflow-menu defect found while hand testing
it: pre-existing, not caused by 93, and recorded as its own item rather than
folded in.
Two traps go to CLAUDE.md because they are still true of the code. Qt emits no
triggered for a QAction owning a menu, which is why every entry in the saved
query menu had always been inert. And a generator must be asked for one
account's query rather than having its all-accounts query wrapped, since the
wrap returns the right rows by accident of path: being hierarchical and a
row-count test cannot tell the two apart.
A third goes beside the existing rendering-probe warnings: visualRect reports a
real height for a row scrolled out of the viewport, so a non-empty check passes
while the pixel loop measures nothing and blames the wrong defect.
The Upgrading note covers what a user sees: the row starts with four shipped
buttons, a duplicate name means one of theirs is now beside a built-in, and
their migrated Sent entry is unpinned for them rather than deleted.
|
|
An unpinned query could not be run. Its menu action carried both a triggered
connection and a submenu of edit actions, and Qt does not emit triggered for an
action that owns a menu: clicking the entry only opened the submenu, so the
connection had never fired.
It shipped unnoticed because the menu was the rarely-used half while the user's
queries were pinned buttons. Item 93 moved every query into the menu, which is
how it surfaced, and item 94 makes the menu their only home, so this is now the
path that has to work.
Running is an item inside the submenu, first and above a separator, with the
edit actions below it. The entry keeps its submenu because an unpinned query
must still be editable and deletable.
The test asserts the Run item exists and is first, then that triggering it
reaches the query, then that Edit and Delete survived beside it. Restoring the
old wiring fails it on the first of those, naming the Qt behaviour rather than
just reporting a wrong query string.
|
|
Item 93, the UI half. Unread, Inbox, Flagged and Sent are buttons the
application ships, sitting first on the row, ahead of the user's pinned saved
queries.
runFilter() is runSavedQuery()'s opposite in the one way that matters: it READS
the account box and never writes it. That is item 90's defect. A filter narrows
what the user is already looking at, so the dropdown is its input rather than
something it resets on the way past. A saved query keeps setting the account
from what it stored, because it is a destination and states its own scope.
runQuery() gains an AccountScope parameter. A filter's text arrives already
resolved in the selected account's scope, and scoping it again would put
path:"work/Sent/**" inside path:"work/**".
Two migration changes, both of which unpin rather than delete:
- Sent is no longer migrated from the INI into queries.json. The built-in
filter covers it, and migrating one too would put two Sent buttons on the
row, one editable and one not.
- A stored entry naming a known generator is unpinned on load, which is what
every install upgraded through 0.19.0 carries. It keeps its name and its
generator and moves to the menu. Deleting it would be data loss on a file
whose readers are supposed to preserve what they do not own.
The test suite needed the same distinction the design makes.
savedQueryButtonLabels() now skips the filters, and savedQueryButton(window,
label) replaces five positional row->findChild<QPushButton *>() lookups that
were silently returning Unread.
One rendering probe had to be fixed rather than adapted.
replyRowsKeepTheirTextUnderTheThreadLine resized the window to 300px, and four
more buttons pushed the reply row below the viewport: the pixel loop then ran
zero times and reported "0 pixels, the row was painted over", which is a
different defect from the one it exists to catch. It gets 600px and a guard
asserting the row is really inside the viewport, so the next person to shrink
it gets told the truth. Verified by putting 300 back: the guard names the row
at 83..165 in an 82px viewport.
|
|
Item 93, the Config half. Four built-in filters, Unread, Inbox, Flagged and
Sent, as generated entries in kQueryGenerators, which was already a closed set
validated on load for Sent alone.
resolvedQuery() gains an overload taking an account key, and that is what makes
a filter compose with the account dropdown instead of fighting it. A generator
is asked for the account's OWN query rather than having its all-accounts query
wrapped in a scope: wrapping gives
path:"a/**" and (path:"a/Sent/**" or path:"b/Sent/**")
which returns the right rows only because path: is hierarchical, so a row-count
test passes against it. The tests assert on the query string for that reason,
and the mutation putting the wrap back fails two of them.
An ordinary saved query ignores the account key and keeps resolving through its
own stored account, which is the behaviour item 90 leaves alone.
matchNothingQuery() exists because an empty query means "match everything" to
notmuch: an account configuring no sent folder would otherwise give a button
labelled Sent that shows the entire Maildir.
Config gains Q_DECLARE_TR_FUNCTIONS for the filter names, which are button
labels. The generator names are not translated: they are matched against the
closed set and stored in queries.json, so translating them would make a file
written in one locale unreadable in another.
No UI yet, and no migration: the query row still builds from pinned saved
queries.
|
|
The user's end state for the query row is built-in filters only, with every
saved query living in the menu. At that point SavedQuery::pinned has nothing
left to decide.
Blocked on 93 and deliberately separate from it: the four buttons have to be
lived with first, and if one of them is wrong, pinning is the escape hatch,
which has to still exist to be used.
Recorded as a user-visible removal rather than a cleanup. pinned shipped in
0.18.0 as a checkbox in SaveQueryDialog and a right-click action, so removing
it is a minor bump with an Upgrading note. The stored field is a separate
decision from the UI, and leaving it in queries.json unread is both cheaper
and reversible.
The spec for 93 gains the ordering rule this resolves: filters first in fixed
order, the user's pinned queries after them, and nothing configurable, since
the mixed row exists only until 94 lands.
|
|
Explaining item 90 to the user produced a reframing rather than a fix. The
buttons and the "more queries" menu are two different kinds of thing sharing
one mechanism: a filter narrows whatever the user is looking at and should
compose with the account dropdown, while a saved query is a self-contained
destination entitled to set the account itself.
Nothing ships as a default today, so the buttons are whatever the user pinned,
which the queries.json migration did to every [queries] entry. That drift is
the defect.
Item 93 ships four built-in filters, Unread, Inbox, Flagged and Sent, as
generated entries in the closed kQueryGenerators set that already exists for
Sent. The user's own pinned queries are unpinned rather than deleted once the
buttons are confirmed working.
Three findings from reading the code, all in the spec. A generator must answer
per account rather than having its all-accounts query wrapped in a scope, or
Sent becomes path:"a/**" and (path:"a/Sent/**" or path:"b/Sent/**"), which
returns the right rows only because path: is hierarchical. Sent is flat and
the other three are not, so the four match in scope and not in view mode. And
m_accountBox has no signal connected to it, which is now a decision rather
than an omission: changing the account runs nothing, the button is the verb.
Item 90's section moves to the closed file, kept in full because its cause and
the rules preview that motivated the reset are still true of the code.
|
|
Item 74. runQuery() set the status bar once and only queryFinished cleared
it, so the bar kept claiming a query was running for the whole walk while
rows were visibly arriving behind it. Measured cold against a 1.1 GB index:
the first batch reaches the model at 642 ms and the walk finishes at 5714 ms,
so five seconds of a slow query read as a frozen one.
onThreadsReady now sets the bar from the model's own row count after each
batch, which is the number of rows the user can actually see. No timing
changes; this only stops the bar from lying.
The refresh branch returns before the new line, so a background refresh stays
silent exactly as onQueryFinished already keeps it. That silence has its own
test, which fails when the write is moved above the guard.
beginRefreshForTesting() is a new seam: refreshCurrentQuery() returns early
without a worker and a bare window has none, so a test cannot otherwise reach
the refresh path.
|
|
The 2026-08-15 pass over ~/Documents/Obsidian/note/notes on qtmaildir.md
found four entries with no item here, and one already-closed item with a
case that was never verified by hand.
89: runAutoSync() returns without re-arming the timer when a sync is
already in flight, so an edit made after the running mbsync passed that
account waits for a manual sync or cron. The second half of the same
complaint, rows leaving the list mid-interaction, is a design question
and is recorded as one rather than guessed at.
90: runSavedQuery() clears the account box for a query that names no
account. The line is deliberate and its comment says why, so this needs
a decision rather than a fix.
91 and 92 are unspecified, as the notes themselves say.
Item 66's row gains the one case its fix should already cover: a
single-message id: query whose card would not open is the same empty
MessageIdRole failure, unverified against an id: query.
|
|
Item 66 turns out not to have been the defect it was filed as. The pane
was never blank: an unexpanded thread root rendered the CONVERSATION,
and the same click rendered one message once the thread had been opened,
because the model learned the root's message id only when the replies
arrived. The user's step-by-step account is what separated the two
halves; two probes against a real database had failed to reproduce the
blank pane because there was none.
Closed by carrying firstMessageId in the query and removing the
conversation view, which the user asked for after being told the stubs
not expanding was itself a defect and that the feature was being judged
in a broken state.
Two defects came out of it and are open. 87: auto mark-read still marks
a whole thread, coherent while a root rendered the conversation and not
any more. 88: threadAt(current.row()) answers about the wrong thread for
a reply row, because a tree numbers rows per parent. 87 is blocked on 88
and the entry says why: a fix for 87 was written, mutation-checked,
shipped and reverted the same evening after it marked an unrelated
message read.
CLAUDE.md gains the row-number trap as its own entry rather than leaving
it implied by the item 20 note, plus the rule that a test for a write
path must exercise the reply case: the reverted fix was green because it
asserted on a root selection, the one case where row() is correct. The
cid-prefixing note is corrected to say every caller now passes one
message while explaining why the prefixing stays.
The changelog carries a Removed entry and an Upgrading note, including
that mark_read_delay_ms accepts a negative value to disable auto
mark-read entirely, verified against config.h.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Reverts bde7409 and 66f1159. The user hit the worst possible symptom:
clicking one message marked a DIFFERENT, unrelated message read.
The cause is in markCurrentThreadRead, which reads
m_model->threadAt(current.row()). CLAUDE.md records this exact trap: a
tree numbers rows PER PARENT, so a reply's row() indexes its siblings
and threadAt() on it answers about an unrelated thread near the top of
the list. The guards then compared the right ids against the wrong
thread and let a write through for whatever message the timer's state
named.
That fault predates these commits, but they made it reachable and
harmful: while the write was thread-scoped the mismatch was mostly
masked, and scoping it to a single message turned it into "a random
message is now read".
Reverting rather than fixing forward. Marking the wrong mail read syncs
out to the server and cannot be undone from here, so the safe state is
the previous behaviour, which is too broad but predictable. The item 66
work in 4a4f82f stands: a thread root still renders one message.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Follows the message-scoped mark-read. The user reported the write going
out with nothing visible changing: the status bar counted an unsynced
edit while the card stayed bold and the message pane still showed the
`unread` tag, until the next query corrected it.
sendMessageTagChange made no optimistic model update on purpose, because
applyTagChange is keyed by THREAD and repainting a whole row for a
one-message edit would claim every reply had changed too. That trade is
right for an explicit tag edit and wrong for auto mark-read, where the
visible change IS the feature and the delay exists to deliver it.
ThreadListModel::applyMessageTagChange updates the message wherever it
is held, as a child row and as `first`, and lets the thread's summary
follow only when the answer is unambiguous: a thread reads as unread
while ANY message does, so the tag is cleared from the thread only when
no other message still carries it. For an unexpanded multi-message
thread the per-message tags are not loaded, so the summary is left for
the next query rather than guessed at.
Mutation checked: without the call the card holds `unread` for the full
timeout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Reported by the user: selecting an unexpanded thread root marked every
message in the thread read, replies included. maildir.synchronize_flags
is on, so removing `unread` rewrites Maildir filenames and the next sync
carries it to the server: mail the user never saw stops being unread
everywhere.
This was coherent while a root click rendered the whole conversation,
because everything marked read had been displayed. Removing that view
made a root render one message and left the thread-wide write in place,
so the defect arrived with the previous commit.
markCurrentThreadRead now sends m_currentMessageId, which is what the
pane rendered, through sendMessageTagChange. The thread-level `unread`
guard is dropped with it: a thread carries `unread` while ANY message in
it is unread, so it would pass a read root under unread replies and send
a write for a message already read. Scheduling still checks it, which
keeps a fully-read thread from arming a timer.
The test asserts on which worker entry point the window used, because
reading tags back cannot answer this. Three earlier versions passed
against the unfixed code: TagsRole is empty for a message row by design,
MessageOwnTagsRole subtracts thread tags and drops marks so it can never
hold `unread`, and raw node tags are not refreshed until onTagsApplied
confirms, which lands after the assertion. Mutation checked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Asserted by hand until now, which meant asking the user to look at the
one branch I had rewritten twice after misreading the libnotmuch API.
Both directions in one test: a query matching only the reply reports the
reply under withRecipients, and the same thread under an ordinary query
still reports its opening message, so this is a Sent special case rather
than a change of meaning everywhere.
Mutation checked: disabling the branch makes a Sent row report
a1@example.org, the thread's opener, where a2@example.org is the message
that matched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
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>
|