aboutsummaryrefslogtreecommitdiffstats
path: root/src/mainwindow.h
AgeCommit message (Collapse)AuthorFilesLines
4 daysfeat(compose): register the six compose actions, item 123Danilo M.1-0/+21
Handlers are empty for now; this commit is the registration, so the three coverage tests guard every later task rather than being satisfied at the end. Two corrections to the spec, both found in the code rather than assumed. It calls for a new top-level Message menu and one already exists, so these join it; two menus named Message would be a defect. And it says every action needs a binding, which item 132 changed while this was being planned: save_message ships with no chord, since it is the rarely-used escape hatch and menu reachability is now the rule that must hold. reply_no_quote shares reply's icon and is added to the no-duplicate-icons exception list for the same reason the five thread actions are: it never reaches the toolbar, and a menu entry always carries its text. That list is renamed menuOnlySharedIconActions, after the property that earns the exemption rather than the tier that first needed it. Bindings are provisional. The user intends to rework them, and Ctrl+Alt+R for reply_no_quote is an imperfect fit since that tier elsewhere means a wider scope rather than a variant. The six labels went through a mnemonic pass that nothing enforced before. Four of them collided inside the Message menu on first writing, and the whole class was invisible to a green suite: Qt does not error on a duplicate mnemonic, it cycles the highlight instead of activating, so the key simply stops working. Item 57 had already decided this rule by rejecting a label that would have collided, but it lived in prose and in one test's comment, which is precisely why it was broken again here. noMenuHasTwoEntriesSharingAMnemonic() enforces it now, scoped per menu since a mnemonic resolves among the open menu's entries, and keyed on QKeySequence::mnemonic() rather than on parsing & by hand, because && is a literal ampersand and only Qt answers which key it will dispatch. Three pre-existing collisions are a named freeze list rather than a silent fix or a narrowed test: Alt+R three ways and Alt+S twice in Message, Alt+O in View. Renaming entries a user has had in their fingers since 0.1.0 belongs to the shortcuts rework, and the freeze is written as exact groups so a new entry joining any of them still fails. Two of the test's own design choices came from mutation checks that failed for the right reason while reporting the wrong thing. Reporting collisions as pairs was order-dependent, so a new colliding entry re-keyed a frozen pair and the fresh defect read as "a frozen collision no longer happens"; matching frozen entries by whole string broke the same way, since a growing group stopped matching its frozen text. It reports whole groups and matches on menu plus key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FXF741wz4SY7j5dqvAxMU5
5 daysrefactor(ui): extract the busy indicator into a widget, item 134Danilo M.1-2/+2
The status bar's sync bar was a bare QProgressBar configured inline in MainWindow, and item 123's send popup needs the same thing again. It also needs the half MainWindow does not use: the popup drains a determinate bar through its cancellable countdown and switches THE SAME widget to indeterminate when send_command starts and the duration stops being knowable. Building that inline a second time is what this removes. BusyIndicator carries both modes. setBusy() resets the range as well as the visibility, so the switch out of the countdown cannot leave the bar drawing its last fraction, and setProgress() treats a total of zero as busy rather than passing it through: setRange(0, 0) IS the indeterminate range, so a zero total would otherwise hand the caller an animating bar while it believed it had drawn an empty one. Only the bar is extracted, not the status label the backlog row mentions beside it. m_statusLabel has 34 uses across MainWindow for transient messages, selection counts and sync phases; it belongs to the window rather than to the indicator, and the send popup owns its own phase text. The hidden-on-construction test needs a shown parent, which cost a mutation to find. Measured against a standalone Qt program: a parentless widget reports isVisible() false and isHidden() true whether or not hide() was ever called, so both obvious assertions passed against a constructor with the hide() deleted. What differs is WA_WState_ExplicitShowHide, and the behaviour it produces appears only once a parent is shown, which is how the status bar holds this widget. All five mutations checked and killed: the zero-total guard, the range reset in setBusy(), the value clamp, the show() in setProgress() and the hide() in the constructor.
6 daysfeat: find mail tagged deleted but never moved to trashDanilo M.1-0/+11
Every version before item 103 tagged a message `deleted` and left its file exactly where it was, so deleted mail accumulated in the inboxes with only a chip to say otherwise. `Find stranded deleted mail` runs the query that finds it: tagged `deleted`, and not inside any configured trash folder. It reports and moves nothing. Acting on its own would be a bulk delete with no selection behind it, and the user asked for something they could come back to and review. Repeatable rather than a startup migration, for the same reason: mail reaches this state again whenever another client tags without moving. A menu entry only, at the user's request, so it cannot be confused with the Trash filter beside the other four. Also adds everyActionIsReachableFromAMenu(), which asserts the fifth registration site nothing enforced. CLAUDE.md documents four places; a menu is the fifth, and `restore` shipped on this branch reachable by a chord and by nothing a user could see. The new test found three more of the same: open_thread, clear_pane and clear_selection were all keyboard-only. All three now sit in the View menu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7 daysfeat(delete): bind Del, and resolve a restore against the databaseDanilo M.1-0/+19
Del is the key a user reaches for and Ctrl+D is not a guess anyone makes. Both are bound; Del is listed FIRST because that is the one the menus advertise. Bare, which is safe here for a reason that does not generalise to other bare keys. A QAction shortcut is dispatched before the focused widget sees the key, and Qt withholds only plain LETTERS from editable widgets, so by the argument that made bare Return break the query bar this should delete mail while the user edits a query. It does not: QLineEdit accepts the ShortcutOverride for Delete itself, because Delete is one of its own editing keys, which Return is not. Measured with and without an explicit filter, the action fires 0 times either way, so no filter is added. theDeleteKeyEditsTextInTheQueryBar() pins that Qt behaviour, since the binding rests on it. **Two defects surfaced from the second binding, both real.** An action can now have more than one default, and KeyMap did not allow for it. sequenceFor() decided "is this a built-in?" by comparing against defaultSequenceFor(), which returns only the FIRST default, so the second looked like a user override and won the "a user binding beats the default" rule. The menus advertised Ctrl+D to a user who had configured nothing, and sequenceFor() and defaultSequenceFor() disagreed about an untouched action. isDefaultBinding() asks whether a sequence is ANY of the action's defaults; when two defaults tie, the one defaultBindings() lists first wins, which is the author's stated preference rather than an alphabetical accident. And Restore read each message's origin tag FROM THE MODEL. The model's tags come from the query, so a row whose delete has not been re-queried still carries its pre-delete tags: measured `[inbox,unread]` on a message already sitting in the trash, one run in three. No origin tag was found, the message took the no-origin branch, and Restore moved it to the INBOX instead of the folder it came from, silently, with the origin tag left behind as the only evidence. A restore has to be right about the destination or it is worse than doing nothing. The trash-view Restore now resolves its messages against the DATABASE first, through a new NotmuchWorker::resolveMessages(). That and resolveThreadMessages() share one walk, resolveQuery(), rather than growing a near-duplicate: they differ only in whether the terms are `id:` or `thread:`. restoreSelectedThreads() already worked this way; this is the same reasoning applied to the message-scoped path. The flake was found by running one test five times rather than trusting a single green, and the fix verified the same way: 5 of 5, then the full suite three times over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7 daysfeat(trash): restore mail from the trash viewDanilo M.1-1/+22
Task 6. Delete moved mail into the trash and the only ways back out were a second press of Delete or Ctrl+Z, both of which act on a row the user has to have deleted in this session. Browsing the trash and putting something back needed an action of its own. `restore` is enabled from the QUERY, not from the selection's tags. The trash view is path-based precisely so that mail trashed by another client appears in it, and such a message carries no tag of ours: deciding from `tag:deleted` would disable Restore on exactly the messages that most need it. isShowingTrash() compares the current query against the trash generator's own, for both the per-account and the all-accounts scope, so it follows the account dropdown like every other filter. A message with NO origin tag is the foreign-trashed case, and it is why this is not simply restoreSelected() under a new name. The two callers want opposite things from a missing origin, which `fallbackToInbox` selects. From the trash view the message is demonstrably in the trash and refusing to move it leaves the user looking at mail they cannot get out, so it goes to the inbox and the status bar says so. From a second press of Delete the message is not in the trash at all and merely wears a stale `deleted` tag from an older version or a hand-written notmuch command; moving that to the inbox would relocate mail the user never asked to move, so the tag comes off and the file stays put. The inbox FOLDER is a new optional per-account `inbox` key, defaulting to "Inbox". It is configurable rather than hardcoded because the name is not ours to assume: naming a folder that does not exist CREATES it, beside the real one, and under mbsync's `Create Both` that folder reaches the mail server. That is not hypothetical, it is what a truncated origin folder did to real mail while this branch was being tested. Unlike `trash` the key is optional, since the default is right for any ordinary Maildir and a wrong value here only affects the fallback. Ctrl+R, which was free. The action is only enabled in the trash view, so the key is inert elsewhere rather than doing something surprising. It sits in the Message menu beside Delete and in the thread context menu, greyed outside the trash rather than hidden: an action that vanishes teaches nothing, while a disabled entry with its shortcut beside it says both that it exists and where it applies. **Adding an action is FIVE places, not four.** knownActions(), defaultBindings() and the icon table are each enforced by a test that fails loudly, and being REACHABLE is a fifth that nothing checked: this shipped registered, bound, iconned, correctly enabled, and present in no menu at all, which a green suite reported as complete. Ctrl+R is not a shortcut anyone guesses, so it was effectively invisible. restoreIsReachableWithoutTheKeyboard() closes that, and deliberately excludes the context menu from its menu-bar assertion, since findChildren returns both and one check would otherwise satisfy the other. Four tests, each mutation-checked. Two worth keeping: the hardcoded "Inbox" mutation fails against the fixture's lowercase folders exactly as it would against a Maildir that spells its inbox differently, and the reachability mutation reproduces the keyboard-only state this shipped in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7 daysfix(delete): repair seven defects in the move-to-trash pathDanilo M.1-5/+80
Item 103's implementation was committed unreviewed and never hand-tested. Reviewing it, and then hand-testing it against real mail, found seven defects. Six of them lose or corrupt state and none was caught by the suite, which was green throughout. **Undo pushed a command instead of consuming one.** onMessagesMoved() pushed a MoveCommand for every confirmed move, including the move an undo had just made, so undoText went "Delete", "Undo Delete", "Undo Undo Delete". A second press of undo re-deleted the message the first had rescued. PendingMove carries a fromUndo flag, which has to survive the queued round trip and so cannot be a window-wide "am I undoing" flag. **Held moves were invisible to the quit guard.** pendingEditCount() summed the held tag edits and not the held moves, so a Delete pressed during a sync left the count at zero: the indicator stayed hidden and closeEvent()'s guard never fired, discarding the move on quit with no prompt. That is item 106's data loss with a worse shape, because a dropped move leaves the file in the folder the user asked it out of. **Two moves to one folder dropped the second's tags.** m_pendingMoves was keyed on the destination, so two Deletes in one account before the first confirmation both named `acct/Trash` and the second insert overwrote the first. That file reached the trash carrying neither `deleted` nor `deleted-from:`, unrestorable and invisible to a `tag:deleted` query. It is a FIFO now: the worker moves one batch at a time and emits in request order, so position alone matches a confirmation to its request. **Second Delete left the origin tag behind.** The restore passed the origin PLACEHOLDER in its removal list, and onMessagesMoved() resolves that from the folder the worker reports, which on a restore is the trash. It asked to remove `deleted-from:Trash`, a tag never written, while the real `deleted-from:inbox` was never named. A restore does not need the placeholder: it already read the origin to decide where to send the file. originTagFor() is now the one derivation both sides use. **Ctrl+Z left it behind too**, for a different reason: MoveCommand was constructed with the unresolved pending.add. The command carries the resolved tags now, and is pushed per origin group rather than once per batch, because the placeholder resolves to a different tag per origin. **A thread root re-deleted itself.** everySelectedRowHasTag() asked a thread row about its THREAD's tags, which notmuch gives as a union. Delete the root of a three-message thread and the replies are untouched, so the union carries no `deleted` and a second press ran Delete again: the message moved trash-to-trash and came out with `deleted`, `deleted-from:inbox` AND `deleted-from:Trash`, with no way back. The union was a documented approximation, called bounded because the worst case for a TAG toggle was re-applying a tag the message already had. A MOVE re-applies the move. Resolved through messageById(), NOT through ThreadSummary::firstMessageTags, which is the value the query delivered and is never refreshed by an optimistic update: after a delete the node reads `deleted` while the summary still reads `unread`. **Delete thread never moved anything.** It was left calling tagSelected() when Delete became a move, so a whole conversation sat in the inbox wearing a `deleted` chip. It moves every message now, each with its own origin, so a thread spanning folders reassembles on restore. A reply row resolves to its own thread through selectedThreadIds(): scopeFor() reports a reply under messageIds and leaves threadIds empty, which made a thread action on a reply row do nothing at all. **And the root card did not repaint** until it was clicked, while its replies did. sendMove() had no optimistic update at all, so nothing moved until the worker answered; and applyMessageTagChange() deliberately leaves a multi-message thread's SUMMARY alone, which is correct for a one-message edit and wrong for a thread-scoped one. The replies have nodes and repainted; the root card reads the summary. The thread paths repaint synchronously with applyTagChange() before the worker is asked, which also keeps the toggle's direction readable for the next press. Every fix carries a test and every test was mutation-checked. Three false greens were found while writing them and are recorded at their assertions: a disjunction that emptied on the wrong term, a QTRY_VERIFY(rowCount() == 0) satisfied by the interval before the worker answers, and a query issued before the confirming write had landed. Absence is asked of notmuch directly through a new notmuchCount() helper for that reason. Two bare-window tests moved off assertions about synchronous pending writes onto the model, since the thread actions now round-trip through the worker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8 daysfeat(delete): move the message to the account's trash folderDanilo M.1-0/+139
Delete added the `deleted` tag and moved nothing, so deleted mail sat in the inbox indefinitely with only a chip saying otherwise. It now moves the file into the account's trash, records where it came from, and moves it back on undo. The origin is derived in the WORKER, not in the UI, because nowhere else knows it. A Maildir filename does not record the folder a message came from and notmuch cannot answer once the file has moved, so the moment the old filename exists inside moveMessages() is the only place it can be read. It travels back on a new messagesMovedFrom() signal, and the UI turns it into a `deleted-from:<folder>` tag that Restore reads days later. The account is resolved from the message's PATH rather than from its account tag: that tag is optional config, so resolving through it would silently make an account undeletable. That needed ThreadSummary to carry the first message's path, since an unexpanded thread row is the ordinary case and held no path at all. It is reported relative to the database root, because the UI knows accounts only by their maildir, itself a database-relative prefix. accountForMessagePath() accepts both an absolute and a relative path, and that is load-bearing rather than defensive: a thread row's path is relative while a reply row's is absolute, since MimeParser has to open it. Matching only one form left Delete on a reply resolving to no account and moving nothing, which is the thread-row/reply-row asymmetry this file has been bitten by before. Tags are applied only once the worker CONFIRMS the move. Tagging first would leave a message marked deleted in a folder it never left when a rename fails, which is the half-done state this removes. A move made during a sync is held in its own queue and flushed like a tag edit: the existing queue carries tag changes only, so a move pushed through it would apply `deleted` and never move the file. An account with no trash configured reports through the status bar and tags nothing, as a second line of defence behind the config-load warning. Six existing tests used `delete` as a stand-in for a message-scoped tag action on bare windows with no account; they move to `spam` and `delete_thread`, which stayed tag-only, keeping the property each was actually testing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9 daysfeat(ui): act on the message a row displays, not its whole threadDanilo M.1-8/+93
A thread's card has rendered one message since item 66, but every tag action still acted on the entire conversation. Delete, Archive, Important, Mark spam and Toggle unread now act on the message the card shows; the whole-thread versions move to a "Whole thread" submenu in the Message menu and the thread list's context menu, on Ctrl+Alt+<key>. Closes items 87, 88, 105, 106, 107, 108, 109, 110 and 111. The defects fixed along the way, several found by reading rather than by report: - threadAt(current.row()) answered about the wrong thread for a reply row, because a tree numbers rows per parent. The audit found four live sites, not the one reported: Delete and Toggle unread each chose their DIRECTION from an unrelated thread, and the tag dialog counted the wrong thread's tags. threadFor(index) replaces them. - A message-scoped write made no optimistic model update and no reply row carried a doomed cue, so acting on a reply moved the pending-edit count and changed nothing on screen. - Both toggles read the state of a reply's THREAD, which a message-scoped write never changes, so they were one-way: the second press re-sent a tag the message already had. - flushHeldEdits() re-sent only thread-scoped edits, so a tag change made on one message during a sync was applied to the row, counted as unsynced, and then dropped without ever being written. - applyTagChange() updated a thread's summary but not its loaded replies, leaving an expanded thread's rows describing a state the database no longer held. - A thread's first message is not among its children, so both message-scoped lookups missed it: acting on a root card repainted nothing and emptied the message pane's chip row. - ThreadSummary::tags is notmuch's union over the thread, so a card standing for one message drew tags belonging to its siblings. The worker now reads that message's own tags in the walk that already finds its id, so the split is known before a row is ever opened. The card shows both tiers: its own message's tags at full size, the rest of the conversation's smaller and muted, so nothing appears to vanish when a row is selected. Auto mark-read is message-scoped as a result, and now arms for a reply, which it never did. With maildir.synchronize_flags on, the old thread-wide write reached the server for mail that had never been displayed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 daysfeat(ui): open a thread on its own by double-clicking a rowDanilo M.1-0/+4
Double-clicking any row drills into its thread: the list becomes that thread alone, expanded, and the pane shows the double-clicked row's own message. A reply therefore opens its WHOLE thread with itself selected, never itself alone, which is what the user asked for and is not the obvious reading of "open it by itself". This is recoverStaleThread() triggered by a gesture. That function already ran thread:<id>, expanded the thread when the row arrived, selected the target message once the replies landed, and fell back to the root when the message had gone; all three cases are existing paths through it, so the new code resolves a row to a thread id and a message id and hands both over. The row is reached through the INDEX and never through index.row(): a tree numbers rows per parent, so threadAt(row) on a reply answers about an unrelated thread. That is item 88's trap, avoided here by construction. The first click of a double-click arms the mark-read timer, and the handler cancels it, because a gesture that navigates must not mutate mail. The timer is armed again for whichever row the recovery lands on, so only the arming for the row being left is cancelled. Its test asserts the timer was active beforehand, so it cannot pass by the timer never having been armed at all. The expander keeps its own double-click: ThreadListView::mousePressEvent accepts a press inside its rect and returns, so Qt never pairs one into a double-click there. Nothing is built for getting back. The filter buttons already are that, per the user. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 daysfix(sync): send held edits before the sync-end refresh reads the databaseDanilo M.1-0/+10
An edit made while a sync is running is held rather than sent, because the worker's read-write open blocks on notmuch's exclusive lock. At sync end onExternalSyncStateChanged() refreshed the list first and flushed the held edits afterwards, so the refresh read a database that still carried the old tag, reconciled it into the model, and overwrote the optimistic update the hold had deliberately left applied. The flush then wrote the tag correctly. The database ended up right and the list ended up wrong, with nothing scheduled to re-read it, which is why it looked like the edit had been lost. Reported by hand: a message read during a sync went back to unread when the sync finished. The flush moves ahead of the refresh and keeps both properties it already had. It stays outside the Idle branch, so edits held when /proc/locks becomes unreadable are not stranded waiting for an Idle that never comes, and it stays after the status-bar retire, so its own "N held changes sent" message survives. Both orders leave identical end state, so the first version of the test passed against the defect: after the handler returns the queue is empty and the write has been sent whichever ran first. flushGenerationForTesting() stamps the query generation at flush time, which is what separates them, and the test fails against the old order with Actual: 3, Expected: 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 daysfeat(ui): highlight the built-in filter matching the current viewDanilo M.1-0/+14
The four filter buttons gave no sign of which one you were looking at, so the row said what you could do and never where you were. The active filter is drawn as a checked QToolButton, which lets the style paint its own pressed look: a hand-picked highlight colour would have to be picked once per theme and would still be wrong under a third. The check state is derived from the query TEXT rather than from the last button clicked, which is the whole design decision. A record of what was pressed goes on lying the moment the query is edited into something else, where a highlight that follows the query clears itself and lights again when a filter's query is typed by hand. It is resolved against the account box, so changing account recomputes it rather than dropping it: the same filter under two accounts is two different query strings and both are still "Inbox". Buttons are held in a hash keyed by generator, cleared at the top of the row build because the row is rebuilt wholesale on every saved-query edit and stale entries would dangle. The connections are owned by the row widget, so a rebuild takes them with it rather than leaving a second copy firing at deleted buttons. Unread opens already highlighted, which is correct rather than incidental: startup_query defaults to it, so the window opens on that view. The test asserts it, so the assertions that follow are known to be a change of state rather than a button that happened to start unchecked. Mutation checked against the design that was rejected: deriving the state from the click instead of the query fails all three tests, each naming the behaviour it protects. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 daysfix(startup): let startup_query name a built-in filter, and run itDanilo M.1-0/+8
Two defects, both reachable only after item 93. startupSavedQuery() searched the saved queries alone, so a startup_query of "Inbox" matched nothing once Inbox shipped as a built-in filter and the duplicated entry was removed from queries.json. It then fell back to m_savedQueries.first(), which is an arbitrary choice that used to look reasonable while every install carried an Inbox entry: with the duplicates gone it opened on a leftover search for one sender, and an empty queries.json opened on nothing at all. The search now covers the saved queries first, so the user's own entry wins a name collision, then the built-in filters; the fallback is the Unread filter, which is always present. The default startup name has always been "Unread" and now resolves for the first time: before this it named nothing unless the user happened to have such an entry. The constructor also read startup.query directly, and a generated entry stores no query at all, so even a matching filter opened an empty bar. It resolves through Config::resolvedQuery() now, unscoped, since the account dropdown starts on "All accounts". Icons per the user's choices: a star for Important rather than the flag action's own icon, since on the query row an icon reads as a category rather than as an instruction, and mail-folder-sent rather than mail-sent. Three tests changed rather than adapted, because their premises were the defect. Two asserted the first-saved-query fallback. aCronSyncDoesNotRefreshBeforeAnyQueryHasRun assumed a fresh window had run no query, which is no longer true; it is now aCronSyncRefreshesTheLastRunQueryNotTheQueryBar and asserts the property that actually matters on a cron timer, through a new lastRunQueryForTesting() seam, since a legitimate refresh bumps the generation and the counter cannot tell the two apart.
10 daysfeat(filters): put the four built-in filters on the query rowDanilo M.1-1/+25
Item 93, the UI half. Unread, Inbox, Flagged and Sent are buttons the application ships, sitting first on the row, ahead of the user's pinned saved queries. runFilter() is runSavedQuery()'s opposite in the one way that matters: it READS the account box and never writes it. That is item 90's defect. A filter narrows what the user is already looking at, so the dropdown is its input rather than something it resets on the way past. A saved query keeps setting the account from what it stored, because it is a destination and states its own scope. runQuery() gains an AccountScope parameter. A filter's text arrives already resolved in the selected account's scope, and scoping it again would put path:"work/Sent/**" inside path:"work/**". Two migration changes, both of which unpin rather than delete: - Sent is no longer migrated from the INI into queries.json. The built-in filter covers it, and migrating one too would put two Sent buttons on the row, one editable and one not. - A stored entry naming a known generator is unpinned on load, which is what every install upgraded through 0.19.0 carries. It keeps its name and its generator and moves to the menu. Deleting it would be data loss on a file whose readers are supposed to preserve what they do not own. The test suite needed the same distinction the design makes. savedQueryButtonLabels() now skips the filters, and savedQueryButton(window, label) replaces five positional row->findChild<QPushButton *>() lookups that were silently returning Unread. One rendering probe had to be fixed rather than adapted. replyRowsKeepTheirTextUnderTheThreadLine resized the window to 300px, and four more buttons pushed the reply row below the viewport: the pixel loop then ran zero times and reported "0 pixels, the row was painted over", which is a different defect from the one it exists to catch. It gets 600px and a guard asserting the row is really inside the viewport, so the next person to shrink it gets told the truth. Verified by putting 300 back: the guard names the row at 83..165 in an 82px viewport.
10 daysfix(status): count threads as they arrive instead of "Searching..."Danilo M.1-0/+13
Item 74. runQuery() set the status bar once and only queryFinished cleared it, so the bar kept claiming a query was running for the whole walk while rows were visibly arriving behind it. Measured cold against a 1.1 GB index: the first batch reaches the model at 642 ms and the walk finishes at 5714 ms, so five seconds of a slow query read as a frozen one. onThreadsReady now sets the bar from the model's own row count after each batch, which is the number of rows the user can actually see. No timing changes; this only stops the bar from lying. The refresh branch returns before the new line, so a background refresh stays silent exactly as onQueryFinished already keeps it. That silence has its own test, which fails when the write is moved above the guard. beginRefreshForTesting() is a new seam: refreshCurrentQuery() returns early without a worker and a bare window has none, so a test cannot otherwise reach the refresh path.
11 daysfeat(pane): always render one message, never the conversationthread-view-removedDanilo M.1-1/+2
Selecting a thread root used to render the whole conversation, stubs plus the last messages expanded, but only until the thread had been expanded once. After that the identical click rendered a single message. The user reported the inconsistency and asked for the single-message behaviour throughout, and for the conversation view to go. The cause was a timing one, not a race. The root card stands for the thread's first message and onThreadSelected already preferred to load just that, but the model learned the id only when the replies arrived, so a fresh row fell through to a whole-thread render. ThreadSummary now carries firstMessageId from the query itself, so the id is known before any expansion and the fallback is unreachable. It is free: notmuch_thread_get_toplevel_messages reads the index, not the message files, and a walk with it is indistinguishable from one without over a 36,615-thread database. Contrast recipients, which reads every file and stays Sent-only. The Sent view keeps showing what the user sent rather than the thread's opening message, which is often someone else's. There is no matched-messages iterator in libnotmuch, only a count, so that branch walks oldest-first to the first NOTMUCH_MESSAGE_FLAG_MATCH and stops: 0.146s against a 0.143s baseline over 4,515 threads. onThreadLoaded merges into renderMessages, since onMessageLoaded was already delegating to it for the actual painting. It still takes a list because MessageView renders a list; collapsing that is a separate change to a class with its own tests. NotmuchWorker::loadThread is kept and documented as having no UI caller. It is a tested way to read a thread's messages with the match set resolved, used as a helper by the worker's own tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysfix(startup): raise the config-problem modal outside the constructorDanilo M.1-1/+15
showWarnings() did two separable things and one of them could not be reached from a test. It set the status label, which is harmless, and it raised a QMessageBox from the MainWindow constructor, which under the offscreen platform nothing can dismiss: the constructor never returned and the suite hung with no output, reading as an infrastructure failure rather than a test one. It splits in two. applyWarnings() keeps the status label and stays in the constructor. configProblems() returns the list, and main.cpp raises the dialog after show(), which also gives it a visible parent to sit on. The distinction between warnings and problems is preserved exactly: a keybinding being ignored interrupts startup, "no sync command configured" does not. The warning path now has its first test, using the config shape that caused the original hang. Mutation checked by putting the modal back in the constructor: the test times out at 124 rather than failing, which is the behaviour this removes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysrefactor(search): carry SearchMode instead of bool extendDanilo M.1-4/+6
Four signatures, no behaviour change: the two shipped operations map to Replace and Narrow. runSearchFromPane becomes a switch and gains the Exclude arm, which nothing can reach until the menu entry exists. Seven call sites across three test files moved with it, two more than the plan predicted: test_messageview and test_mainwindow also drive these signals directly. mainwindow.h and messagedetailsdialog.h now include searchterm.h for the type; messageview.h already did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysfeat(search): run a search asked for from the message paneDanilo M.1-0/+7
The panes carry a finished query and know nothing of the query bar; the window sets the field and calls the existing runner, so the account scope and the generation counter keep working as they do for a typed query. Narrowing combines here rather than in a pane, because only the window can see what the bar currently holds. The tag strip's chips join the header, the body selection and the details dialog as a fourth surface. Also fixes the details dialog to actually close when a search is chosen: the comment above the connection already described this requirement, but nothing called accept() or reject(), so the dialog stayed open, the query ran behind it, and the modal exec() never returned. This hung the whole test suite on QT_QPA_PLATFORM=offscreen once a covering test was added.
11 daysfeat(rules): seed the rules dialog even when it is openDanilo M.1-1/+6
The dialog is non-modal and single-instance, so a second Create tagging rule reaches one that is already up. Seeding it beats dropping the request, which would read as a broken menu item. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfeat(queries): edit, pin and delete a saved query from the UIDanilo M.1-0/+36
Item 82. Saving a query worked and nothing else did: changing one field meant retyping the whole query under the same name, and deleting one meant editing the file by hand. An action that creates something the UI cannot then change or remove is incomplete, and the user hit it within minutes of the first hand test. Right-clicking a saved query, on its button or its menu entry, now offers Edit, Move to menu / Show as a button, and Delete. Every path funnels through one replaceSavedQuery(), which matches on the name the dialog was OPENED with rather than the one it returns, so a rename replaces the entry instead of leaving the original behind beside a new one, and which merges the stored entry's unknown fields in a single place rather than in three. Delete confirms first: the rule against confirmation dialogs covers tag mutations, which the undo stack can take back, and this writes user config that it cannot. Two cases the item did not anticipate. A generated entry has no query to edit, so the dialog shows its composed query read-only rather than offering a field that changes nothing, and carries `generated` and `flat` through an edit rather than letting it decay into a plain entry holding a snapshot of what it resolved to today. And the overwrite notice had to learn to ignore the entry being edited, since warning that "Inbox" already exists while editing Inbox is noise. This also fixes a defect that predated it and was already reachable from the save path. rebuildSavedQueryRow() called deleteLater() on the old row, which defers destruction to the event loop, so the stale row went on answering findChild() and every lookup after a rebuild reported the state from before the edit. Nothing looked wrong on screen, which is why it surfaced only as three tests failing against a row that had in fact been rebuilt correctly. Five tests, three mutations. Matching on the returned name fails two, never writing the file fails three, and dropping the unknown-field merge fails one. That last one initially proved nothing: it drove UNPIN, which copies the stored entry and so carries `unknown` along by itself, and passed with the merge deleted. It now goes through the edit path with a replacement that has none, which is what the dialog actually returns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfix(queries): put the Save query button beside the query barDanilo M.1-0/+3
The spec asked for "a Save query button beside the search bar" and what shipped was a menu entry and Ctrl+S. The user went looking for the button where the design said it would be and did not find it. Saving is a thing you decide on while looking at the results, so it belongs where the results came from rather than behind a menu or a remembered chord. The button takes the action through setDefaultAction rather than a second connect, so it inherits the text, icon, tooltip and enabled state and cannot end up offering to save an empty query while the menu entry correctly refuses. The mutation that replaces it with a plain clicked() connection fails the test. Also records item 82: a saved query cannot be edited, unpinned or deleted from the UI. Item 23 specified saving and nothing else, and that is exactly what was built, so the only way to unpin a query is a text editor or retyping it in full under the same name. An action that creates something the UI cannot then change or remove is incomplete, and this was found within minutes of the first hand test. It is filed as a defect rather than an enhancement, and the spec now says so where a reader would otherwise take the design for complete. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfeat(queries): save a query from the UI, and split the buttons off the query rowDanilo M.1-0/+21
Second half of item 23, on top of the storage change. A query can now be kept without hand-editing a file, and the row of buttons no longer grows without bound. Ctrl+S opens a dialog on whatever is in the query bar, taking a name, an optional account scope and whether the query is pinned. It preselects the account already chosen in the dropdown, since that is the scope the user is looking at, and it says so when a name is about to replace an existing query rather than refusing the name: overwriting a saved query on purpose is a normal edit, and the only thing worth preventing is doing it without noticing. Saving over an entry keeps the stored entry's unknown fields rather than the dialog's fresh value, so a field written by a later build survives being edited here. The saved queries move to a row of their own beneath the query bar, pinned ones as buttons and the rest behind a More queries menu that only exists when something is in it. The ponytail note that stood in the query row predicted exactly this: an unbounded list of buttons sharing the row squeezed the field. Sent moves down with them and is still not a saved query, for the reason already recorded there. A saved query's account scope goes through the account DROPDOWN rather than being baked into the query text. runQuery() already wraps the query in the selected account's path, so pre-scoping here would apply it twice, and setting the dropdown also shows the user which scope they are in. An unscoped query clears the selection rather than inheriting whatever the last one left, which is the same defect the rules preview had. Seven tests, three mutations. Ignoring the pinned flag fails two of them, pre-scoping the text instead of setting the dropdown fails two, and letting an unscoped query inherit the previous account fails one. The menu-absence test initially passed against no implementation at all, since it only asserted a widget was missing; it now proves the row was populated first, which is the guard that class of test needs. Two existing invariants caught real omissions rather than needing adjustment: every registered action must appear in KeyMap::knownActions(), which is what gives it a configurable binding, and every action needs its own icon. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfeat(rules): preview a rule's mail in the thread listDanilo M.1-0/+26
Item 77. The dialog could say how many messages a rule matched and not which ones. A Preview in list button now runs the selected rule's query in the main window; the dialog stays open, since comparing the rule against its results is the point. Two constraints from the backlog entry, both now asserted and both mutation-checked. The query runs exactly as stored, with no tag:new and no wrapping parentheses. The post-new hook adds those when it applies a rule, and a preview that copied them would match nothing outside a sync window, since tag:new is set only on mail that has just arrived. The account selector is cleared first. runQuery() wraps the bar's text in the selected account's scope, and a rule query usually names its own path already, so previewing one with an account selected would scope it twice and show an empty list, which reads as "this rule collects no mail". The second mutation only fails once the test's config has an account to select: with the default empty config the selector sits on "All accounts" anyway, and asserting that a preview leaves it there passed against the mutation. Recorded in the test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
13 daysfeat(rules): open the tagging rules from the Message menuDanilo M.1-0/+31
Counts are generation-stamped and dropped when stale or when the dialog has closed: counting every rule against a cold index takes seconds, so an in-flight reply outliving its dialog is ordinary rather than rare. The stamp is its own counter, not m_generation as drafted. That one is the QUERY generation, compared against directly by every thread, tree and message load, so bumping it to count rules would discard whatever the user was opening at the time and blank the message pane for an unrelated reason. Registering an action obliges two more entries, both enforced by tests: the name in KeyMap::knownActions(), and a default binding, since every action carries one. Ctrl+Shift+T, shifted against Ctrl+T for edit_tags the way Ctrl+Shift+U is shifted against Ctrl+U. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
14 daysfeat(sync): sync a tag change automatically after a short delayDanilo M.1-0/+19
Item 71. A tag edit reached the notmuch index at edit time and then sat there until the user clicked Sync or their cron job fired, so "mark all read" updated the view while the change itself waited, sometimes for ten minutes. A confirmed edit now arms a debounce that runs the existing sync path. The delay is auto_sync_delay_ms in [general], defaulting to 2000, and follows mark_read_delay_ms exactly, including that zero and negative are not errors: zero syncs on the next trip through the event loop, and any negative value disables the behaviour, which is the switch for a user who wants only their cron job. It is armed from onTagsApplied, where a write is confirmed and the pending count is already current, rather than where one is sent: a sync scheduled for a write the worker went on to reject would run for nothing. A debounce rather than a schedule, restarted by each edit, because "mark all read" confirms one write per thread in the view and an arm-per-edit timer would be the storm of syncs the debounce exists to prevent. Nothing is armed when no sync command is configured or when the pending count is zero, the case where an edit was netted against its own inverse. When the timer fires with a sync already running, local or cron, it skips rather than queues: mbsync's own answer to a second run is to fail on it, and the edits stay pending rather than being lost. Also fixes a pane blanked out from under the reader, found by hand testing this feature. onSyncFinished called runCurrentQuery() where the cron path calls refreshCurrentQuery(), and a re-run clears the model, the undo stack and the message pane. The stale-thread notice handles a thread that stops matching the query and has since item 35, but a re-run left nothing for it to describe. The two paths had no reason to differ; before this item a local sync only followed a click on Sync, so the difference went unnoticed. Reading a message in the Unread view, having it marked read, and watching the pane go blank two seconds later is what surfaced it. Its test asserts on the undo stack rather than the pane: both paths issue a queued query test_mainwindow has no worker to answer, so the pane ends up blank either way and an assertion on it would pass against both, while the undo stack is cleared by one and kept by the other. Nine tests, four in test_config and five in test_mainwindow, each mutation-checked: removing the schedule call, honouring a negative delay, dropping the nothing-pending guard, dropping the already-running guard, and restoring runCurrentQuery() each fail a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
14 daysfeat(placeholder): count sent mail and drafts on the blank paneDanilo M.1-1/+44
Item 67. The pane counted unread, flagged and inbox from three fixed tag: queries. Sent and drafts cannot join that list as tags: tag:draft counts 0 against a real database and no draft-ish tag exists in it at all, so a tag-based line would be a permanent zero that reads as working code. Both are composed from each account's folder keys instead, the same way the Sent view already composes its query. The drafts key was parsed and documented as unused in v1. Composing drafts is still v2; counting them is not, so Account::draftsQuery() and Config::allDraftsQuery() now mirror the sent pair. The shared body moved into folderQuery() and joinAccountQueries(), so the load-bearing quoting (a provider nests both folders under a bracketed parent, and [ and ] are Xapian syntax) and the bare-"or" guard exist once rather than once per folder type. The fixed array is gone rather than extended. It held queries and labels in two lists indexed in parallel, which is a hazard that grows with the list: an entry inserted in one and not the other prints a real number against the wrong name and looks entirely plausible. placeholderLines() carries each query beside the callable that labels it, so the two cannot drift, and the count reply stays paired by position as the worker requires. A line is omitted when no account configures that folder rather than shown as 0, following item 63: a missing folder is a real configuration, and "0 sent" claims the user has sent nothing. Measured against the real config: 4 sent terms over 601 threads, 5 drafts terms over 3, the extra drafts term coming from the one account that configures drafts and no sent, which is what proves the two are collected independently. Four tests here and four in test_config, mutation-checked at three points: dropping the drafts line, an off-by-one in the label pairing, and removing the -1 guard for an uncountable query. Each mutation fails a test.
2026-08-11feat(sent): add a Sent view, flat and by recipientDanilo M.1-1/+27
Adds a `sent` key to [account.*] naming that account's sent folder, and a Sent button beside the saved queries that composes its query from every account carrying one. An account without the key is omitted silently, as a real account may keep no sent mail locally. With no account selected the button spans all of them; selecting one narrows it through the existing scope wrap rather than a second path. Composed at run time rather than shipped as a [queries] entry. A saved query is one fixed string: it cannot narrow to the selected account, and it goes stale the moment an account is added or a provider renames a folder. The design and the measurements behind it are in docs/superpowers/specs/2026-08-11-sent-mail-design.md. Three things there are worth repeating here. The composed path is QUOTED, and that is load-bearing. A real provider nests its sent folder under a bracketed parent, and "[" and "]" are Xapian syntax: unquoted, the query parses rather than matches and returns nothing while looking entirely plausible. Composition happens in one place so there is one chance to get it right, and a bracketed path is pinned in a test. Recipients are opt-in per query, which is a performance contract rather than a preference. notmuch_message_get_header(m, "To") is not served from the index, it reads the message file: folding every thread of a 4411-thread inbox took 38.2 seconds against 251 ms for the 601-thread sent view. The worker skips the walk entirely unless asked, and the refresh path carries the same flag so a background sync cannot blank the column mid-read. Always folding is mutation-tested: the data would be right and only the cost wrong, which nothing else here would notice. The messages reached through the thread are owned by it and freed with it, so recipientsOf() holds them raw and finishes while the thread is alive, exactly as walkReplies does. An NmMessage wrapper there is a double-free. Sent mail is presented flat, and the pane follows. A message you sent otherwise drags in the replies you received, so a view labelled Sent shows conversations rather than what you sent. ThreadListModel::setFlatMode() makes hasChildren() and ReplyCountRole answer differently and changes nothing else; runQuery() sets it on EVERY run, so any other query restores the tree on its way through and the flag cannot outlive the button that set it. The pane needed its own fix for the same reason: the single-message path depends on a field only filled when a thread is expanded, which never happens in a flat list, so loadThread() gained matchedOnly and drops the messages that did not match instead of rendering them as stubs. Recipients replace the sender through the existing SendersRole rather than a new one, so the delegate needs no branch and cannot disagree with the model about which name a row shows. It falls back to the sender when a To header is absent or unparseable, since a blank where a name belongs reads as a rendering fault. Address parsing uses GMime: a display name may contain a comma, so "Rossi, Mario" <m@example.org>, info@example.net is two addresses and splitting reports three. internet_address_list_parse returns NULL for an empty string, which is a crash if unguarded. Backlog item 63.
2026-08-10feat(view): follow a background sync without a keystrokeDanilo M.1-0/+105
The thread list now updates itself when a sync finishes, whether it is empty or populated. New threads appear where the sort puts them, threads that stopped matching leave, and threads whose state changed repaint. Refreshing used to mean re-running the query, which cleared the model, the selection, the message pane and the undo stack, so 0.8.0 declined to do it on a cron timer and asked the user to press Enter instead. The result was a list that quietly disagreed with the database: mail indexed by cron never appeared, and an Unread view read to the end sat empty in front of it. ThreadListModel::reconcile() diffs a result against the current rows by thread id instead, so a surviving thread keeps its row, its persistent index and its loaded replies. Order comes from the result and is never imposed here, which is what makes the sort dropdown authoritative. The undo constraint this was sized around did not exist: no undo entry was ever keyed on a row. ThreadTagCommand stores thread ids and MessageTagCommand stores message ids, and applyTagChange() looks its target up by id, so an entry already survived its rows leaving the view. A thread read out of the current view now leaves the list, which is correct and would otherwise strand the reader, so MessageView grows a notice saying the open thread no longer matches, with a button that re-queries it. Recovery lists the whole conversation, expands it, and restores the message that was on screen rather than reopening at the first one. Ten defects were found building this, nine of them by hand testing: - SyncMonitor::start() polls synchronously, so an idle lock file emits stateChanged(Idle) from inside buildUi() and the first handler to touch a widget segfaults before the window exists. - QTreeView sets a current index when it takes focus with none set, and current drives loading, so new mail opened itself and was marked read without the user having looked at it. Selection is now required. - The notice outlived what it described, both when the pane was blanked and when another message replaced it. - Retiring the "Background sync completed" message left the bar claiming a sync was still running: silent means saying nothing new, not leaving a stale claim on screen. - A thread root sets both the thread id and the message id, so treating the message id as the message-row case discarded it for the commonest way to open a thread. - A freshly queried root does not know its own first message until the tree loads, so recovery selected nothing and left the pane blank. - A user query mid-recovery had its result hijacked by the pending selection. - MessageView emitted the recovery signal with its own members, so a direct connection handed MainWindow references that runCurrentQuery() then cleared by blanking the pane. The ids went empty mid-slot and no recovery ever ran. Every test passed against this, because reaching a slot through invokeMethod copies its arguments. A Qt signal argument is a reference until something copies it. Emitting a member to a slot that can re-enter the emitter is a use-after-write, and it presents as a wrong value rather than as a crash. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10feat(ui): let the user choose newest or oldest firstDanilo M.1-0/+1
Two entries, straight to notmuch. This adds a feature rather than replacing one: the column header was decorative and nothing implemented click-to-sort, so removing the header with the grid lost nothing. Stored in uistate.conf, never in the hand-edited config, and range-guarded on read: a stale file can hold anything, which is the lesson item 58 recorded. SortOrder needed qRegisterMetaType despite carrying Q_ENUM. Q_ENUM gives the enum a meta-object entry, not a metatype registered under the name invokeMethod resolves, so the queued runQuery would have dropped its sort argument at runtime and every query would have silently run newest-first. Nothing in the suite exercises a real worker thread, so this was asserted directly rather than left to a warning nobody would see. It is registered beside the type rather than in MainWindow's constructor: a first attempt put it there and passed only because the test that catches it never constructs a MainWindow. The account dropdown's entries now carry their account's colour as a swatch, which is what makes the accent bar on a card mean anything: a colour down a card's edge says nothing until something maps it to a name. Raw colour here rather than the blended line colour, since a swatch is a filled patch like a chip rather than a thin line. Its test builds its own two-account config: reading the environment's made it SKIP wherever no accounts are configured, which is a test that asserts nothing while reporting success.
2026-08-10feat(ui): scope actions to the selected row kind and name itDanilo M.1-0/+65
tagSelected resolved rows to threads with threadAt(index.row()), which is wrong for a message row: a child's row number indexes its siblings, so acting on a reply tagged whichever thread sat at that position in the list. It now routes through ThreadListModel::scopeFor, and a message row's change is sent as message ids down applyTags with its own MessageTagCommand for undo. MessageTagCommand stores message ids where ThreadTagCommand stores thread ids, and that difference is the point rather than an inconsistency: re-resolving the thread on undo would restore tags across every sibling the action never touched. sendMessageTagChange deliberately skips the optimistic model update. applyTagChange is keyed by thread and would repaint the whole row as though every message in it had changed, which for a one-message edit is a lie the user watches correct itself on the next query. It keeps the two things that are NOT optional: the edited-account set, resolved through the containing thread since the account is a property of the thread, and holding the edit when a sync holds notmuch's write lock, since the worker's read-write open blocks rather than failing. The scope is now stated before an action and after it, naming both the message count and whether a whole thread went. This is what stands in for the confirmation dialog CLAUDE.md rules out: undo is the safety net, and undo is only usable if the user can tell that something larger than they meant has just happened. Selecting a single message reports no count at all, since reading one message is not a bulk action. A mutation that routed message rows down the thread path SURVIVED the whole suite: undo depth and status text are identical either way while every sibling gets tagged. anActionOnAMessageRowTagsThatMessageNotTheThread exists because that gap was found, and asserts on the ids actually sent. anActionOnAThreadRowSaysItHitTheWholeThread reads the status bar BEFORE draining the event loop. This binary has no worker, backlog item 36, so the queued write reaches a database that has never heard of the thread and answers with errorOccurred, which overwrites the status bar: draining first asserts on that error and fails against correct code.
2026-08-10feat(ui): render a single message when its row is selectedDanilo M.1-0/+9
loadMessage queries by id: and returns one MessageRef, always matched, since the user asked for that message by clicking its row and a stub would answer the wrong question. An unknown id emits an empty vector rather than an error: a stale row after a reindex is an ordinary race, not a failure worth the status bar. The signal fires even when empty so the UI handler runs instead of waiting for a reply that never comes. The branch in onThreadSelected is placed BEFORE threadAt(), which is the whole trap. threadAt takes a top-level row number and a child's row number indexes its siblings, so handing a message row's number to it loads whichever thread happens to sit at that position. Mutation-checked: with the branch disabled the test reports thread 't1' for a reply belonging to 't2', a wrong answer plausible enough to survive review. m_currentMessageId and m_currentThreadId are mutually exclusive and each clears the other, so a queued reply can tell which kind of selection it belongs to. onMessageLoaded carries a third guard onThreadLoaded does not need: a reply landing after the selection moved to a thread row would render one message where the conversation belongs. No mark-read timer for a message row in this pass. Marking one message of a thread read is a per-message tag write and the pending-edit map is keyed by thread; item 28 is the record of what happens when that count goes wrong.
2026-08-10feat(ui): load a thread's replies when its row is expandedDanilo M.1-0/+7
Replies are fetched on expansion rather than with the query: walking the reply tree of every thread in a 10k-thread result would cost more than the query and almost none of it would be looked at. hasChildren is what makes that lazy loading work, and its absence would have shipped the feature unreachable. rowCount is 0 until the worker has walked the thread, so a view left to infer the expander from rowCount alone draws none, the user can never expand, and the replies are never requested. It answers from the summary's totalCount before loading and from the children afterwards, so a thread whose count included duplicates stops offering an expander that opens onto nothing. onThreadTreeLoaded reads the thread id from the reply rather than remembering it from the request. Two expansions can be in flight at once, and pairing them by order would attach one thread's replies to the other.
2026-08-10refactor(view): make ThreadListView a QTreeView for message rowsDanilo M.1-2/+14
The strip survived the port because every geometry call it needs exists on both classes. What did not survive is anything keyed on a row NUMBER: a tree numbers rows per parent, so row 0 exists once per expanded thread and the old flat 0..N walk would paint the first thread's strip over every one of them. The walk now goes by index, and the alternating colour follows visual position rather than index.row() for the same reason. QTableView::isRowSelected(int) has no QTreeView equivalent; isSelected on the index replaces it. MainWindow loses verticalHeader and selectRow, so row height comes from uniformRowHeights and three helpers replace the row arithmetic. next_thread and prev_thread now resolve the containing thread first: in a tree current.row() + 1 is the next SIBLING, which under an expanded thread is the next reply, not the next thread. Two test defects found by mutation and worth recording, since both produced a green suite over a broken assertion: The indent test asserted on column 0. A QTreeView indents only the column holding the expander, verified against Qt 6.11: with setTreePosition(4), column 0 reports the same left edge for a thread and its reply while column 4 reports 420 against 440. It was failing against a correctly indented tree. The strip test passed with the view's skip deleted, because the real model already returns no pills for a child row, so the view's guard was never the thing under test. It now runs against a stub model that hands pills to every row, which leaves the view's skip as the only thing that can keep replies clean. That rewrite then failed for a third reason: without the delegates MainWindow installs, rows take the default height, the band is measured against SubjectDelegate::rowHeightFor and overflows into the row below, and the thread's own strip paints across the reply. Reads exactly like a missing skip and is not one.
2026-08-09fix(ui): stop a restored splitter position collapsing the message paneDanilo M.1-0/+8
A splitter position is saved in pixels, so one saved in a wide window does not fit a narrower one: QSplitter::restoreState() honours the first pane's saved size verbatim and gives the second whatever is left. A real 1285/1252 split restored into a 1136px window left the message pane 29px wide, a sliver of rendered mail beside a full-width thread list. The wider the window ever was, the worse the next narrower session. Fixed with a minimum width on the pane and setCollapsible(1, false), which covers the restore and the equivalent drag. A restore-time repair running from showEvent() was written first and deleted: with the floor in place it was mutation-tested to be redundant, since the minimum width constrains restoreState() as much as it constrains a drag. The floor is 300px rather than a bare "visible" width, because it is reached only when a position does not fit and should land somewhere mail is readable; at 200 the placeholder's own text wraps every few words. The test asserts against the pane's own minimumWidth() rather than a repeated literal, with a guard on the floor itself so it cannot pass against a lowered one. Note for later work in test_mainwindow: the offscreen platform chooses the window width itself and has been seen to choose differently between two runs of the same binary (1181 and 779), and it ignores resize(), setFixedWidth() and a resize of the splitter on a shown window. Any assertion on a pane ratio, or on the second pane's pixels, is measuring that choice rather than this code. Backlog item 55, whose recorded cause blamed the thread view's size hint and first-run layout. Measured, that hint is 256px, not the 886 the item computed from the column widths, and a freshly built window splits correctly; the entry has been corrected in place.
2026-08-09fix(sync): clear the pending-edit count on a cron syncDanilo M.1-2/+9
Item 54. A sync fired by the user's cron carries tag edits to the mail store exactly as a local one does, but only onSyncFinished() cleared the pending state, so the indicator kept reporting work that had already shipped and the exit prompt asked to sync for it. Verified against a real cron run: 31 changes, cleared with no manual sync. The window cannot see an external run's exit status, and /proc/locks carries no outcome. It does not need to: mailsync.sh already ends every run with a "RUN END ... status=OK" banner in its log, which outlives the process that wrote it. MailSync::lastRunOutcome() reads a bounded tail of that file and takes the last completed marker, so no change to the script and no optimistic guessing were needed. Only a definite OK clears anything. A failed run, a missing or unreadable log, and a State::Unknown lock reading all leave the count alone: over-reporting costs a redundant sync, under-reporting costs the user their edits. m_editedAccounts is drained in the same place, before flushHeldEdits() and matching the local path's ordering. Item 49 uses it to choose which mbsync channels a run syncs, and a count that reached zero while the set stayed full would look correct and still sync the wrong channels. The log path comes from a new optional [sync] log key, defaulting to where the script writes, so a test never reads the developer's own log. Two notes on the verification, both recorded in the backlog: - A timing probe endorsed a tail read that was not happening. The first version of the huge-log test required the call under 100 ms and passed with the seek deleted, because reading 10 MB is fast either way. Replaced with an assertion on content. - Every fixture was invented and the first batch had the wrong timestamp format, since the script uses date -Iseconds. The tests passed anyway, because the parser keys on the prefix and the status token. One test now builds the banner the way the script does. The before-flushHeldEdits ordering has no test: without a held lock the flush is a no-op, so both orderings pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07feat(sync): sync only the accounts with unsynced editsDanilo M.1-0/+20
A sync ran mbsync -a regardless of what changed, so tagging mail in one account fetched all of them. The account set was not a parameter anywhere on the path: MailSync::start() took no arguments and the script hardcoded -a, so nothing between a tag edit and mbsync carried which account changed. Track which accounts have edits and pass their mbsync channels through to the script, which now takes channel names and falls back to -a when given none. An empty set means all accounts, per the request: a sync with nothing pending is a fetch, and narrowing that to wherever the last edit landed would quietly stop collecting mail everywhere else. The channel is a new optional per-account key rather than the section key. The two names genuinely diverge, because a QSettings section key may carry dots that the channel does not, and mbsync treats an unknown channel as fatal rather than skipping it, so key-as-channel would fail those accounts' syncs outright rather than degrade. It defaults to the key, so accounts whose two names already agree need no config change. The edited-account set is deliberately not netted the way the pending-edit map is: that map tracks the index, where a tag removed and re-added leaves nothing outstanding, while this tracks the mail store, where both writes have already renamed files that mbsync still has to propagate. It is also snapshotted before flushHeldEdits(), which inserts into it synchronously rather than on a queued reply, so a successful sync cannot clear accounts whose edits it never carried. Closes item 49. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07feat(ui): add a Maildir overview under HelpDanilo M.1-0/+33
Nothing in the UI reported database-level facts: every query gave a thread count for that query, and nothing said how much mail there is overall. A dialog under Help now shows messages, threads and tags from notmuch, plus the account list from config, since notmuch does not model accounts at all. A separate worker call rather than a reuse of requestCounts, which counts threads to match the row count of a query. This counts messages, which is what a user means by "how much mail is in here". The test pins 4 messages in 3 threads against the fixture and fails if they are ever made equal, so routing both through one count cannot pass unnoticed. Every field starts at -1 and renders as "unknown" when notmuch could not answer it. Printing 0 would say the Maildir is empty, and telling someone their mail is gone is the worst way to report an index that failed to open. The dialog opens showing "Counting..." rather than blocking, since counting every message is not free on a large database. That makes two lifetimes matter: the reply can arrive after the dialog is closed, so the label is a QPointer, and the dialog can be closed and reopened while a count runs, so a generation counter drops the older answer. The test drains DeferredDelete before firing the late reply, because close() deletes through deleteLater and without that the dangling case is never actually reached.
2026-08-07feat(ui): fill the blank message pane with a branded placeholderDanilo M.1-0/+37
An empty right pane said nothing, and multi-select made it a routine sight. It now carries the wordmark, thread counts that run their query when clicked, and a sync line that appears only when something needs attention. Rendered into the existing web view as a third document shape, so there is one document path and one set of security rules. The brand palette is a deliberate exception to deriving colours from the desktop theme, since a logo is brand rather than chrome; the theme still picks which of the two sets is used. Counts refresh when the pane is about to show rather than in the background: one goes stale the moment a tag is edited, and refreshing one nobody is looking at is work for nothing. A generation counter discards a superseded reply, and a late answer cannot repaint over an opened thread. The helper lines are real links because JavaScript is off in this profile. The handler is gated on the placeholder actually being displayed, so the same URL inside a message body is dropped: a stranger's mail must not drive the thread list, even to run a harmless query. Three defects found while building, all silent: - Every CSS percentage was invalid. QString::arg does not collapse "%%" into "%", so the document carried "50%%" and the browser dropped each declaration holding one, disabling the mask, the glow and both radial gradients while still rendering something plausible. Substitution is by named token now, which cannot collide with a percent sign. - A geometry probe endorsed the layout while that was live, because it measured only properties without percentages. - The font test passed against a build with one face missing, since the other satisfied both of its checks on its own. The mockup's light values needed correcting against a real pane: the grid vanished at a 2% luminance step on white, and the glow subtracts light there rather than adding it, washing the pane. Strength only, not hue.
2026-08-07feat(tags): mark every thread in the view read, in one undoable stepDanilo M.1-0/+26
An action removing "unread" from every thread in the current view, on the toolbar, the Message menu and Ctrl+Shift+U. It deliberately ignores the selection, which makes it the one action in the window that does, and it routes through the same funnel as every other tag change, so it is one write rather than one per thread. Disabled until the query reports its total. Threads arrive in batches, so before then the model holds only what has landed, and an action saying "all" must not silently skip the rest. A greyed control says "not yet" without needing a dialog or a stall the user cannot see. The state is also set at registration, since QAction starts enabled and a window that has not run a query has nothing to act on. Two things came out differently from the plan, both forced by existing code. It carries a default binding, because everyActionHasAShortcut requires every registered action to have one: an unbound action is unreachable from the keyboard, and that invariant is deliberate, so the action was given Ctrl+Shift+U rather than the invariant relaxed. And only the threads that are actually unread are sent, because sending the rest would inflate the pending-edit count with writes that change nothing, and the quit prompt reads that count. A view with nothing unread does nothing, pushes no command and says so: an undo entry that restores nothing is worse than none, since it absorbs a Ctrl+Z meant for the previous action. undoDepthForTesting() is new and exists for a reason worth recording: undo->isEnabled() cannot answer "was a command pushed", because the undo QAction is always enabled and tests canUndo() when triggered. The first version of the no-op test asserted on it and passed against a mutant with the unread filter removed. Closes item 43. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07feat(sync): the status bar says which account is syncingDanilo M.1-3/+23
"Syncing..." was set once and never updated, so a run that takes over a minute reported nothing about what it was doing. The original diagnosis in the backlog was half wrong, and two further wrong ones were made and discarded before the real cause: plain "mbsync -a" prints NOTHING until it exits, then a single summary line. Measured on a real run, one line at 11:11:08 then 73 within the second 11:11:33, at the end of a 46-second run. So there was no stream to read for the part of a sync that takes time. It is not buffering, so stdbuf changes nothing, and the account name is not unavailable either, which was the second wrong conclusion. mbsync -V is what changes both: it announces each channel as it reaches it, which is at once the progress and the account name originally asked for. The shipped script now passes it. SyncPhaseTracker derives a short status from the output as it streams: the channel being synced, the summary counts when mbsync ends, then the notmuch reindex. It lives beside MailSync rather than in the window so the matching rules are one testable thing, and it holds no widget. Matching is loose and case-insensitive, since the wording varies by version, and nothing in it decides success or failure: the exit status remains the only authority on that. Lines are reassembled in MainWindow before being fed, because QProcess::readAll() splits wherever it happens to and a half-line would match nothing. Every status is sanitised and truncated: the channel name comes from a config file this app does not own, and a long one must not stretch the status bar. Two defects in existing code, fixed with it. setSyncBusy(true) ran after start(), so a fast run's output arrived before the per-run reset and wiped its own phase. And a first draft deferred phases while a transient message showed, which let a "Background sync completed" message armed before the sync began suppress the whole run: a running sync's state outranks an expiring event message. Verified by replaying real captured mbsync -V output through the tracker, not only against fixtures. The MainWindow test paces its script with sleeps, since a script that prints everything at once arrives in one readyRead and makes every intermediate phase unobservable. Closes item 42. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06fix(ui): one Sync control, and a query bar that looks finishedDanilo M.1-1/+4
Sync had two controls that behaved differently. The QPushButton beside the query bar cleared the log, opened the pane and disabled itself; the QAction behind the toolbar, menu and shortcut called start() and did nothing else, discarding its return value so a rejected start was silent. Worse, item 29's "disable Sync during a background sync" set the button only, so the toolbar entry stayed clickable through a cron sync and could only produce the script's EX_TEMPFAIL skip. startSync() is now the single handler behind every route in, and the enabled state lives on the QAction, which reaches the toolbar, the menu and the shortcut at once. It also reports when no sync command is configured rather than doing nothing. The QPushButton is gone. It read as a Search button given it sat beside a text field, which is the user's own observation and the reason the toolbar one survives instead. Its unavailable-command tooltip moved to the action, since that is the only thing that says why the control is dead. Removing it left the query field running flush to the window edge, so the saved-query buttons move from their own row onto the query row. The bar is now framed by the account dropdown on the left and the saved queries on the right, the empty row is gone, and the thread list gains the space. The field also gains setClearButtonEnabled, which is Qt's own themed clear icon rather than a hand-rolled button. A "Search" button was considered and rejected: Return already runs the query. No overflow handling for [queries], which is unbounded. Three entries fit; item 23 already specifies buttons-plus-menu and is where that belongs. CLAUDE.md's architecture diagram named four widget classes that have never existed, QueryBar, SavedQueryBar, HeaderWidget and AttachmentBar. The query row and the message header are built inline. Corrected, and the components that do exist but were missing from it added. Tests: the new action test was verified red first and load-bearing by mutation. The old button test is deleted rather than repointed, being an exact duplicate of it, and the unobservable-lock test now drives the action. The clear button and the row layout were confirmed by hand; no test clicks the icon, which is a mouse path. Backlog: 45 done and reclassified as a defect rather than a cosmetic redundancy, 47 added for the bar.
2026-08-06test: stop two tests depending on the machine they run onDanilo M.1-0/+12
Both are the same class of defect: a test that reads real machine state and so passes or fails on circumstance rather than on the code under test. Item 38. Every MainWindow a test builds constructed its SyncMonitor on the live /proc/locks, so a window observed the machine's actual sync state and the sync-button assertion failed whenever the user's cron sync happened to be running. Cron fires every ten minutes and a run lasts ~35s, which is roughly 6% of runs, and it read as flakiness. SyncMonitor already took an injectable locks path for exactly this; MainWindow did not expose it. It does now, as a test seam rather than a config key: /proc/locks is not something a user would set, and a wrong value silently disables background sync detection instead of failing loudly. The monitor is still constructed and started, per the item's own constraint. Only the table it reads is redirected, to an empty file in the test's own temporary directory. Item 46. uiStateSurvivesARestart asserted a 940px width, and the offscreen platform reports an 800x800 screen. restoreGeometry() clamps to the available area, so the width came back as 798 while the 620 height, which fits, restored untouched. That asymmetry was the tell that persistence was fine and the test was wrong. The size is now 640x560 and carries no meaning beyond differing from the default. Verified by reproducing the original conditions rather than by waiting for them: the suite run under flock -n /tmp/mbsync.lock fails item 38's assertion with the seam bypassed and passes with it in place, and item 46 now passes under offscreen where it failed. One dud mutation is recorded in the backlog, writing an unparseable line into the injected lock table does not fail the test, because lockHeldIn() correctly finds no lock in it. Suite: 15/15 offscreen with the lock held, and green on Wayland except the pre-existing querycompleter screenshot flake, which fails to grab under Wayland and passes offscreen.
2026-08-06fix(sync): hold tag edits made during a background syncDanilo M.1-0/+38
A tag edit sent while another process holds notmuch's write lock does not fail: the read-write open blocks and then succeeds. Measured against Slackware's notmuch, 9.158s against a 12s hold, status SUCCESS. Since the worker is a single thread, that blocked open holds up every read queued behind it, so the message pane freezes on whichever thread was selected first and replays the queue when the lock releases. The window now defers instead. While SyncMonitor reports a sync running, a tag change is held rather than sent, and flushed when the sync ends. The optimistic update stands in the meantime, so the row keeps its tag and the edit still counts toward the unsynced indicator, which is what the quit prompt reads. The original diagnosis was that the open fails and the edit is discarded, and a retry was built on it. That was wrong: the error branch in notmuchworker.cpp is unreachable through lock contention. The premise was taken from a plausible-looking error path without provoking the condition, and measurement disproved it. The backlog entry records this rather than quietly correcting it. Verified by hand against a real blocking open, which the tests cannot reach: they drive the deferral through the meta-object and never take a lock. Both locks held for 100s with a tag edit made during the hold. Row kept the tag, status did not expire, indicator rose, window stayed responsive, held edit sent itself on release. The 2s SyncMonitor polling window is knowingly left open: a sync starting between polls is invisible for up to 2s and an edit there still blocks. SyncMonitor::lockHeldIn() would close it at the cost of one file read per tag action, and is recorded as the option to revisit. Also fixes revertPendingTagChange() clearing the entire undo stack after any rejected write, found while working on this. Backlog: item 37 done, and item 46 added for a test that fails only under the offscreen platform, where an 800x800 screen clamps a restored 940px window. Pre-existing and unrelated; the suite is green otherwise.
2026-08-04fix(sync): count unsynced edits as net state, not as writesDanilo M.1-2/+20
Item 28, reported by the user: open a thread, let the automatic mark-read remove `unread`, then press Ctrl+U to put it back. The indicator read "2 unsynced change(s)" with the mail store exactly where it started. The counter incremented per confirmed write and never decremented, so any add-then-remove of the same tag inflated it. Mark-read is simply the path that fires without being asked, which is why it surfaced there. The user's call was net state: an edit and its inverse are zero outstanding changes, because what the indicator answers is whether quitting now would strand work. A QHash keyed "<messageId>\n<tag>" replaces the int, and a pair that reverts is erased rather than stored with the new direction, so the map cannot grow without bound across a long session of tagging and untagging. Keyed per (message, tag) rather than per message: removing `unread` and adding `flagged` on one message are independent changes and must not cancel each other. A change carrying no message ids cannot be netted against anything and is counted separately, since dropping it would understate the indicator, which is the direction that costs work. Both properties item 18 established still hold: a successful sync clears everything, a failed one clears nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04feat(ui): make Delete a toggle, and expire transient status messagesDanilo M.1-0/+22
Items 16 and 33. Delete now removes the `deleted` tag when every selected thread already carries it, so pressing it twice puts a thread back. One direction for the whole selection, never per row: toggling each independently would leave a single keystroke with the selection in two states, which is worse than either outcome. Status messages are classified rather than blanket-timed, which is the substance of item 33. Events expire after six seconds and fall back to the last query's thread count: "Sync complete", "Nothing to undo", the skip notice, the per-action "Archive: 3 threads". State does not expire: "Searching...", "Syncing...", the selection count, and "Sync failed (exit N)", because an error must not vanish before it is read. A test caught a mistake in that routing. Making the per-action message transient armed the timer during select-all, since tagSelected() runs on a selection onSelectionChanged() had just described, and the count would then be replaced while it was still true. Writing the count now cancels any transient still counting down. QStatusBar::showMessage() would give the same behaviour but the label is added with addWidget() beside permanent widgets, so adopting it means reworking that arrangement. One timer beside the label is the smaller change. Both fixes verified by reverting them and watching the tests fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04feat(ui): disable Sync during a background sync, and blank the pane on EscDanilo M.1-0/+17
Items 29 and 32. 29 was a constraint item 27 specified and that shipped unbuilt: while a cron sync held the lock the Sync button stayed clickable, and pressing it could only produce the EX_TEMPFAIL skip. The progress bar and the button are now written by one updateSyncControls() taking both sync sources, which the item asked for by name: two independent assignments, one per path, means whichever finishes second wins, so a background sync ending would re-enable the button in the middle of a local run. Unknown re-enables the button, deliberately. It means /proc/locks could not be read and nothing was observed, so leaving the button disabled would strand it permanently wherever the lock cannot be seen. 32 adds a clear_pane action on Esc. It clears m_currentThreadId with the pane, not merely alongside it, or a threadLoaded still in flight would paint the thread straight back; and it cancels any pending mark-read, since a thread blanked from view must not be marked read two seconds later. The selection, the query and the undo stack are untouched. The one real risk in 32 was Escape being stolen from the query completer, the way Return was once lost to a window shortcut. Probed rather than reasoned about: a popup consumes the key before a window-level shortcut sees it, so the completer still dismisses. Every test here was verified by reverting the code it covers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04fix(sync): stop a local sync reporting itself as a background oneDanilo M.1-2/+12
Reported from hand testing: a manual sync ended with "Sync finished elsewhere" stamped over its own result. Ownership of a lock period was being decided when the lock was RELEASED, by asking MailSync::isRunning(). That question cannot be answered then: the process exits, so isRunning() goes false, and only afterwards does the next poll observe the lock gone. The guard therefore suppressed the message while the sync ran and let it through at the end, up to two seconds after onSyncFinished() had already said what happened. Ownership is now latched when the lock APPEARS, which is the moment isRunning() can still answer, and the matching release is swallowed. onSyncFinished() hands the latch back when it sees exit 75, because a skip means the lock was never ours: if a manual run and the cron run start inside one poll interval, the lock would otherwise be latched as local and that other run's completion swallowed with it. Also renames the messages to "Background sync running/completed" per the user: "finished elsewhere" reads as though the application does not know what is syncing the Maildir, when in fact it is the same script. The tests added here cover the external path and the Unknown state. They do NOT reproduce the reported bug, and were checked against a reverted fix to confirm that: staging it needs isRunning() true at the Running transition and false at the Idle one, which cannot be arranged in test_mainwindow without a configured sync command and a live child process. That was tried and abandoned, it left a process running for the length of the suite and popped a dialog. The ordering and the fix were instead verified against a standalone model of both code paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04feat(sync): notice syncs this window did not startDanilo M.1-0/+7
The user's cron runs mailsync.sh every ten minutes, so mail arrives and tags change while the window sits idle, and nothing here noticed. The script already holds an flock for the whole run, so that lock is the signal: no status file is needed, and a kernel lock cannot go stale because it dies with the process holding it. The observation method is the part that matters, and two of the three plausible ones are wrong. Both were probed on Linux 6.18 before any of this was written: - flock -n acquires in order to test, so polling every two seconds would open a window every two seconds in which a starting mailsync.sh is refused the lock and exits 75. It would cause the very skips the script reports. - fcntl(F_OFD_GETLK) never acquires and looks ideal, but reports UNLOCKED against a lock held by flock(2): separate lock namespaces in the kernel, which cannot see each other. A silent false negative. - /proc/locks is a pure read. It observes flock(2) correctly, and since it takes no lock at all it can never contend with the Xapian write lock notmuch new holds during the same run. Confirmed: 200 reads left the lock table unchanged and this process holding nothing. SyncMonitor keeps the parsing separate from the polling so the parsing is testable, and it reports Unknown rather than Idle where /proc/locks cannot be read: "no sync is running" is the claim that would let the window quit, so it must never be guessed. Verified against a real flock end to end, not only against synthetic content. It reports rather than refreshes. runCurrentQuery() clears the undo stack, the selection and the message pane, which is right for a query the user typed and hostile for one a cron timer fired: it would discard undo history and close the thread being read up to six times an hour, with no action from the user. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04feat(ui): make multi-select discoverable and stop it opening threadsDanilo M.1-0/+23
Multi-select already worked by Ctrl+click and Shift+click, but nothing in the UI said so and every tag action was keyboard-only, so the Ctrl+T tag dialog could not be reached with a mouse at all. Adds a select_all action on Ctrl+A, registered like every other action so it reaches the Edit menu, the shortcut reference and [keys]; a right-click menu on the thread list built from the same QActions rather than parallel copies; a selection count in the status bar, which is the part that actually teaches the feature by acknowledging a selection while it is being built; and a note in the shortcut dialog for the mouse gestures, which are view behaviour and cannot appear in the generated table. A selection gesture must not open mail or mutate it. Selecting several rows now blanks the message pane and cancels any pending mark-read, rather than rendering each row swept through and queueing it to be marked read. Two Qt behaviours shaped this, both established by probe rather than from memory: - selectAll() emits no currentRowChanged at all and leaves the current index invalid. - currentRowChanged is emitted BEFORE the selection model is updated. The second one caused two distinct faults. Collapsing a multi-row selection back to one row reported the old count, so the guard swallowed the load and the pane stayed blank; that case is handled in onSelectionChanged, which sees the true count. And a Ctrl+click taking the selection from one row to two also reported one, so the thread was loaded, blanked, and then painted back when the queued reply returned from the worker. By the third row the id was already cleared and the reply was discarded, which is why the fault presented as an off-by-one in the threshold rather than as a race. Tests cover the synchronous half. The late-reply guard has no test: MainWindow in tests has no worker, so threadLoaded never fires and the repaint cannot be reproduced in process. Verified by hand instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04feat(sync): dismissable log pane, progress bar, and a skip that is not a failureDanilo M.1-0/+25
Four changes to the sync UI, three of them from using it. The log pane could not be dismissed. It is hidden at construction and shown on failure, and nothing ever hid it again, so a single failed sync left it on screen until the application restarted. It now sits in a container with its own Close button, and is 200px rather than 120, because mbsync's output is wide and repetitive and the shorter pane showed too little of it to read. It still appears only on failure, which the user confirmed is what they want. A sync gives no feedback while it runs. The status bar now carries an indeterminate progress bar for the duration, and the Sync button is disabled rather than left looking live. The bar is indeterminate on purpose: mbsync reports no percentage and the script's output is unstructured, so a bar filling left to right would be inventing a fraction nobody knows. The log is also cleared at the start of each run, since leaving the previous run's lines in place makes a stale failure look like the current one. The lock skip was reported as a failure. mailsync.sh exits when another run holds the lock, and that was exit 1, which qtmaildir reads as "sync failed": it showed the log pane and, on the exit path, told the user their changes were still unsynced. With a cron timer every ten minutes, a click landing inside a run is routine and none of that is true. The script now exits 75 (EX_TEMPFAIL) and the window reports it as its own case, saying a sync is already running. On the exit path it stays open and says plainly that the other run is most likely carrying the changes over but that this window cannot see it finish, rather than guessing either way. That last hedge is what item 27 records: the application cannot see a sync it did not start. The user chose continuous polling of the lock file over the narrower "only while quitting" version, and the entry notes that the lock is already the signal, so no status file is needed, and that a kernel lock cannot go stale where a written file can. Verified against stub binaries: a second run while the lock is held exits 75 and says SKIPPED, while the run holding it completes at 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>