| Age | Commit message (Collapse) | Author | Files | Lines |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
A vCard FN is untrusted and ContactStore faithfully decodes `\n` to a real
newline, so `Evil\nBcc: x` was inserted raw into a recipient field and flowed
through splitRecipients() to MessageBuilder. contactInsertionText() only quoted
a name carrying a comma or a double quote, so every other RFC 5322 special
(<, >, ;, @) and any control character reached the header unguarded.
The name now has control characters and whitespace runs replaced by single
spaces, and every non-empty name is quoted, with `\` escaped before `"`.
Quoting contains all the specials in one step. The parser is left faithful;
this is fixed at the consumer/trust boundary.
Tests: a name with a decoded newline inserts no control character and yields
one recipient; a name with <, >, ;, @ is quoted and yields one recipient. The
three tests that expected an unquoted plain name now expect the quoted form.
|
|
MainWindow reads contactsDir() once while building its UI, holds the
result in m_contacts, and hands it to the query bar's completer right
after that is constructed and to every ComposeWindow as it opens. An
empty contactsDir() skips the call, so a machine with no address book
pays nothing and warns about nothing.
The directory is not watched: a restart picks up a vdirsyncer update,
and a QFileSystemWatcher would be a live-index feature nobody asked for.
No action is added, so none of the five places in "Adding an action is
FIVE places" applies: there is no name in KeyMap::knownActions(), no
default binding, no icon-table entry and no menu entry to add.
|
|
from: and to: now offer the vCard store's addresses, each quoted via
SearchTerm::quote() with the contact's name as the description. The store
is the enumerator libnotmuch does not expose, which is what the old
complete-nothing comment said was missing; it keeps that role for folder:,
subject:, attachment:, thread: and id:.
With no store configured the branch returns {} and the behaviour is
unchanged.
|
|
One shared QCompleter serves To, Cc and Bcc, attached with setWidget and
never setCompleter, which resets the prefix to the whole field and stops
matching after the first comma. The prefix is the comma-delimited token
under the cursor, set by hand from textEdited; accepting replaces only
that token and leaves the rest of the field alone.
Candidates match the name and the address case-insensitively. A display
name containing a comma is quoted on insertion, and splitRecipients() is
now quote-aware so the quoted name survives as one recipient.
Contacts reach the composer through setContacts() rather than a fourth
constructor argument, so every existing three-argument construction and
test stays as it was. An empty list leaves the fields behaving exactly
as before completion existed.
|
|
Adds Config::contactsDir(), the [general] key Task 5 will read to locate
the ContactStore. Empty means the feature is off.
The key is read WITHOUT the general/ prefix, like notmuch_config, because
QSettings' INI backend strips a section literally named [general]. Absent
or empty is silent; a set path that does not exist is reported through
addProblem(). A leading ~ is expanded by a local helper, since config.cpp
expands no other path and this is the first one to need it.
|
|
Reads a vdirsyncer contacts directory of vCard 3.0 files into a
QList<Contact> for the completion work that follows. Pure over values,
no widget and no QCompleter, so the parse is testable without a window.
unfold() joins folded lines before any field is looked at; parseCard()
splits property from value on the first colon outside a quoted
parameter, unescapes FN, and yields one contact per EMAIL line;
loadDirectory() walks recursively, skips unreadable or addressless
cards, de-duplicates on the address case-insensitively, and sorts by
name then address. N, PHOTO, ADR and TEL are deliberately not used.
23 new tests. No user-facing strings, so no tr() change.
|
|
The spam branch of populateMessageBar() was keyed on the path predicate
alone, but its only action is hidden on a reply, so a reply inside an expanded
spam conversation lost Reply, Forward and Star. Skip the branch when the
reply guard is set, so the ordinary branch populates instead.
Extend notSpamIsOfferedInTheSpamView with the message-bar assertions the
QAction-only check missed, waiting for the reply row to load first, and mark
Task 9's step checkboxes done in the plan.
|
|
Close the two Important test gaps and fold in the minor notes.
notSpamIsAbsentOnAReplyRow passed for the wrong reason: the reply node had
no filePath and the selected child was the first message, so the predicate
answered false on the empty path and hid the action with or without the reply
guard. Give the reply a real spam path and select the actual reply child;
mutation-checked that removing the guard now fails the test.
Add notSpamThreadMovesEveryMessageHome, the thread-scoped coverage
notSpamThreads()/m_pendingThreadScope/wholeThreadIds had none of, and assert
the folded thread-scoped trash Restore re-adds the inbox tag.
Rename the label Not junk -> Not spam (no free mnemonic in the Message menu)
to match the rest of the UI, with the Italian translation updated, and add a
changelog line for the thread-scoped Restore inbox-tag fix.
|
|
Backlog item 201. A message in the Spam view had no way back out: spam is
one-way and Restore is hidden outside the trash. not_spam moves each message
to the folder its moved-from: origin names, falling back to the account's
inbox (reported) for provider-caught mail with no origin.
The action is labelled "Not junk" on the free Alt+J: every letter of "Not
spam" is taken in the Message menu, and Alt+P (Re&ply) and Alt+S (Mark &spam,
frozen) are unavailable.
restoreResolvedMessages() and the undelete_thread branch are parameterised
with the cleared tag and undo description rather than copied, so Delete and
Not spam cannot drift.
|
|
|
|
|
|
|
|
|
|
Mark spam was a tag-only action that added the spam tag and removed inbox,
so a message marked as spam stayed in the inbox on disk. It now MOVES the
file into the account's configured spam folder, exactly mirroring Delete:
the account-relative spam key is the destination, the move records
moved-from: with the origin, and unread and inbox are stripped in the same
confirmed write so one undo returns the folder and the tags together.
NotmuchWorker::moveMessages already handled a folder generically and
applyTags already overwrote an older moved-from: tag, so the worker needed
no change; the tests pin that behaviour for the spam destination.
Five existing tests used spam as a worker-free, tag-only stand-in for the
old Delete. Since spam is now a move too, they are retargeted to flag, the
remaining selection-scoped tag-only action.
|
|
|
|
|
|
|
|
Item 101. The sync run was already account-aware for pending edits and
consulted the account dropdown for nothing, so looking at one account and
pressing Sync collected every one of them.
pendingSyncChannels() now reads the dropdown as well as m_editedAccounts.
The two are a UNION rather than one replacing the other, which is the whole
safety property: looking at one account while having edited another is
ordinary, and a run that dropped the edited account's channel would strand
that write with nothing on screen to say so. All accounts is the empty key
and narrows nothing, so a full fetch stays what an unselected window asks
for. The existing fallback is untouched: an account whose section names no
channel still widens the run rather than being silently skipped.
syncStartedText() is the visibility half. A run opened with "Syncing..."
whether it covered one account or all of them; it now names what it covers.
The exit paths overwrite the label with their own wording immediately, so
the parameter is defaulted rather than threaded through them.
Four tests. The narrowing one asserts the channel list is EMPTY before the
gesture, since empty is what a full fetch looks like and a test starting
from a narrowed state could not tell the fix from a window that had never
widened. Mutation-checked twice: ignoring the selection fails three of
them, and replacing the pending set instead of unioning with it fails the
union test alone, which is the version that looks correct and loses a
write.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
|
|
Item 192's second half, answered by the user: indexing is not repainting.
The sent copy became findable the moment it was sent and a Sent view already
on screen still did not show it, because nothing re-ran the query. The model
cannot insert the row optimistically either, since item 170's constraint
applies: the query never returned that thread.
NotmuchWorker::indexChanged() is emitted at the end of both indexDraftFile()
and removeIndexedFile(), the only two entry points that change what a path
query would return without any query having run. MainWindow connects it to
refreshCurrentQuery(), which covers all three gestures a path view can miss:
a sent copy indexed, a draft saved, a draft's entry dropped on send. Wiring
only the indexing half would have left a ghost draft row visible in a Drafts
view after a send.
The signal carries nothing, so it cannot invite an optimistic insert. It is
emitted after the database closes, so a refresh reaching notmuch on the next
turn of the event loop cannot race the write handle. refreshCurrentQuery()
rather than runCurrentQuery(): a send must not clear the selection, the
expansions or the undo stack of the window behind the composer.
aSentMessageAppearsInASentViewAlreadyOnScreen drives a real send through a
worker-backed window, asserts the Sent view is empty first, and asserts the
row arrives with no second returnPressed() and no sync. Mutation-checked by
disabling the connection.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWeBw3UqxpBktSc1AkZ6ir
|
|
ComposeWindow filed the sent copy into the account's sent folder and
discarded the path DraftStore::write() returned, using the result only to
test for failure. Nothing announced the file, so notmuch never learned it
existed, and the Sent view is a path query over the index rather than a
listing of the folder: the message was on disk and invisible until the
next notmuch new, which here is a cron tick up to ten minutes away.
Measured immediately after a send: 65 files in the account's Sent folder,
64 messages indexed for the same path.
This is item 158's defect one path over. That item established the rule
for drafts, and MainWindow wires both halves of it, so the send path was
already telling the worker to DROP the draft's entry while never telling
it to add the sent copy's. The missing half is the one the user sees.
A sentCopyFiled signal, emitted only where the write succeeded, connected
to the worker's existing indexDraftFile. That slot is generic despite its
name: it calls notmuch_database_index_file, applies nothing
draft-specific, and its previousPath already defaults to empty, which is
right for a copy that replaces nothing.
The connection is a lambda whose context object is m_worker, and that is
load-bearing. indexDraftFile takes two arguments where the signal carries
one, so a direct slot connection does not compile; the context object is
what queues the call onto the worker's thread and keeps notmuch off the
GUI thread. Simplifying it to a plain call reads as tidier and would
cross that boundary, so the comment says so.
The test asserts on the signal, on the file existing, and on it being
inside the Sent folder. Indexing itself is already covered against a real
database in test_notmuchworker; what was unproven was that anything ever
called it for a sent copy. Announcing a path that was never written is
the ghost entry removeIndexedFile exists to undo, which is why the
existence check is there.
Indexing is not repainting: a Sent view already on screen does not gain
the row from this, and whether it should refresh after a send is left as
a separate question.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jq9gXquUo9W4KXDagJXMmn
|
|
Every autosave called MessageBuilder::build(), which generated a fresh
Message-ID unconditionally, so each revision of a draft was a different
message rather than a new version of one. The entry called this invisible
while the file is replaced correctly, and that turned out to be wrong:
mbsync uploads each revision to the drafts folder before the next save
removes the local file, so the server keeps one message per revision and
syncs them all back down. Measured on real mail as four independent
messages for a single reply, all four carrying a ,U= infix, threading
into the conversation and putting a draft tag on a Sent row. Deleting a
local file does not retract an uploaded one, which is why the local
cleanup, which is correct, could never fix it.
The user chose a stable id while drafting, discarded at send: the sent
copy is a different item from the draft, and the draft is deleted once
the message goes out, which the code already did.
Three links, none of which existed. OutgoingMessage::messageId is the
field, where empty means generate, so the send path is unchanged by
construction rather than by remembering to clear it.
MessageBuilder::build() uses a supplied id when there is one.
ComposeWindow::m_draftMessageId holds the identity between revisions,
assigned from built.messageId so the first save adopts the id GMime just
generated, and ComposeContext::draftMessageId carries it across a reopen,
read in forDraft() from ParsedMessage::messageId, which the parser
already provided and nothing had ever used.
Five tests, because the property spans three objects and a test at any
one of them passes while another link is broken. Two are the safety
constraint rather than the feature: a field defaulting to a fixed value
would satisfy the reuse test and make two sent messages share an id,
which is far worse than the defect this fixes.
One comment is corrected rather than left: the autosave's dirty check
justified comparing the message rather than the built bytes with "GMime
is given a fresh Date and Message-ID on every build". Half of that is no
longer true. The Date still is, so the conclusion stands.
Revisions already on the server are not touched by this; the four found
on real mail were deleted by hand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jq9gXquUo9W4KXDagJXMmn
|
|
Both views are lists of the user's own messages and are flat, but
walkThreads() emitted one ThreadSummary per THREAD and then picked a
single matched message to stand for it, breaking at the first one the
oldest-first walk reached. A conversation replied to twice therefore
produced one row: dated by the thread, opening the OLDER of the two
messages, with the newer one reachable nowhere in the view. Reported
against real mail, where a message sent at 12:42 was missing while the
row above it, dated 12:42, opened a message from three weeks earlier.
The same wrongly chosen message supplied firstMessagePath, so Delete or
Archive on such a row would have moved a file the user was not looking
at, silently, and mbsync would have carried it to the server. That half
was never visible.
The Sent branch now emits one summary per matched message, each carrying
its own id, tags, sender, path, date and subject. withRecipients still
selects the branch, so Sent and Drafts both get this and no second flag
can disagree with the flat-mode flag.
Ordering was a second defect under the same item, found by hand once the
rows appeared: notmuch_query_set_sort is a THREAD sort, so every row of a
thread inherits that thread's single position and an older reply drew
above a newer one. Sorting each thread's rows in place is not enough
either, since a message from another thread dated between them still
cannot land between them. Flat rows are collected and sorted as one list
before emitting.
ThreadListModel::rowKeyFor() is the second consequence and would have
broken quietly: two rows now share a threadId, and reconcile() keyed its
QHash on exactly that, so a sync would have dropped one of them by a
different route. It answers what makes a row unique, the message id in
flat mode and the thread id otherwise.
Tests cover both halves over new fixture threads F and G. The
cross-thread ordering assertion passed for the wrong reason at first,
because the existing fixture threads happen not to interleave; thread G
exists to break that and failed the moment it was added.
oldestFirstReversesTheOrder is corrected rather than satisfied:
OLDEST_FIRST orders threads by their oldest message while NEWEST_FIRST
orders by their newest, so the two lists mirror each other only while no
thread's date span contains another's, which this fixture is the first to
violate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jq9gXquUo9W4KXDagJXMmn
|
|
The action carried mail-mark-important, which Breeze and several other themes
draw as an exclamation mark rather than a star. The filter button for the same
tag has used `starred` since a15505d, where the comment records the user asking
for a star when item 57 renamed the action, and the two were allowed to differ
on the reasoning that a query-row icon reads as a category while an action icon
reads as a verb.
That reasoning held only while the action appeared beside its own label. Item
189 put it on the icon-only message bar, where the icon IS the control, and it
read as an info glyph. Both are `starred` now.
The label stays "Important" and the tag stays `flagged`; only the picture
changes. `starred` sits under status/ rather than actions/ in the icon spec,
which needs no fallback: an unresolved name already leaves the action with text
alone, and it resolves in the user's own theme, verified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NY6poqw199LfFaXe5BHKNe
|
|
Follows items 185 and 186, which established the pane's bar as where actions
on the displayed message live. Star and Archive are both selection-scoped and
fit that rule with nothing to decide; Archive leaves the main toolbar the way
Delete did, since the same icon in two places reads as two controls when the
toolbar is icon-only.
Ordered by what they do rather than by where they came from: answering the
message, then filing it, then destroying it, so the destructive button is not
between two that are not.
Mark all read deliberately stays on the main toolbar, at the user's decision.
It is the one action in this window that ignores the selection and acts on
every row in the view, so a bar whose every other entry acts on the one
displayed message is exactly where it must not be.
Item 140's toolbar test named archive as an example of a list-wide action.
That was never true of it, only untested, and this item reclassifies it: the
test now asserts archive LEFT the toolbar and keeps its guard on
mark_all_read, which is the action that genuinely is list-wide.
Closes item 189.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NY6poqw199LfFaXe5BHKNe
|
|
The pane's bar offered Reply and Forward on a message the user had thrown
away, which are the two things a trashed message is least likely to want,
while Restore and the purges lived only in menus.
The bar now has a third branch, asked before the draft one: a deleted draft
must come out of the trash before it can be edited. It is keyed on the
SELECTION being in a trash folder, the same predicate the menu entries use,
rather than on the trash VIEW, which disagree on mail reached from a search.
It carries Restore, Delete permanently and Empty trash, and only Restore is
tinted: the two purges are one act at two scopes and need no colour to tell
them from each other, only from the one action that gives mail back.
Delete moves here from the main toolbar in the same change (item 186). It
acts on the displayed message, like Reply and Forward, so it belongs on the
pane's bar by the rule items 139 to 141 settled for those two. It stays in
the Message menu and the context menu.
Delete permanently is new. It is Empty trash scoped to the selection, the
same purgeMessages() call with the ids resolved from the selection rather
than from a query, so it inherits both of that action's safeguards: it
confirms, naming the count, and it carries no default shortcut. One combined
thread:/id: query resolves a mixed selection, so a conversation and a reply
selected together still ask once.
The bar is refilled when the conversation digest arrives as well as on
selection, since a conversation's trash-ness is not known until every path
has been reported.
Closes items 185 and 186.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NY6poqw199LfFaXe5BHKNe
|
|
Item 125, the half that was genuinely missing.
Most of this item was already built and the row was stale. The exit-75
branch in onSyncFinished() predates this session and does what the entry
asks: the spinner clears, the skip is reported as neither success nor
failure, m_lastSyncFailed stays put, the log pane is not raised, the lock
latch is handed back to the external monitor, and the sync-on-exit case
has its own dialog. Item 174 then added the external half, a `skipped`
state a run the application did not start can be seen to have produced.
What nothing covered was the RE-ARM, and it is the symptom the item was
filed for. runAutoSync() re-arms when it declines to START, which is item
89 and covers a sync skipped before launching. A run that LAUNCHES, finds
the lock held and exits 75 reaches onSyncFinished() instead, and that
branch armed nothing: the edit stayed pending with nothing scheduled to
carry it, waiting for a manual sync or the next cron tick. That is "a
held edit waits for a completion that never comes".
scheduleAutoSync() in the skip branch. It re-checks the delay, the sync
command and the pending count on the way in, so it cannot arm a sync for
nothing, and against a long external run it re-arms once per debounce
interval until the lock clears.
This is the half the status file could not reach, and the distinction is
worth keeping: that file says what a run DID, this is what the
application does NEXT.
The test records a real pending edit first, since runAutoSync() correctly
declines when there is nothing to carry and a fixture without one would
arm nothing for a legitimate reason. It failed before the fix and is
mutation-checked.
Suite: 43 of 44, with undoMovesTheMessageBack failing as it does on
master (item 136).
|
|
Item 174, and half of item 125.
The premise was corrected before any code. The note asks for an external
`notmuch new` to clear the pending count; it must not. That count means
tag mutations not yet known to have reached the MAIL STORE, which is the
server: an edit is in notmuch the moment it is made, and what is
outstanding is mbsync pushing the renamed Maildir files. `notmuch new`
re-indexes local files and pushes nothing, so clearing on it would tell
the user their work was safe to quit on while it was still local. The
entry's own proposal to watch notmuch_database_get_revision() was
rejected for the same reason: a revision moves when mail ARRIVES too, and
in neither case does it say anything about the server.
What was actually wrong was the reporting channel. The application
inferred a finished run from an inode in /proc/locks and from grepping
the log for its RUN END banner, which made a human-readable line into
wire format and could not say WHICH channels a run carried. The local
sync path has always narrowed its clear to the accounts it carried; the
external path could not, and cleared everything, so an edit to an
account a run never touched was reported as delivered.
So the script reports instead of leaving evidence to be inferred. It
writes ~/.local/state/qtmaildir/syncstatus.json atomically at the end of
every run, including a skip, naming the channels, both exit statuses and
a state of ok, failed or skipped. MailSync::readStatus() reads it,
MainWindow prefers it over the log banner and narrows the clear through
Account::syncChannel(). A skipped run clears nothing, which is item 125's
first half: the application can now see that a run happened and carried
nothing. The log banner and lastRunOutcome() stay as the fallback for a
missing file, which is what a first run after upgrading looks like.
This is the user's own framing of the scope: the script was written for
another system and adapted, and is now qtmaildir's only consumer, so it
serves the application rather than the reverse. Two facts made it safe to
act on: their crontab runs mailsync.sh and nothing else touches mail, and
~/bin/mailsync.sh is a symlink into this repo, so an edit is live on the
next tick.
Two bugs found while wiring it in, both recorded in the closed item.
A test read the developer's real sync state, twice: a [sync] section
naming only `log` leaves syncStatus() defaulting to the real file, so two
tests asserting that a FAILED run leaves the count alone read the last
real cron run, found ok, and cleared. Pinning only `status` has the
mirror problem. noSyncTestReadsTheRealSyncState() is the guard, modelled
on noTestCanSeeTheRealLockTable().
And Qt::ISODate carries no milliseconds. The status file is preferred
only when it describes THIS run, compared against when the lock appeared,
so a stale success cannot outrank a fresh failure; but the script writes
date -Iseconds, and a round trip of "now" comes back 329 ms behind,
measured. A fast sync's own file therefore parsed as stale and fell back
to the log, with nothing failing to say so. One second of slack matches
the precision the format carries.
Design: docs/superpowers/specs/2026-08-29-sync-status-file-design.md
Suite: 43 of 44, with undoMovesTheMessageBack failing as it does on
master (item 136).
|
|
Item 182, found by hand: a thread of 9 messages with 5 unread, marked
read while a sync was running, reported "<subject>: mark as read" and
then reported the same work again when the sync finished. The user read
it as double reporting.
Not a double write, and the mail was correct. It is one action reported
twice because the FIRST report was the wrong one.
A sync holds notmuch's exclusive write lock and the worker's read-write
open blocks on it rather than failing, so an edit made during a sync is
held and sent when the lock frees. All three hold branches say exactly
that, in a label chosen deliberately: NOT transient, because it
describes state lasting until the sync ends, and a message that expired
would leave rows showing a tag the database has not got and no
explanation of why.
That label never survived. Every caller announced the action itself a
line later through showTransientStatus(), which overwrote it, so the
user was told the write had happened and the hold was never mentioned.
The flush at the end of the sync then reported the same work again and
read as a duplicate rather than as its completion.
announceAction() asks whether a sync holds the lock and, when one does,
sets a non-transient label naming the action AND the wait. The action is
still named because that announcement is what stands in for the
confirmation dialog this project rules out: it is how a user tells that
something larger than they meant has just happened, so the hold is added
to it rather than replacing it. The flush message is untouched and is
the only signal that held work actually landed, whose absence was item
106.
The test drives toggle_unread, the route the user took, and asserts both
halves: the text mentions the sync, and it still says what is waiting.
Asserting only the first would pass against an announcement that dropped
the action entirely. Mutation-checked by forcing the non-held branch,
which fails with the exact text the user reported.
The new string is translated, since one that misses the Italian ships as
English inside an otherwise Italian UI: lupdate found it with no context
warnings, lrelease reports 552 finished and 0 unfinished.
Suite: 42 of 43, with undoMovesTheMessageBack failing as it does on
master (item 136).
|
|
Item 181, from the user's notes: "the thread dashboard doesn't update
live with the modifications applied to the list pane. If I mark the
thread as read, the dash still reports N unread".
ThreadDashboard draws a ThreadDigest, which the worker builds from the
index and which reached the pane only when a conversation was selected.
A tag write updated the model optimistically and repainted the card
beside it, and nothing touched the digest, so the pane went on reporting
the unread count, the progress bar and the Waiting-for-you list the
conversation had when it was opened.
Reachable from the dashboard's own Mark all read button, which is the
worst version of it: the number sits directly above the button that
fails to move it.
refreshDashboardDigest() re-asks the worker for the digest of the
conversation on display, and returns at once when the pane is showing
anything else. It bumps m_digestGeneration like any other request, so
the guards in onThreadDigestLoaded() discard a reply that arrives after
the user has moved on. No placeholder digest, unlike the selection path:
the pane already holds this conversation, and blanking it to re-fill it
would flicker the whole dashboard for a change to one number.
Called from onTagsApplied(), where a write is CONFIRMED, and not from
the two write funnels. The first attempt put it beside the optimistic
model update by analogy with every other optimistic repaint, and that
analogy does not hold here: the digest is rebuilt from the index, so a
refresh queued beside the write reaches the worker before the write does
and answers from the state before it. The test failed identically to no
fix at all.
Every write rather than a chosen subset, at the user's decision:
narrowing it to the writes that change what the dashboard happens to
draw today is a list the dashboard can outgrow silently, and this costs
a round trip only while a conversation is on screen. Re-requested rather
than edited in place, because the digest is a derived summary and
recomputing it here would be a second place that has to agree with the
worker about what a write did.
The test is worker-backed over a real two-message conversation and is
driven through the mark_all_read action rather than the private funnel,
which is the path the dashboard's own button takes. It asserts the pane
carries the unread state before the write, so the assertion after it
means something.
Suite: 42 of 43, with undoMovesTheMessageBack failing as it does on
master (item 136).
|
|
Item 178. everySelectedRowIsInATrashFolder() read
ThreadSummary::firstMessagePath for any row that was not a message row.
That was correct while a thread row MEANT that message (item 108) and
stopped being correct when item 177 made it mean the conversation. A
conversation is in the trash only when ALL of its messages are, so a
partly trashed thread answered on whichever message the query returned
first: Delete could be hidden on a conversation that still had mail
outside the trash, and Restore offered on one that mostly did not.
Not data-affecting. Both actions are no-ops in the wrong direction:
Delete on already-trashed mail takes moveMessages()' already-there
branch, and Restore on mail that was never trashed finds nothing to
move.
qtmaildir cannot produce such a thread itself, since Delete is absent on
a reply row and Restore is thread-scoped. Two things outside it can:
another client trashing a single message, and a reply arriving after the
conversation was trashed.
ThreadDigest already walks every message of the selected conversation
for its sender counts, and a filename is served from the index like
everything else in it, so the paths ride along on a request the
selection already makes rather than costing a walk on every query.
ThreadDigest::messagePaths is relative to the mail root, for the reason
firstMessagePath records: an absolute path matches no account and
silently resolves every row to none. MainWindow keeps them beside the
dashboard's thread id and clears them when the dashboard is left, so a
late digest cannot answer about another row.
One limit, stated in the code rather than hidden. The digest is
requested only for a single selected conversation row, so that is the
only case with a real answer; any other selection falls back to the
summary's one path. That fallback IS the pre-177 answer and is wrong in
exactly the same partial case, which is the point: a multi-row selection
is left no worse than it was, rather than given a second, differently
wrong rule of its own. Making it exhaustive costs a per-query walk over
every message, which is what this avoids.
Two tests, both mutation-checked. The worker test puts its two messages
in different folders, since two in one folder answer identically
whichever way the code resolves them. The window test asserts both
directions, so a fix that simply hid Delete everywhere would fail it,
and sets totalCount explicitly: a summary left at the default is a
message row, and the test would otherwise exercise the other branch and
pass for the wrong reason.
Suite: 42 of 43, with undoMovesTheMessageBack failing as it does on
master (item 136).
|
|
The expander pill read "N replies" while the row stood for the
conversation: a thread of one message and four replies said "4 replies"
over rows that listed all five messages. The user's model is messages, so
it now reads "5 messages". A thread of one still shows nothing: its row is
the message, the pill is the expander, and there is nothing to open.
ReplyCountRole becomes MessageCountRole and CardLayout::Input::replyCount
becomes messageCount, so the names stop lying about what they carry. The
label is now translated under a CardLayout context, with Italian
"messaggio"/"messaggi" shipped; %n's untranslated fallback on this Qt does
not pluralise, so the two forms are separate entries. The card's densest
geometry test needs 460px rather than 400 now that the pill is one
character wider.
|
|
setThreadMessages() dropped nodes.first(), which was correct while a thread
row MEANT its first message: listing that message under itself would have
shown it twice. Item 177 made the row stand for the conversation and render
a dashboard instead, so the drop left the first message with no row
anywhere: the user reported the list starting at the second message with
the first unreachable.
A conversation row now keeps every message, including the first; a thread
of one keeps the old rule, since there it IS its message and must not be
listed beneath itself. The choice reads what actually ARRIVED rather than
summary.totalCount, which counts duplicates and can lie about whether a
thread really is a conversation.
Reply-scoped tests pointed at child 0, which was the first reply and is now
the first message; they read child 1 instead, and two asserted a child
count that grew by one. Two new model tests pin both halves, and both
directions are mutation-checked.
|
|
A thread row has no message to render, so the pane shows the conversation
instead. A thread of one message still opens its message on one click, and
the automatic mark-read is not armed for a row that displays nothing.
|
|
Senders, unread messages and an activity histogram for the dashboard, as a
plain value struct over a queued signal. Everything comes from the index, so
no message file is opened; the unread list is capped and unreadTotal carries
the real number.
|
|
Closes item 176. applyTags reports the messages whose tags really moved, and
a command stores that rather than what it asked for, so undoing a mark-read
no longer marks the whole conversation unread.
|
|
A widget over a ThreadDigest: header, tags, counts, the unread list capped
with a link to the rest, and an activity sparkline, scrolling under a pinned
action strip. It invents no colours.
ThreadDigest::unread is QVector<MessageNode> rather than QVector<MessageRef>.
The dashboard draws rows for messages it never opens, which is what
MessageNode exists for; MessageRef carries only what the pane needs once a
message is already open and has no subject, sender or date. The struct's
no-file-opening contract still holds, since all four of those fields are
served from the notmuch index.
|
|
Closes item 170 under item 177. A conversation belongs to a view while any
of its messages match, so reading one message of a thread no longer takes
the conversation out of the Unread view. The current row is never evicted,
and an automatic write defers its eviction until the selection moves.
|
|
Item 112 hid the toggle whenever the selection disagreed, because a union
is not a state and no honest label existed for it. That was affordable
because the "Whole thread" submenu sat beside it carrying two absolute
entries, which worked whatever the mix.
Item 177 deletes that submenu: the row decides the scope, so a second set
of actions is a second answer to a settled question. Hiding the toggle
then leaves the commonest conversation in the mailbox with no key at all.
The rule is a catch-all instead. Any unread message, a mixed conversation
included, reads "Mark thread as read" and marks every message read; only a
fully read selection reads "Mark thread as unread". Two presses therefore
reach either state from anywhere, which is what makes one key enough.
The write direction moves with the label. Computing it from
everySelectedRowHasTag() while the label promised "read" would mark a
mixed conversation unread, which is the item 112 report happening again
from the other end; the mutation putting that back fails the new test.
The three-valued selectionTagPresence() is unchanged and still asked, since
Every and Mixed differ for other callers. Only this label collapses them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEK9z5D3oa1nVmJ6xpQhBs
|
|
The five *_thread actions and their submenu are gone: the row's identity is
what decides the scope, so a second set of actions was a second answer to a
settled question. mark_thread_unread went with them, being the sixth entry in
the same submenu. tagSelected() loses its TagScope parameter, and
everySelectedRowHasTag() its own, so the direction and the write ask the same
question of the same object. ThreadListModel::scopeFor() and messageScopeFor()
are deleted; scopeForSelection() is the one resolver.
Labels name the scope. Archive, Delete, Restore, Spam, Important and the
unread toggle all say "thread" on a conversation row, and Delete, Restore and
Archive are ABSENT on a reply: a single reply cannot be removed from a
conversation.
Compose follows the same rule. Forward, Save, Reply-all and Reply without
quoting disappear on a conversation row, which shows no message to act on, and
Reply becomes "Reply to this thread": reply-all, quoting nothing, threaded off
the conversation's NEWEST message so the answer lands at its end rather than
forking the discussion at its opening post. That id is not in the model, since
an unexpanded conversation holds no nodes for its replies, so it comes from
resolveThreadMessages(); resolveQuery() states its newest-first sort rather
than inheriting notmuch's default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iDeN6C7y97nHYPvP6ST4L
|