| Age | Commit message (Collapse) | Author | Files | Lines |
|
A transient status armed before it would otherwise clear it when its
timer fired.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
The no-confirmation rule gains its second exception, by property: a write
scoped to the whole index confirms with the count, though it can be undone.
AGENTS.md and the spec now describe the backfill as built: the worker
resolves the ids once, writes one tag at a time over that fixed list, and
answers with a single ruleApplied from which the undo macro is built, with
nothing armed on the shared confirmation queue. Apply is refused during a
sync.
Hand round trip, throwaway index of 2 messages (one from the promo
sender), rules.json written by TagRules::save from the C++ side with
allow_archive and a note: post-new exited 0 and logged "applied 1
rule(s)"; after it, tag:promo 1, tag:inbox 1, tag:promo and tag:inbox 0,
tag:unread 2, tag:new 0. The file read back 1 rule with allow_archive
True and note "kept".
Italian translations for the 16 new strings; lrelease reports 725
finished, 0 unfinished.
Items 212 and 213 closed. Item 214 opened: an undo's write is not armed,
so its confirmation is credited to whatever command is waiting.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
A second Apply overwrote the pending token, so the first rule's answer
was dropped as stale although its writes had landed: no undo, no pending
count, no accounts. A write's answer cannot be superseded, so the token
is gone and every ruleApplied() is handled. Pending counts are keyed by
generation for the same reason, since a second Apply before the first
count returned dropped the first Apply entirely.
applyRule() no longer emits errorOccurred(). That path reverted an
unrelated in-flight user write and its message was then overwritten by
"nothing to change". The failure travels in the reply instead:
ruleApplied(description, changes, error). openReadOnly() and writeTags()
take an optional error out-parameter so the rule's path stays quiet.
The window shows the error, still records and offers undo for whatever
landed first, and names the rule from the reply's description.
Item 212.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
Apply no longer arms commands on the shared confirmation queue. A
backfill can run for a long time; a gesture made meanwhile cleared the
queue, and the backfill's confirmation was then credited to the user's
command, so undoing that command inverted the backfill over every
message it touched.
MainWindow counts the whole rule, confirms, and sends applyRule() with a
token. onRuleApplied() drops a stale token, records each operation's
effective change through the bookkeeping onTagsApplied() shares, and
pushes one MessageTagCommand per operation with the ids it moved, inside
one undo macro. Every configured account is marked edited, since a
backfill reaches mail no row shows and a narrowed sync could otherwise
clear the pending count without pushing it. Nothing moved shows the
nothing-to-change status instead of "done".
Also: a count of -1 says the rule could not be counted, and the
confirmation says undo lasts until the next search, which is when
runQuery() clears the stack.
Item 212.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
applyRule() replaces applyTagsToQuery(). It resolves the messages the
whole rule would move once, then writes one tag at a time over that
fixed list, and answers with ruleApplied(token, changes): one TagChange
per operation carrying the ids it actually moved. Always answered, even
when nothing moved, and never through tagsApplied(), so the window can
build the undo entry from the answer instead of the shared confirmation
queue that user gestures also use.
Resolving once matters when the query names a tag the rule removes:
re-resolving after the first write matched nothing and wrote half the
rule. Writing per tag keeps each effective set exact, so the undo never
strips a tag a message had before.
applyTags() keeps its behaviour; its body is now writeTags(), which
returns the changed ids without emitting.
Item 212.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
This reverts commit a30b4c2. A backfill will stop arming commands on the
shared confirmation queue altogether, so matching confirmations by tags
is no longer needed, and its drop-ahead rule could discard a live
command when an unarmed write's tags matched a later one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
onTagsApplied() handed each confirmation to the head of the waiting
queue. The worker stays silent for a write that moved nothing, so when
one operation of a rule found nothing to do (another writer got there
between the count and the write), the next operation's ids were
credited to it, and its undo inverted the wrong tag on the wrong
messages: item 176 through a race.
A confirmation now goes to the first waiting command whose add and
remove sets equal the change's, and the commands ahead of it are
dropped with no ids, so their undo stays a no-op. Equal-tag commands,
the two halves of a mixed selection, still match in send order. No
match credits nobody. TagCommand carries its own add and remove to make
the comparison.
Items 176 and 212.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
The dialog's Apply reaches MainWindow, which counts the messages the rule
would actually move, confirms with that count, then writes through the
worker. The rule is written one tag at a time inside one undo macro,
because the worker's effective set is per message: a single +a -b write
would make the undo strip a tag from messages that already had it. Each
undo entry is filled from its own confirmation, so it reverts exactly
what moved. Refused while a sync holds the write lock, checked again
after the confirmation closes.
Item 212.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
The dialog stays open after Apply, so a refusal from a failed attempt and
the load warnings about a file Apply has just rewritten both outlived what
they described. A successful Apply rereads the file and redraws the
banner. Deleting the last rule now disables Apply, and copying or
deleting a rule re-checks which rules the sync hook will skip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
Apply saves first, so only what the hook would run is applied, then asks
the owner to apply the saved rule. A checkbox maps to allow_archive, and
the banner names every rule the sync hook will skip.
Items 212 and 213.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
addChangedMessageIds() ran QStringList::contains per id, quadratic on a
10k-message write, which item 211's archive and item 212's backfill both
produce.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
|
The hook refused to remove inbox by skipping the whole rule, so the promo
rule (+promo -inbox) had never tagged a single arrival. A rule carrying
allow_archive now runs, and inbox is removed for it. unread is still
refused, and a rule without the flag is still skipped, with a log line
saying how to allow it.
Item 213.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
|
Both readers learn it together, as the rule format requires. No version
bump: the field is optional, and each writer emits it only on a rule that
removes inbox, so a file without one saves unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
Two items found together. A rule edit reaches only new mail, and a rule
that removes inbox is skipped whole by the post-new hook, which is why the
promo rule has never tagged an arrival. The spec adds an Apply button
that saves, counts what would change and confirms with that count, and an
optional per-rule allow_archive the hook honours for inbox only.
Items 212 and 213.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
Select all on a 10k-row search, then Archive, froze the window with one
CPU pinned. gdb on the live process showed the UI thread cubic before the
write was ever sent: each optimistic row write emitted dataChanged, whose
handler refreshed the action labels by walking the whole selection, and
each step of that walk looked the message up with a linear scan of every
thread.
ThreadListModel now answers id lookups from a lazily rebuilt hash,
invalidated by its own structural signals and by hand at the two in-place
id changes. MainWindow::batchModelWrites() holds the label refresh while a
loop of optimistic writes runs, then refreshes once. Two list membership
tests became sets.
The announcement also used to fade on a timer whether or not the write
had landed. It now reads "applying" until a round trip through the
worker's queue says the write has run, then "done". An error takes the
label back from a late "done".
Item 211.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
The list scrolled per ITEM, Qt's default, so a wheel notch jumped
several whole cards at once and a touchpad's small deltas were
quantised into card-sized jumps. The eye lost track of how far the
list had moved.
The view now scrolls per pixel, so a touchpad moves it continuously,
and ThreadListView::wheelEvent turns each wheel notch into a 160ms
eased glide of exactly one card. Notches arriving mid-glide add to its
target rather than restarting from where it has got to. Pixel deltas
and modified wheels pass through to Qt unchanged. The cost of per-pixel
scrolling is that a card can rest half above the top edge.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
Clicking the first message in a conversation overview's unread list did
nothing visible. applyPendingDashboardSelection() matched the
conversation row on the root's message id, so it reselected the row that
was already current and the overview stayed on screen. Since item 177
setThreadMessages keeps a conversation's first message as child 0, so
it has a row of its own. Only a thread of one now answers for its
message on its own row, the same guard 236ac86 added to the recovery.
AGENTS.md still said a thread's first message is never among its
children, which has been true only for a thread of one since item 177.
It is corrected to say so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
SearchTerm::quote() escaped an embedded double quote with a backslash.
notmuch has no backslash escape inside a quoted term: it doubles the
quote, and a backslash-escaped one ENDS the term. Selected text holding
a quote therefore let the rest of the selection reach the query as
syntax. Measured on a throwaway index: under a `tag:nomatch and` scope,
the backslash form of `x " or tag:inbox or id:"` matched every message,
while the doubled form matched none.
quote() now doubles the quote and leaves a backslash alone, since
notmuch reads it literally. Truncation still runs first, so a cut
cannot split a doubled pair.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
--message naming the first message of a multi-message conversation
selected the conversation row, so the pane showed the dashboard rather
than the message. The recovery matched the conversation row on its own
message id, which is the root's. The rule is that --message always
targets the message, shown inside its expanded thread, and that
--thread is the way to ask for the conversation.
Since item 177 setThreadMessages keeps a conversation's first message as
child 0, so the root already has a row of its own. applyPendingRecovery()
now lets the thread row answer for a named message only when that row is
not a conversation, which leaves a thread of one opening on its message
as before, and finds the root among the children like any reply. The
provisional pass still selects the thread while the tree loads and is
refined to the root's row when the children arrive.
The same recovery serves the stale notice and a double-click. A stale
notice raised while reading the root's row now reopens that row, which
is the same request. A double-click on a conversation row asks for the
conversation, so it no longer names the root and still lands on the
dashboard. recoveryOnTheFirstMessageSelectsTheThreadRow asserted the old
behaviour on the pre-177 premise that the root has no child row; it is
retargeted as recoveryOnTheFirstMessageSelectsItsOwnRow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
A --message launch handed to a window already in use switched the list
to the conversation and made the reply row current, and the message
pane stayed empty. The same launch into a fresh window worked.
selectRowAt() selected the row and then called the view's
setCurrentIndex(). QAbstractItemView::setCurrentIndex() asks
selectionCommand() what to do with the selection, and with no event to
read it answers from QGuiApplication::keyboardModifiers(), which is the
modifier state of the last input event the application received rather
than anything the user is doing now. With Control in that state the
command is a Toggle: the row selected a line earlier was deselected
again, onThreadSelected() refused a current row that is not selected,
and nothing was loaded. The provisional thread row the recovery selects
first went the same way, so the pane never left the placeholder. A
fresh process has received no input at all, which is why only a window
in use was affected.
The current index is now moved through the selection model with
NoUpdate, so the selection stays exactly what select() made it. Every
caller of selectRowAt() shared the defect (the stale-thread recovery,
double-click, the dashboard's message entries and the launch selectors)
and every one of them means "select exactly this row", so all of them
take the fix.
The test puts a window into use, leaves Control as the last modifier
the application saw, launches --message for a reply in another thread,
and asserts the reply is current, selected, and rendered in the pane.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
Completes the options, and the account keys after --account, read from the
[account.<key>] sections of the config the application itself reads. Thread
and message ids are deliberately not completed: offering them means querying
the whole notmuch index on every Tab, which on a real Maildir is tens of
thousands of ids nobody would pick from a list anyway.
Installed under share/bash-completion/completions, where bash-completion
loads a command's file on demand by name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
recoverStaleThread() runs thread:<id> in the account dropdown's scope, so a
--thread or --message for a conversation in another account than the one
the window was left on came back with no rows, and the list went blank with
nothing said.
A thread or message selector now switches the dropdown to All accounts
first, unless the launch named an account with --account, in which case
that scope is kept. For a message the switch waits until the id has
resolved, so a message that does not exist leaves the dropdown alone.
When the selector's own thread:<id> query returns no rows, the miss is
named in the status bar, reusing the existing "No thread matched" and "No
message matched" strings, and the view that was on screen when the launch
arrived comes back: the dropdown, the bar's text and the list, re-run in
the scope it was built in. The notice waits for that query to land, since
the query writes its row count to the bar and would cover it. A refused
thread id and an unresolved message restore the dropdown the same way, and
re-run nothing, since the list never changed.
The judgement is keyed to a flag the selector paths set after the query
and runQuery() clears, so the stale-thread notice and double-click, which
share recoverStaleThread(), behave as before.
README and CHANGELOG describe the selectors as they now behave: --account
alone opens its startup view, the other two look in every account unless
narrowed, brackets are accepted, and a miss keeps the view the user had.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
The account dropdown deliberately does not re-run the query: by hand the
user picks a filter next. A launch from another program has no next click,
so `--account work` on its own moved the dropdown and changed nothing the
user could see.
When the account selector applies and no thread or message was given, the
startup view now runs again in the new account. The constructor's startup
path moves into runStartupView() and both callers use it, so the view is
resolved the same way in each: a generated filter is asked for the
account's own query rather than having its all-accounts query wrapped in
the account's path.
The test starts on the trash view because its per-account query is the
account's own trash path; for a tag filter the generated and the wrapped
queries are the same string, and a test on one passed against the wrap.
It asserts on the generated string and on the rows, and fails with the
wrap put back.
anEmptySelectorSetChangesNothing also asserts that no query ran, which the
bar and the dropdown alone cannot show.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
A Message-ID is written <local@domain> in every header a caller copies it
from, and notmuch stores it without the angle brackets, so
`--message '<abc@example.org>'`, the form the README itself shows, matched
nothing and was reported as a miss. applySelectors() now trims the value
and strips one enclosing pair of brackets before anything else sees it, so
the worker's resolve and the recovery's selection target both use the bare
id. Stripping for the resolve alone would open the right thread and then
fail to select the message inside it, which is why the test targets a reply
rather than a thread root.
No refusal of a slashed id was added: notmuch 0.39 reads `/.../` as a regex
for mid: but not for id:, measured as 0 matches for id:"/.*/" on an index
where mid:"/.*/" matches every message.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
Add an Usage section to the README covering --account, --thread and
--message, the hex-only thread id, Qt's own options, the exit status 2 on a
bad option, and the single-instance hand-off over the local socket. Add the
Unreleased changelog entries for the selectors, the second-launch behaviour
and the plain-text status bar.
Translate the new strings into Italian. The option names stay untranslated in
the help text: they are wire format, and a translated option name is an option
the user cannot type.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
Connect first, become the server only if that fails. A live instance is handed
the selectors and this process exits without ever opening a database, so two
launches no longer mean two notmuch handles. When no instance answers and no
socket can be created either, the window still opens: losing single-instance
behaviour is a degradation, losing the mail client is not.
--version and --help stay hand-checked before QApplication exists: Qt's own
versions exit through QCoreApplication, and --version has to work where the
GUI cannot open. The selectors themselves are parsed after QApplication, from
app.arguments(), because the constructor strips Qt's standard options such as
-platform and -style; parsing the raw argv would reject them as unknown. An
unknown option prints the error on stderr and exits with status 2 before any
window is shown.
The socket's parent directory is created first, since the state directory does
not exist on a first run and listen() cannot create a socket inside a missing
directory.
Raising under Wayland is a request rather than a command. The compositor may
honour it as a focus hint or ignore it by policy; the selectors apply either
way, which is the half that has to work.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
A launch selector that matches nothing is echoed into the status label so
the user can see which value missed. That value comes from another
program's command line or from the single-instance socket, and a QLabel
left at Qt::AutoText guesses whether a string is markup, so a selector
shaped like HTML would have been rendered rather than shown. The format is
now stated rather than guessed, the same reasoning AGENTS.md records for
MessageDetailsDialog. No existing status message carries markup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
One entry point, called both at startup and by the socket handler when a later
launch arrives. Two paths would drift, which is the lesson this file has
already learned from every other pair.
The account moves first, because a built-in filter composes with the dropdown
and a query run before it would carry the old scope. The thread case reuses
recoverStaleThread() whole, as item 91's double-click already does. A message
id resolves to its thread first through the worker: opening a message means
opening its conversation with that message selected.
A thread id must be hex or it is treated as a miss and never reaches notmuch.
recoverStaleThread() builds thread:<id> unquoted, which is safe for ids notmuch
handed out and not for ones from another program's command line, where
"0000 or tag:inbox" would widen the query to the whole inbox. The check uses
anchoredPattern() because a PCRE $ also matches before a trailing newline.
An empty selector set changes nothing. A bare launch against a running window
means raise yourself, and a raise is not a navigation.
Also drops an empty public: label left in notmuchworker.h by the previous
commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
For --message (item 200), which knows an id and needs the conversation:
opening a message means opening its thread with that message selected, never
an id: query showing one card out of a conversation (item 91).
The id is quoted before it reaches notmuch, unlike every other id in this
class. Those came out of notmuch; this one comes off another program's command
line, and notmuch parses garbage happily while matching nothing, so an id
carrying query syntax would be read AS syntax with no error anywhere. The
quoting is local rather than SearchTerm::quote(): that escapes an embedded
quote with a backslash, which notmuch does not honour inside a phrase, so the
quote closed the phrase and the injected query matched a real thread (the new
test failed against it). notmuch escapes a quote by doubling it.
threadIdForTesting() keeps its name and gains a shared helper rather than being
promoted: it is documented as not being a slot, and the new entry point has to
answer asynchronously.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
A QLocalServer under the state directory. The first launch listens; a later one
connects, hands over its selectors and exits.
Connect-first ordering, and on Qt 6.11 the probe is the ONLY guard for a live
instance: with UserAccessOption, listen() binds in a private directory and
renames the socket onto the path, which replaces whatever is there, a stale
file and a live socket alike. Measured: with the probe disabled, a second
launch takes the first one's socket and both handover tests fail. The same
rename is what reclaims a stale file after a crash; the removeServer() retry
on AddressInUse is kept for a listen that binds in place.
The server reads each connection asynchronously and parses on disconnect,
rather than blocking in waitForReadyRead() on the UI thread. The client's one
write followed by a disconnect is what marks the payload complete, a reader
past the payload cap is aborted, and a connection that never hangs up is
dropped after two seconds. A connection that writes nothing at all is the other
launch's probe and is ignored without a warning.
A socket that cannot be created does NOT stop the window opening. A read-only
state directory costs single-instance behaviour, which is a degradation; it
must not cost the user their mail client.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
--account, --thread and --message, plus the payload that crosses the socket.
A value type with no GUI dependency: it is parsed before QApplication exists
and both halves need tests no window has to be built for.
QDataStream rather than a line-based payload, because a Message-ID may contain
a newline. Every read is status-checked, which is what catches a truncated
payload: a short read otherwise leaves the fields default-constructed and a
half-written id would be applied as an empty one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
A unix domain socket between two copies of this program, for item 200. Not
network protocol work: the rule in AGENTS.md is about IMAP and SMTP.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
aSuccessfulCronSyncDrainsTheEditedAccounts named a log but no status
file, so Config fell back to ~/.local/state/qtmaildir/syncstatus.json
and the test passed or failed on the developer's last cron run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
Escape was scoped to the pane and only cancelled an edit, so once an
event was selected nothing closed the pane again. It is a window shortcut
now, labelled Close event: with no edit open it clears the selection and
the pane hides. With an edit open it cancels only when focus is inside
the pane, so a stray Escape after clicking the grid cannot discard the
form.
That guard needed the month grid to take focus on a click, which it never
did; the view-scoped PgUp/PgDn, Delete and Ctrl+Z were likewise reachable
only by Tab until now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
The pane reserved a quarter of the window even when empty. It now shows
only on the details or edit page, so the grid takes the full width until
an event is selected or a new one is started.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
The month grid drew each cell's outline with drawRect while the brush was
still set from the previous cell's all-day chip or today pill, so every
day after one carrying an event was filled with that event's colour.
Reset the brush before the outline.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
|
|
|
|
Give RepeatRule its own Q_DECLARE_TR_FUNCTIONS context so lupdate extracts
describe() and the ordinal words, mirroring the array's QT_TRANSLATE_NOOP;
without it lupdate warned and RepeatRule/last was never extractable.
|
|
|
|
save be retried
|
|
|
|
pane
|
|
|