| Age | Commit message (Collapse) | Author | Files | Lines |
|
The interface language followed the environment and nothing else, so
choosing it meant setting LANG for the whole application. [general]
language overrides it in both directions: it selects Italian on an English
desktop, and en_US forces English on an Italian one.
A short code or a full locale name both work, since Qt resolves "it" to
it_IT when the QLocale is built and QTranslator::load falls back from
qtmaildir_it_IT to qtmaildir_it. "system" is the default written down, so
the default can be expressed rather than only reached by deleting the key.
Validated on the locale NAME rather than on whether a translation loads,
because those are different questions and only one is an error. QLocale
accepts any string and degrades an unrecognised one to C rather than
failing, so `language = itallian` loads no translation and is otherwise
indistinguishable from asking for English on purpose; meanwhile
`language = en_US` legitimately loads nothing, English being the source
language and shipping no .qm. Checking the name separates the typo from
the deliberate choice, and the typo is reported.
The translator is now installed after Config is loaded, since the config
is what chooses it. The cost is that config warnings are generated before
the translator exists and are therefore built in English; retranslating
them would mean re-running load(), and a warning about the config file is
the one string a user can still act on in either language.
Verified against the real loader across six configurations, under both
LANG=en_US and LANG=it_IT: short and full codes select Italian, system and
an absent key follow the environment, en_US forces English whatever the
environment says, and a bad name reports a problem and falls back. Two
mutations checked: dropping the name validation and treating "system" as a
locale name each fail a test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Nothing loaded a translation before this: no QTranslator, no .ts file and
no build rule, so every string was English whatever the locale said. The
language now comes from the environment, LANG=it_IT.UTF-8, and any other
locale runs in English as before.
The audit found that the tr() discipline was largely holding, and found
eight strings that could never be translated into any language. kFields[]
in tagrulesdialog.cpp declared the rule-builder field labels with
QT_TR_NOOP inside an anonymous namespace, where lupdate reports "tr()
cannot be called without context" and extracts nothing, while the use site
calls TagRulesDialog::tr() on them at runtime. From, To, Cc, Subject, Tag,
Folder, Attachment and Date: the whole vocabulary of the rule builder,
absent from every translation file that could ever exist. The source
compiles and reads correctly; only lupdate reveals it.
Q_DECLARE_TR_FUNCTIONS is not the fix for that case, though it is the fix
for a free function calling tr(). Measured against lupdate: a class
carrying the macro beside the array still extracts 0 strings, because the
context must be attached to the literal itself. QT_TRANSLATE_NOOP names it
explicitly and matches the tr() that already reads them, so the use site
needed no change.
Twenty configuration and keybinding warnings were not translatable either.
They are user-facing, reaching the status label and the "Configuration
problems" dialog. Config already had the tr() macro; KeyMap needed it.
Translating the filter labels then broke startup_query, found in hand
testing: a filter's name is a translated label, so `startup_query = Inbox`
matched nothing where the filter shows as "In arrivo". The application
opened a different view and reported the user's own working config as
invalid. Resolution matches the generator as well now, which is stored in
queries.json and identical in every locale; the translated name still
works. The regression test installs a real QTranslator rather than a stub,
since the bug lives in the gap between the stored string and the displayed
one, and it writes a queries.json because the warning it asserts on is
guarded by a non-empty saved-query list: without one the branch never runs
and the test passes against a broken check.
main.cpp's --help and --version stay bare printf, as they run before
QApplication exists and no translator could serve them.
Verified per the backlog's own standard, that lupdate output is the
evidence rather than reading: 355 strings extracted with zero context
warnings, where before there were 327 with eight; lrelease reporting 355
finished and 0 unfinished; the built .qm loaded in a standalone probe
printing "From -> Da" and both Italian plural forms; and the install rule
placing it where main.cpp looks. test_translations guards it and was
mutation checked, failing on an emptied translation and naming the defect
when QT_TRANSLATE_NOOP is reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
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.
|
|
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.
|
|
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.
|
|
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>
|
|
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>
|
|
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>
|
|
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>
|
|
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.
|
|
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.
|
|
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 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>
|
|
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>
|
|
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 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>
|
|
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.
|
|
|
|
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.
|