aboutsummaryrefslogtreecommitdiffstats
path: root/src
AgeCommit message (Collapse)AuthorFilesLines
27 hoursfix: draw Important as a star on the message barDanilo M.1-3/+18
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
28 hoursfeat: put Star and Archive on the message bar tooDanilo M.1-8/+26
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
28 hoursfeat: give the trash its own actions on the message barDanilo M.5-20/+194
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
29 hoursfix: retry a sync that was skipped because another held the lockDanilo M.1-0/+17
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).
29 hoursfeat: have the sync script report what it didDanilo M.6-10/+276
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).
31 hoursfix: say an edit is waiting for the sync instead of claiming it landedDanilo M.2-3/+38
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).
31 hoursfix: refresh the conversation dashboard after a writeDanilo M.2-0/+49
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).
31 hoursfix: judge a conversation's trash state on all of its messagesDanilo M.4-20/+123
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).
44 hoursfeat: count a card's messages, not its repliesthread-row-identityDanilo M.8-39/+55
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.
45 hoursfix: list a conversation's first message under its rowDanilo M.1-14/+29
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.
46 hoursfeat: show the dashboard when a conversation is selectedDanilo M.5-1/+348
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.
46 hoursfeat: read a thread's digest from the indexDanilo M.2-0/+234
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.
46 hoursfix: undo only what the write actually changedDanilo M.3-33/+220
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.
47 hoursfeat: add the thread dashboardDanilo M.4-0/+690
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.
47 hoursfeat: judge a row's membership on the thread's unionDanilo M.4-0/+293
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.
47 hoursfeat: treat a mixed conversation's unread state as unreadDanilo M.1-22/+32
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
2 daysfeat: scope an action to the row it was invoked onDanilo M.6-415/+393
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
2 daysfeat: resolve a selection's scope from what each row isDanilo M.2-0/+45
One resolver replacing the scopeFor/messageScopeFor pair. The caller no longer chooses the scope, which is what let one gesture mean two things.
2 daysrefactor: remove the sibling chip tierDanilo M.4-111/+12
Nothing is a sibling any more: a conversation row draws the thread's tags and a message row draws its own.
2 daysfeat: draw a conversation row's own tags, in one tierDanilo M.4-165/+23
Items 110 and 111 reconciled a card that showed one message with a row that was a thread. The row is the conversation now, so the union is simply what it means: the first-message substitution, PillOwnCountRole and the seeded first node all go.
2 daysfeat: let a row say whether it is a conversationDanilo M.2-0/+31
One predicate for the question every scope, label and membership decision in item 177 keys on. It repeats hasChildren()'s rule deliberately: an expander and a conversation are the same fact, including that a loaded thread trusts its children over a count that included duplicates.
3 daysfeat: forward an HTML message with its formattingDanilo M.8-3/+698
Item 171. A forward carried only the plain-text version of the original, so formatting was lost; and an original with no plain-text part at all (30 of 342 sampled inbox messages, ~9%) forwarded as an empty quote with its content silently gone. A forward now sends ONE part chosen by the Send-as-HTML toggle: the original's markup when on, the text quote when off. Not a multipart/alternative, at the user's decision: a forward's shape is already decided by that toggle, and sending both hands the choice to the recipient's client. The toggle is honoured even for an HTML-only original, which then forwards as a text fallback. HtmlSanitiser strips remote content from the forwarded markup, checked by default with a per-forward opt-out. This is the security-critical part: the markup leaves this process and is rendered by the recipient's client, where none of MessageView's protections apply, so forwarding a tracking pixel forwards the tracking. It is an ALLOW-LIST, unlike HtmlBuilder::namespaceCids(), because a missed rewrite is a broken image while a missed strip is a beacon reaching the recipient. An HTML forward does not seed a text quote into the editor. The first build did, then subtracted it when building the HTML part, so the user could edit a quote whose edits were discarded; what the composer shows must be what gets sent. The forwarded message appears in a read-only pane beside the editor instead, a QSplitter at 60/40 with a toggle in the Format menu. A plain forward is unchanged. ComposeContextBuilder::quoteBody() renders htmlBody down to text when there is no plain part, so the plain path never emits an empty quote. Design in docs/superpowers/specs/2026-08-27-forward-html-design.md. Two tests repaired for the splitter: the 60/40 assertion reads stretch factors rather than pixels, since the offscreen platform gives the splitter no width and reports 49/49 whatever the code asks; and theComposerSplitsItsToolbarByScope looked for the body directly in the composer's column. Not yet hand-tested in this arrangement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtUzfNjMD8fiYfamDd3ywW
3 daysfix: flag a saved draft seen so it is not tagged unreadDanilo M.1-1/+8
DraftStore::write() was called with "D", and it uses the flag string verbatim, so every draft this application wrote landed as :2,D. With maildir.synchronize_flags on, notmuch tags any message lacking the S flag `unread`, and a draft the user authored is seen by definition. The symptom heals itself: the next sync of that folder round-trips the file, adds S, and the tag goes away. Only the newest draft in a folder that has not synced since shows it, which is why it read as intermittent and why measuring an older draft finds nothing wrong. TestComposeWindow::aSavedDraftIsFlaggedSeen() asserts both flags on the written filename, verified failing first against "D". TestMainWindow::anAutosaveWritesADraftAndClearsTheDirtyFlag() asserted endsWith(":2,D"), pinning the whole flag set where its own comment said the point was the draft flag "not left bare", so it failed against the corrected behaviour. It checks for D within the flag set now. Also reconciles the backlog with the user's notes: records the forwarded-HTML defect as item 171, closes item 169 (shipped last session, its row still read open and its section was still in the open file), and records this fix as item 172. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtUzfNjMD8fiYfamDd3ywW
4 daysfix: size the pending-changes dialog to its contentDanilo M.1-1/+11
380px of dialog for three rows left most of itself empty. The height is asked of the layout now, capped so a long list scrolls rather than growing past the screen and floored so a single row does not collapse it. A background role on the scroll viewport was tried in the same pass and reverted: the dialog renders semi-transparent under the developer's compositor, and painting a Base-coloured layer under the list made that worse rather than better. The transparency is the desktop's own doing, which is the trap this project has already recorded for window geometry. Not covered by a test. The offscreen platform returns an identical frame for a correct size and a broken one, so an assertion there would pass against both; CLAUDE.md records that measurement. Confirmed by hand instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P88Q3MCSCSQxKDy7pmXh9F
4 daysfeat: open the unsynced-changes count to see what it countsDanilo M.5-0/+302
Item 119, and item 146 which is the same request recorded again. The status bar's count answers "is my work safe to quit on" and could not say what the work was. The label opens a read-only list on a click. A QLabel has no clicked signal, so the press is taken by MainWindow's existing event filter rather than by replacing the label with a flat QToolButton, which would have brought the style's button metrics into a status bar the label already sits correctly in. The pointing-hand cursor is the affordance, since a status-bar label has room for nothing else. The layout is the user's own: a message appears once with its actions beneath it. PendingChangesDialog::rowsFor() does the grouping over a run of rows sharing an id, which the snapshot has already ordered, so the actions under one message keep the order they were made in. Read-only, deliberately. Retrying or discarding a change from here would be a new mutation path with its own undo question, and the count exists to be understood rather than edited. Three rules the tests pin, each of which is a way the list could disagree with the count it was opened from: - Grouping must not collapse: two actions on one message are two rows. - A thread row stays thread-scoped and reports how many messages it covered. - An id the index no longer holds still opens a run of its own, showing that its subject is unknown rather than folding its actions under the message above it. This is why the row carries startsMessage rather than inferring it from a non-empty subject. The queued call carrying QStringList, QList<bool> and QList<int> is covered by a test that drives it across a real thread, since a container whose metatype does not resolve is dropped at runtime and the slot runs with a default. Both survive on Qt 6.11; the test is what says so, and what would fail if that changed. Italian ships with it: five new strings, lupdate clean, lrelease 522 finished and 0 unfinished. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P88Q3MCSCSQxKDy7pmXh9F
4 daysfeat: resolve pending-change ids to subjectsDanilo M.2-0/+104
Item 119, second half of the data: the step that turns the snapshot's ids into something worth showing. resolvePendingSubjects() takes the rows' ids in order, each flagged as a thread id or a message id, and answers positionally: one subject per input, plus the thread's message total for a thread id and -1 for a message id. Positional rather than set-based, and that is load-bearing. The caller has already decided what its rows are and in what order, and one id can legitimately appear on several rows: a message with two outstanding actions is two rows carrying one id. A combined query returns a set, which loses both the order and the duplicate, so the walk is one lookup per row instead. The cost is bounded by what the user did by hand since the last sync, which is not a query-sized number. A missing id answers with an EMPTY subject rather than being dropped. The dialog still shows that row, because the count the user clicked has to equal the list they are shown, and dropping a row breaks that agreement in exactly the case where the user is most likely to notice. An index that cannot be opened answers the same way, one empty subject per row, so the list still shows the changes with only the subjects missing. The thread count is taken at snapshot time and says so: a held thread edit applies when the sync ends, and a reply arriving in between makes the real number larger. The row describes what the user is looking at, not what the write will touch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P88Q3MCSCSQxKDy7pmXh9F
4 daysfeat: snapshot the pending changes as rowsDanilo M.3-9/+130
Item 119, first half: the data the list behind the unsynced-changes count is built from, with no dialog and no worker, so the rules it has to follow are testable on their own. pendingChangeSnapshot() gathers the three queues the count sums into PendingChange rows. Two properties are the whole point. Scope follows the ACTION, not the storage. A held thread edit stays one thread row, because a `*_thread` action made it and reporting its messages instead would claim the user acted on each one; a netted tag edit and a held move are message rows. The queues already encode that distinction, so nothing is expanded and nothing is escalated. The rows are grouped by id, so a message with several outstanding actions appears once with its actions beneath it, which is the layout the user asked for. The sort is stable, so those actions keep the order they were made in; QHash has none of its own, and without it the list would reshuffle between openings. A snapshot, taken once and frozen. Subjects are empty here and filled by the resolve step to come. m_pendingTagEdits gains the action name beside the direction it already kept. The direction alone was enough to count with; a list has to say what each change was, and only the action that made it knows. It is carried from TagChange::description rather than derived from the tag, so there is no second table of tag names to labels to drift from the first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P88Q3MCSCSQxKDy7pmXh9F
4 daysrefactor: drop the unnettable pending-edit counterDanilo M.2-17/+9
Item 119's stated blocker, removed by finding out what it held: nothing. pendingEditCount() summed four sources, three of which can name the messages they hold and one of which was a bare int. That int counted confirmed changes carrying no message ids, on the reasoning that an edit which cannot be netted must still register rather than be lost. It was what made the count impossible to open and list, since a dialog would have shown three groups and then owed the user a remainder it could not describe. The remainder is empty. NotmuchWorker::applyTags() is the only emitter of tagsApplied(), and its first statement returns on an empty id list, which is the exact condition the counter required. applyTagsToThreads() resolves threads to message ids through a query and errors out when that comes back empty, so it can only ever hand applyTags() a non-empty list. Measured rather than read. A qFatal in the branch fired in 4 of 70 test_mainwindow cases, all four building a TagChange by hand and invoking the slot directly with no worker involved; an assertion before the worker's own emit never fired across the whole suite, worker-backed tests included. The worker's guard stays and is pinned where it lives, by applyTagsWithNoIdsDoesNothing() in test_notmuchworker. The MainWindow test that asserted the deleted branch is replaced by one for the consequence: a change reaching the indicator names its messages, and an edit with its inverse nets back to nothing, which is the property a growing-only counter could never have. Three tests that leaned on the counter to show the indicator now carry message ids, as a real edit always does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P88Q3MCSCSQxKDy7pmXh9F
4 daysfix: give a restored message its inbox tag backDanilo M.1-1/+12
Restore moved the file back to the inbox folder and left it invisible: the message carried no `inbox` tag, so the Inbox view could not see it, and the user reported restoring a message and losing it. Delete strips `inbox` so a deleted message leaves that view, which makes restoring it the other half of the same change. restoreResolvedMessages() already meant to add the tag back, and the comment above the branch describes exactly this failure, but the comparison deciding it read `origin`, which four lines earlier had been reassigned from the bare folder name to the finished tag. `deleted-from:Inbox` never equals `Inbox` however an account spells its inbox, so the branch was dead and the tag never came back. The destination folder is taken from the move's own key instead, which is what the surrounding code already builds and what the comment says is being compared. The existing test passed against this throughout. It asserted the file moved, the origin tag came off and `deleted` came off, all of which were true; nothing asserted the tag that decides whether the user can see the message afterwards. It does now, and fails against the old comparison. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P88Q3MCSCSQxKDy7pmXh9F
4 daysfix: correct the avatar initials, the two-tone fill and the fadeDanilo M.6-51/+166
Hand-testing item 169 found four defects, three of them visible on every card. The initials were taken from whatever the card's first line held, which is the raw From header on a reply row and notmuch's comma-joined author summary on a thread row. A naive space split therefore gave `T<` for `tsujan <notifications@github.com>` and one letter each from two different people for `Standreas, tsujan`, and a separator counted as a word, so `INE - Expert IT Training` drew `I-`. Avatar::initialsFor() now normalises first: the angle-addr and any quoting go, a comma takes the first entry unless the name is quoted, a bare address is not a name, and a word has to carry a letter or a digit. Avatar::fillFor() uses the same normalisation, so an address in the name's place no longer reads as a person. The two-tone fill built its gradient axis as a radius from the centre, so the 0.5 colour stop landed on the squircle's edge and one hue filled almost the whole face. The axis spans the diameter now. The fade ran left to right, which put its hard stop at 60% of the card and read as a slab rather than a wash. It runs right to left: opaque at the card's right edge, where the only hard stop is the card's own boundary, and gone before it reaches the accent bar that already states the account. And the flat views hashed the user's own address on every row, so every Sent and Drafts card shared one pattern. ThreadSummary::firstMessageRecipient rides the recipient fold, which already parses the To header, and SenderAddressRole prefers it, falling back to the sender when there is no usable To. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P88Q3MCSCSQxKDy7pmXh9F
4 daysfix: harden candidate appends and cover the message-row sender rolesDanilo M.1-2/+11
4 daysfeat: propose business senders from newly synced mailDanilo M.3-0/+86
4 daysfeat: load the business-senders list at startupDanilo M.2-1/+65
4 daysfeat: draw a sender's avatar on every cardDanilo M.2-0/+61
4 daysfeat: fade the account colour across a cardDanilo M.2-0/+46
4 daysfeat: expose a row's sender to the delegateDanilo M.4-0/+34
4 daysfeat: reserve a card's avatar gutterDanilo M.2-0/+20
4 daysfeat: propose business-sender candidates, always commented outDanilo M.2-0/+111
4 daysfeat: read the business-senders listDanilo M.3-0/+137
4 daysfeat: paint the avatar squircle from a hashed seedDanilo M.1-0/+78
4 daysfeat: choose an avatar fill and derive its colourDanilo M.1-0/+22
4 daysfeat: derive a sender's avatar initialsDanilo M.3-0/+156
4 daysfeat: carry the first message's sender address on a thread summaryDanilo M.2-0/+78
4 daysfeat: flag what you answered, mark what was forwarded to youDanilo M.16-14/+514
Item 68, which turned out to be three things once its premise was measured. The note asked to extend a "passed" subject rule to "Fw:"; there was no subject rule, and the correlation it rested on did not exist. What did exist was a gap nobody had reported. Reply and forward now flag their source. The Maildir R and P flags, which every other client sets and notmuch reads back as "replied" and "passed", had never been written here: measured on the developer's index, all 317 "replied" and all 6 "passed" came from other clients. ComposeWindow emits sourceMessageAnswered after a successful send and MainWindow routes it through sendMessageTagChange, message-scoped and off the undo stack, for the reason auto mark-read is: the flag records that the mail went, and the send cannot be undone. ComposeContext carries sourceMessageId rather than reusing inReplyTo, which is deliberately empty on a forward so the recipient's client does not file it under the thread it left. Keying on it made the "passed" half dead code that compiled and never fired. A resumed draft is excluded: its kind records how the file was opened, not what the user is doing, so flagging on it would set R from a guess. A received forward gets its own mark. Derived from the subject at paint time, storing nothing and reaching no server, because "passed" means "I forwarded this" and setting it from a guess would assert something false on 222 existing messages. subjectIsForwarded() shares forwardSubject()'s prefix table so the two cannot disagree, strips a Re: chain first, and takes extra locale spellings from [general] forward_prefixes, which extends the built-in table rather than replacing it. A mutation survived the first round and corrected a claim in the code: QRegularExpression::escape already makes a punctuation prefix inert, so the word guard is not about pattern validity. It stops a configured "-" matching "-: x". The comment and test say that now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXCZFLXbAii5n5wtovpdhh
5 daysfix: offer Delete and Restore only where they mean somethingDanilo M.2-3/+89
Item 168, found by the user while hand-testing 118: Delete could be triggered on a message already in the trash. Not dangerous, which is how it survived. moveMessages() finds the file already in the destination and takes its early-return branch, so the message is reported as moved, an unsynced change is counted, and nothing happened. Restore had the mirror of the same problem, added unconditionally to both menus and so offered on mail that was never deleted. Each is now hidden where it has no meaning, which is the rule item 112 established for the unread entry. The question is about the PATH, never the deleted tag: a message trashed by another client carries no such tag, which is why the trash view is path-based, and asking the tag would hide Delete on exactly the mail a trash view is full of. Delete also removes unread now, at the user's request on the same tangent. It travels inside the same sendMove() call rather than as a second write, so one undo returns the folder and the tag together. This rewrites the Maildir filename, because maildir.synchronize_flags is true, and so reaches the server: the same mechanism the post-new hook refuses to touch, and the difference is that the hook acts unattended on arriving mail while this is an explicit gesture on a message in front of the user. A mutation survived the first round and found a real hole: comparing the prefix without its trailing separator passed every test, because no fixture had a folder whose name starts with the trash folder's. Under it Delete silently vanished from mail in acct/trash-old, which is not the trash. The fixture carries that row now and all three properties are mutation-checked. The suite is 37 of 38, the failure being item 136 on an unrelated path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HFuRPtzFrSxCQjFk6tq7gD
5 daysfeat: empty the trash, the one action that asks firstDanilo M.5-0/+256
Item 118, unblocked by 103. Message > Empty trash..., scoped to the account selector, with no default shortcut. purgeMessages() is a separate worker entry point from moveMessages() rather than a flag on it, because the two look alike and only one can be undone. It takes named ids, never a folder sweep, so the blast radius is what the dialog enumerated and the user confirmed, and it deletes every file of a message: notmuch deduplicates by Message-ID, so leaving one behind leaves the message alive in the folder the user emptied. It confirms, naming the count and the account, defaulting to Cancel. That breaks CLAUDE.md's no-confirmation rule deliberately and the rule now records it as its single exception, in the same paragraph: a purge has no inverse to push onto the undo stack, so the protection the rule provides has to come from somewhere, and the dialog is where. Two defects found rather than reasoned. The count claimed messages whose files were already gone, overstating an irreversible action; an absent file is correctly not an error, but that is not the same as destroyed. And the user's hand test found the list still showing mail that no longer existed: a purge removes rows rather than changing them, so there is no optimistic update to apply and nothing was connected to messagesPurged at all. It re-runs the current query now. Verified against the live index after the user emptied one real account's trash: zero files on disk, zero in the index. The suite is 37 of 38, the failure being item 136 on an unrelated path. Ten new strings translated, lrelease reports 0 unfinished. Item 168 is filed from the same hand test, on Delete being offered on mail already in the trash. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HFuRPtzFrSxCQjFk6tq7gD
5 daysfeat: say which way the unread action will go, and hide it when it cannotDanilo M.3-18/+111
Item 112, and 99 and 147 with it: the user's note is one design across all three. A union is not a state. ThreadSummary::tags is notmuch's union over the conversation, so a thread holding even one unread message answered "unread" and the thread toggle always chose "mark read". There was no input that reached "mark thread unread" on a mixed thread, which is the thread a user wants it for. The thread toggle becomes two absolute actions, mark_thread_read and mark_thread_unread. Neither takes a default chord, at the user's choice: Ctrl+Alt+U meant whichever direction the union picked, and since item 132 a shortcut is a chosen subset rather than a requirement. It is now unbound. The message-scoped toggle stays a toggle, because one message has a real two-valued state, and its label now names the direction it will go. On a selection with no single state the entry is hidden rather than labelled wrongly, chosen over disabling it; the thread submenu is the route then, and its entries are absolute. selectionTagPresence() is the three-valued predicate that needed to exist. everySelectedRowHasTag() delegates to it and keeps its two-valued answer, which is all a direction needs; a label needs the third value. The refresh is keyed on the model's dataChanged as well as on the selection, so a write moves the label without reselecting and none of the six optimistic-update call sites has to remember. Three mutations fail: restoring the union predicate reports the user's original symptom, showing the action on a mixed selection, and dropping the dataChanged refresh. The suite is 37 of 38, the failure being item 136 on an unrelated path. Four new strings translated, lrelease reports 0 unfinished. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HFuRPtzFrSxCQjFk6tq7gD
5 daysfeat: number each build of a dev treeDanilo M.5-4/+33
The version alone cannot tell one build of an unreleased X.Y.Z from another, and the user rebuilds and hand-tests unreleased builds daily. They chose a counter over a git description: what they want to know is that the binary is newer than the one they were running, not which commit it came from. QTMAILDIR_BUILD_NUMBER is a cmake option, ON by default, that runs cmake/BuildNumber.cmake as a build step to increment a counter and write buildnumber.h. It had to be a build step: configure_file runs once per cmake run, so a counter interpolated into version.h.in would sit still across exactly the rebuilds this exists to distinguish, which is why version.h.in includes a second generated header rather than carrying the number itself. Two macros, and the split is load-bearing. QTMAILDIR_VERSION stays a clean X.Y.Z and keeps the window title, applicationVersion and anything that might ever compare versions; QTMAILDIR_VERSION_DISPLAY carries the number and goes to the three surfaces the user picked, --version and --help, the About dialog, and the placeholder pane. The window title was offered and declined, since the number would then be in every screenshot. The counter lives in the build directory and is not tracked, so it cannot conflict on a pull or leave the tree dirty; a fresh build directory restarts at 1, which is honest, because it is a different build tree. A release passes -DQTMAILDIR_BUILD_NUMBER=OFF and the header is written empty. The SlackBuild in the my-slackbuilds repo needs that flag and is a separate commit there. Verified by running it, since none of this is reachable from a C++ test: three consecutive builds reported build 2, 3 and 4, and a separate Release configure with the option OFF reported a clean 0.27.0. Passing the flag to a tree that does not have the option yet is an unused-cli warning and exit 0, so the SlackBuild change is safe before 0.28.0 ships. The suite is 37 of 38, the one failure being item 136 on an unrelated path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HFuRPtzFrSxCQjFk6tq7gD
5 daysfix(worker): reopen the notmuch handle so queries see new mailDanilo M.1-1/+21
A read-only notmuch handle is a Xapian snapshot taken when it is opened, so it never observes a write made by another process afterwards. The worker opened one handle and kept it for the process lifetime, which made the sync script's `notmuch new` invisible: every query after startup was answered from the index as it stood when the application launched. The symptom was mail arriving while the window was open and not appearing until a restart. It was not confined to the post-sync refresh, which is what made it hard to place: a query typed by hand also found nothing, since it hits the same handle. Tag writes were unaffected throughout, because applyTags opens its own read-write handle per call. Reopen in openReadOnly() rather than at each call site: every read path begins by asking for the handle. A reopen failure is deliberately not fatal, since the existing handle is still usable and answering from a slightly stale index beats refusing to answer. The suite could not reproduce this before: the test helper builds a fresh worker per query, so it opens a fresh handle every time. The new test holds one worker across two queries and indexes between them from a second process. Item 104.
5 daysfix(compose): resolve a path a sync renamed, at all three read sitesDanilo M.4-6/+126
Item 163. mbsync renames an uploaded file to add its `,U=<uid>` infix, and the model's `MessageRef::filePath` was captured when the query ran, so a row loaded before that sync names a file that no longer exists. MimeParser then honestly reports a message it cannot open. MaildirName::resolveRenamed() answers the filesystem question: returns the path unchanged when it still exists, otherwise looks in that one directory for the file whose unique stem matches. mbsync preserves the stem (`<stem>:2,D` becomes `<stem>,U=5:2,D`), which is what makes this safe to do by filename at all. It never recurses, never crosses a folder boundary, and refuses an ambiguous match rather than guessing, since opening or moving the wrong message is worse than reporting none. It lives in MaildirName because that namespace already owns the `,U=` infix and is a pure-value unit testable without a widget. A file that changed FOLDERS is a different question that only the message id can answer, and NotmuchWorker::moveMessages() re-resolves that way already. Three call sites, all of which held a stale path: - The message pane, which reported "(unreadable message)" over a file that was on disk and readable. Cosmetic and self-repairing. - Reply and Forward, refused outright, so the user could not answer a message that was sitting there. - The draft reopen, and this is the half that costs data. The refusal happens BEFORE any composer exists, so the user composes again into a fresh window whose autosave has no previous path to unlink. The old revision survives, each save mints a new Message-ID, and both files reach the server. The unlink machinery was correct throughout and never ran. forDraft() seeds draftPath from the RESOLVED path, never the caller's: seeding the stale one would let the reopen succeed and the unlink still miss, which is the same fork arriving one step later. Covered by five unit tests on the resolver, including the two that keep it honest (a genuinely missing file yields nothing, and a neighbouring message is never matched), and by an integration test that renames the draft the way mbsync does and asserts the file COUNT, which is the shape the fork actually takes. Both mutation-checked; the integration test fails with the reported symptom when the resolution is removed. The stable-Message-ID question is deliberately untouched: it is what turns a stale path into two server-side messages rather than one replaced file, and it wants its own item. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8