aboutsummaryrefslogtreecommitdiffstats
AgeCommit message (Collapse)AuthorFilesLines
10 daysfix(startup): let startup_query name a built-in filter, and run itDanilo M.6-25/+224
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 daysfix(filters): label the flagged filter Important, and give the four iconsDanilo M.5-24/+132
Item 57 renamed the `flag` action to "Important" in 0.14.0, chosen over "Starred" partly because &I was free where &S collided with Mark spam. Item 93 then shipped the filter for the same tag as "Flagged", so one window offered both names for one thing. The generator keeps its own name, `flagged`: that string is stored in queries.json and matched against a closed set, so it is wire format rather than a label. The filters are QToolButtons now, like the Save button at the other end of the row, carrying a themed icon with the text beside it. Icon AND text for the reason the Save button already records: this row is a row of text buttons, so an icon alone reads as a different kind of control than it is. Theme icons rather than the shipped SVGs in Marks, because item 70's split is that the panes are ours and the chrome is the system's, and the query row is chrome. mail-mark-important matches the `flag` action's own icon, since the filter finds what the action marks. The icon test asserts a NAME was requested rather than that the icon resolved: QIcon::fromTheme returns null where no icon theme is installed, so isNull() would fail for a reason unrelated to this code. Dropping the setIcon call fails it. Widening the buttons to QToolButton broke eleven tests that reached them through findChild<QPushButton *>, which does not match a sibling type. The helpers and the filter lookups take QAbstractButton; savedQueryButton() stays on QPushButton, since the user's own queries really are those.
10 daysrelease: 0.21.0v0.21.0Danilo M.2-1/+9
10 daysMerge branch 'builtin-filters': four built-in query filtersDanilo M.10-103/+1045
Items 93, 95 and 90. The query row starts with Unread, Inbox, Flagged and Sent, shipped by the application rather than pinned by the user, and they compose with the account dropdown instead of resetting it. Item 90 is absorbed: the button that cleared the account selection is no longer a saved query at all. Item 95 was found by hand testing this and is not caused by it: no entry in the saved query overflow menu had ever been runnable, because Qt emits no triggered for an action that owns a submenu.
10 daysdocs: close items 93 and 95, record their trapsDanilo M.4-39/+167
Item 93 ships four built-in filters composing with the account dropdown, and absorbs item 90. Item 95 is the overflow-menu defect found while hand testing it: pre-existing, not caused by 93, and recorded as its own item rather than folded in. Two traps go to CLAUDE.md because they are still true of the code. Qt emits no triggered for a QAction owning a menu, which is why every entry in the saved query menu had always been inert. And a generator must be asked for one account's query rather than having its all-accounts query wrapped, since the wrap returns the right rows by accident of path: being hierarchical and a row-count test cannot tell the two apart. A third goes beside the existing rendering-probe warnings: visualRect reports a real height for a row scrolled out of the viewport, so a non-empty check passes while the pixel loop measures nothing and blames the wrong defect. The Upgrading note covers what a user sees: the row starts with four shipped buttons, a duplicate name means one of theirs is now beside a built-in, and their migrated Sent entry is unpinned for them rather than deleted.
10 daysfix(queries): make a query in the overflow menu runnableDanilo M.2-4/+90
An unpinned query could not be run. Its menu action carried both a triggered connection and a submenu of edit actions, and Qt does not emit triggered for an action that owns a menu: clicking the entry only opened the submenu, so the connection had never fired. It shipped unnoticed because the menu was the rarely-used half while the user's queries were pinned buttons. Item 93 moved every query into the menu, which is how it surfaced, and item 94 makes the menu their only home, so this is now the path that has to work. Running is an item inside the submenu, first and above a separator, with the edit actions below it. The entry keeps its submenu because an unpinned query must still be editable and deletable. The test asserts the Run item exists and is first, then that triggering it reaches the query, then that Edit and Delete survived beside it. Restoring the old wiring fails it on the first of those, naming the Qt behaviour rather than just reporting a wrong query string.
10 daysfeat(filters): put the four built-in filters on the query rowDanilo M.5-59/+486
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 daysfeat(filters): resolve built-in filters per accountDanilo M.3-1/+302
Item 93, the Config half. Four built-in filters, Unread, Inbox, Flagged and Sent, as generated entries in kQueryGenerators, which was already a closed set validated on load for Sent alone. resolvedQuery() gains an overload taking an account key, and that is what makes a filter compose with the account dropdown instead of fighting it. A generator is asked for the account's OWN query rather than having its all-accounts query wrapped in a scope: wrapping gives path:"a/**" and (path:"a/Sent/**" or path:"b/Sent/**") which returns the right rows only because path: is hierarchical, so a row-count test passes against it. The tests assert on the query string for that reason, and the mutation putting the wrap back fails two of them. An ordinary saved query ignores the account key and keeps resolving through its own stored account, which is the behaviour item 90 leaves alone. matchNothingQuery() exists because an empty query means "match everything" to notmuch: an account configuring no sent folder would otherwise give a button labelled Sent that shows the entire Maildir. Config gains Q_DECLARE_TR_FUNCTIONS for the filter names, which are button labels. The generator names are not translated: they are matched against the closed set and stored in queries.json, so translating them would make a file written in one locale unreadable in another. No UI yet, and no migration: the query row still builds from pinned saved queries.
10 daysdocs: open item 94, dropping pinned once the buttons are built-inDanilo M.2-0/+61
The user's end state for the query row is built-in filters only, with every saved query living in the menu. At that point SavedQuery::pinned has nothing left to decide. Blocked on 93 and deliberately separate from it: the four buttons have to be lived with first, and if one of them is wrong, pinning is the escape hatch, which has to still exist to be used. Recorded as a user-visible removal rather than a cleanup. pinned shipped in 0.18.0 as a checkbox in SaveQueryDialog and a right-click action, so removing it is a minor bump with an Upgrading note. The stored field is a separate decision from the UI, and leaving it in queries.json unread is both cheaper and reversible. The spec for 93 gains the ordering rule this resolves: filters first in fixed order, the user's pinned queries after them, and nothing configurable, since the mixed row exists only until 94 lands.
10 daysdocs: spec built-in filters as item 93, fold item 90 into itDanilo M.3-51/+278
Explaining item 90 to the user produced a reframing rather than a fix. The buttons and the "more queries" menu are two different kinds of thing sharing one mechanism: a filter narrows whatever the user is looking at and should compose with the account dropdown, while a saved query is a self-contained destination entitled to set the account itself. Nothing ships as a default today, so the buttons are whatever the user pinned, which the queries.json migration did to every [queries] entry. That drift is the defect. Item 93 ships four built-in filters, Unread, Inbox, Flagged and Sent, as generated entries in the closed kQueryGenerators set that already exists for Sent. The user's own pinned queries are unpinned rather than deleted once the buttons are confirmed working. Three findings from reading the code, all in the spec. A generator must answer per account rather than having its all-accounts query wrapped in a scope, or Sent becomes path:"a/**" and (path:"a/Sent/**" or path:"b/Sent/**"), which returns the right rows only because path: is hierarchical. Sent is flat and the other three are not, so the four match in scope and not in view mode. And m_accountBox has no signal connected to it, which is now a decision rather than an omission: changing the account runs nothing, the button is the verb. Item 90's section moves to the closed file, kept in full because its cause and the rules preview that motivated the reset are still true of the code.
10 daysfix(status): count threads as they arrive instead of "Searching..."Danilo M.6-46/+187
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.
10 daysdocs: reconcile the backlog with the user's notes, open 89 to 92Danilo M.1-1/+164
The 2026-08-15 pass over ~/Documents/Obsidian/note/notes on qtmaildir.md found four entries with no item here, and one already-closed item with a case that was never verified by hand. 89: runAutoSync() returns without re-arming the timer when a sync is already in flight, so an edit made after the running mbsync passed that account waits for a manual sync or cron. The second half of the same complaint, rows leaving the list mid-interaction, is a design question and is recorded as one rather than guessed at. 90: runSavedQuery() clears the account box for a query that names no account. The line is deliberate and its comment says why, so this needs a decision rather than a fix. 91 and 92 are unspecified, as the notes themselves say. Item 66's row gains the one case its fix should already cover: a single-message id: query whose card would not open is the same empty MessageIdRole failure, unverified against an id: query.
11 daysdocs: close item 66, open 87 and 88, record the row-number trapDanilo M.4-67/+175
Item 66 turns out not to have been the defect it was filed as. The pane was never blank: an unexpanded thread root rendered the CONVERSATION, and the same click rendered one message once the thread had been opened, because the model learned the root's message id only when the replies arrived. The user's step-by-step account is what separated the two halves; two probes against a real database had failed to reproduce the blank pane because there was none. Closed by carrying firstMessageId in the query and removing the conversation view, which the user asked for after being told the stubs not expanding was itself a defect and that the feature was being judged in a broken state. Two defects came out of it and are open. 87: auto mark-read still marks a whole thread, coherent while a root rendered the conversation and not any more. 88: threadAt(current.row()) answers about the wrong thread for a reply row, because a tree numbers rows per parent. 87 is blocked on 88 and the entry says why: a fix for 87 was written, mutation-checked, shipped and reverted the same evening after it marked an unrelated message read. CLAUDE.md gains the row-number trap as its own entry rather than leaving it implied by the item 20 note, plus the rule that a test for a write path must exercise the reply case: the reverted fix was green because it asserted on a root selection, the one case where row() is correct. The cid-prefixing note is corrected to say every caller now passes one message while explaining why the prefixing stays. The changelog carries a Removed entry and an Upgrading note, including that mark_read_delay_ms accepts a negative value to disable auto mark-read entirely, verified against config.h. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysRevert the message-scoped auto mark-readDanilo M.4-345/+10
Reverts bde7409 and 66f1159. The user hit the worst possible symptom: clicking one message marked a DIFFERENT, unrelated message read. The cause is in markCurrentThreadRead, which reads m_model->threadAt(current.row()). CLAUDE.md records this exact trap: a tree numbers rows PER PARENT, so a reply's row() indexes its siblings and threadAt() on it answers about an unrelated thread near the top of the list. The guards then compared the right ids against the wrong thread and let a write through for whatever message the timer's state named. That fault predates these commits, but they made it reachable and harmful: while the write was thread-scoped the mismatch was mostly masked, and scoping it to a single message turned it into "a random message is now read". Reverting rather than fixing forward. Marking the wrong mail read syncs out to the server and cannot be undone from here, so the safe state is the previous behaviour, which is too broad but predictable. The item 66 work in 4a4f82f stands: a thread root still renders one message. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysfix(read): repaint the card when one message is marked readDanilo M.4-4/+167
Follows the message-scoped mark-read. The user reported the write going out with nothing visible changing: the status bar counted an unsynced edit while the card stayed bold and the message pane still showed the `unread` tag, until the next query corrected it. sendMessageTagChange made no optimistic model update on purpose, because applyTagChange is keyed by THREAD and repainting a whole row for a one-message edit would claim every reply had changed too. That trade is right for an explicit tag edit and wrong for auto mark-read, where the visible change IS the feature and the delay exists to deliver it. ThreadListModel::applyMessageTagChange updates the message wherever it is held, as a child row and as `first`, and lets the thread's summary follow only when the answer is unambiguous: a thread reads as unread while ANY message does, so the tag is cleared from the thread only when no other message still carries it. For an unexpanded multi-message thread the per-message tags are not loaded, so the summary is left for the next query rather than guessed at. Mutation checked: without the call the card holds `unread` for the full timeout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysfix(read): auto mark-read touches only the message on screenDanilo M.2-6/+178
Reported by the user: selecting an unexpanded thread root marked every message in the thread read, replies included. maildir.synchronize_flags is on, so removing `unread` rewrites Maildir filenames and the next sync carries it to the server: mail the user never saw stops being unread everywhere. This was coherent while a root click rendered the whole conversation, because everything marked read had been displayed. Removing that view made a root render one message and left the thread-wide write in place, so the defect arrived with the previous commit. markCurrentThreadRead now sends m_currentMessageId, which is what the pane rendered, through sendMessageTagChange. The thread-level `unread` guard is dropped with it: a thread carries `unread` while ANY message in it is unread, so it would pass a read root under unread replies and send a write for a message already read. Scheduling still checks it, which keeps a fully-read thread from arming a timer. The test asserts on which worker entry point the window used, because reading tags back cannot answer this. Three earlier versions passed against the unfixed code: TagsRole is empty for a message row by design, MessageOwnTagsRole subtracts thread tags and drops marks so it can never hold `unread`, and raw node tags are not refreshed until onTagsApplied confirms, which lands after the assertion. Mutation checked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daystest(worker): cover the Sent view's matched-message branchDanilo M.1-0/+37
Asserted by hand until now, which meant asking the user to look at the one branch I had rewritten twice after misreading the libnotmuch API. Both directions in one test: a query matching only the reply reports the reply under withRecipients, and the same thread under an ordinary query still reports its opening message, so this is a Sent special case rather than a change of meaning everywhere. Mutation checked: disabling the branch makes a Sent row report a1@example.org, the thread's opener, where a2@example.org is the message that matched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysfeat(pane): always render one message, never the conversationthread-view-removedDanilo M.8-58/+237
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 daysdocs: close item 36, narrow item 66 with a negative resultDanilo M.4-49/+98
Item 36's section moves to the closed file and its three traps go into CLAUDE.md, which is where they will be read: the worker is unreachable by findChild, rowCount on an unexpanded thread row is 0 by design, and currentThreadId reports intent rather than content. The claim that this class of bug cannot be reproduced in test_mainwindow is now false, so it is corrected rather than left standing beside its replacement. One in-test reference to item 36 as a permanent limitation is reworded: bare-window cases still have no worker, but that is now a choice per case rather than a property of the binary. Item 66 stays open with the simple case ruled out. The negative result sharpens this entry's own candidate rather than contradicting it: the test drives setCurrentIndex, which updates the selection model synchronously, while the suspect guard turns on a real click not having done so yet. Two cheaper conditions are named as still unexcluded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daystest(mainwindow): give it a real worker, and probe item 66Danilo M.1-4/+199
WorkerBackedWindow builds a throwaway notmuch database, writes a config pointing at it, and hands back a Config. Opt-in per test: the fifty-odd existing cases construct a bare window and pay nothing. No production change. wireWorker() already builds the worker from m_config.notmuchConfig(), so a written qtmaildir.conf exercises the shipping path rather than a parallel one built for tests. Three findings, each of which had made an earlier version of this test worthless: The worker is unreachable by findChild. wireWorker() creates it parentless and moves it to its own thread, so the tests wait with QTRY_VERIFY on observable state instead of on worker signals. rowCount on a thread row is 0 until the thread is expanded, since children are populated by the expansion. hasChildren is the correct pre-expansion assertion and also proves the two messages threaded. currentThreadId() is assigned synchronously in the selection handler, before any worker round-trip, so it reports intent rather than content: the test passed with onThreadLoaded() disabled entirely. It asserts on MessageView::showingPlaceholder() now, checked blank before the click so the check after it means something, and mutation-confirmed to fail when the load is disabled. Item 66 does NOT reproduce here: selecting an unexpanded thread root in a two-message thread shows the message. Recorded rather than widened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysdocs: close item 84, spec item 36Danilo M.4-64/+223
Item 84's section moves to the closed file, recording that the fix was a split rather than a suppression, and that its mutation check reproduces the original hang at exit 124 rather than merely failing. Item 36 is specced and no longer "on demand": item 66 needs it. Two findings while writing it. The deliverable is a RED reproduction of 66, with the fix deliberately excluded, since that defect has never been isolated and designing a fix beside a hypothesis is how a wrong one gets locked in. And the item is smaller than it has read since 2026-08-04: wireWorker() already builds the worker from a config key, so a test writes a config pointing at the fixture and nothing in src/ changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysfix(startup): raise the config-problem modal outside the constructorDanilo M.4-9/+69
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 daysdocs: close item 86, excluding a value from a searchDanilo M.3-33/+52
Section moved to the closed-items file on the commit that closes it, per the backlog's own rule. It records the two decisions that are not recoverable from the code, and that the plan under-counted the signature change: three test files drive these signals, not one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysfeat(search): offer Exclude from search in both menusDanilo M.4-0/+67
Third entry in the message pane's submenus and in each details row, greyed rather than hidden when the query bar is empty, so the feature stays visible to someone exploring a fresh window. requestSearch refuses an Exclude with no query as well, so the guard does not rest on the menu's enabled state alone. Mutation checked: disabling that condition fails the new test on the blocked emit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysfeat(search): push the query bar's emptiness into the panesDanilo M.5-7/+30
The menus cannot read the query bar and must not. MainWindow already watched textChanged for the Save button; the same lambda now also tells MessageView, which passes it to the details dialog at construction, where it cannot go stale. Nothing consumes it yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysrefactor(search): carry SearchMode instead of bool extendDanilo M.9-35/+71
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 daysrefactor(search): add SearchMode, the type replacing bool extendDanilo M.1-0/+16
Introduced alone, ahead of the four signatures that change to it, so that change lands as one mechanical commit with no behaviour difference. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysfeat(search): add SearchTerm::excludeDanilo M.3-0/+72
Parenthesises both sides, as extend() does: unparenthesised, a disjunction in the query bar binds so the exclusion covers only its last term and leaves the excluded mail on screen, with nothing reporting an error. An empty existing query returns empty rather than the addition alone, which is where this deliberately differs from extend(). Excluding from nothing means the whole Maildir minus one value; the menus will grey the entry out and this is the second layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysdocs(plan): six-task plan for item 86, excluding from a searchDanilo M.1-0/+817
Ordered so each task leaves the suite green: the grammar first, then the SearchMode enum alone, then the four signatures as one mechanical change with no behaviour difference, then the guard, then the menus. Two traps carried from item 85 into the task that inherits them. The details dialog's mutation check hangs rather than fails, and a hung binary is re-run stale by a later ctest. The mutation revert is by hand, never from a backup file, which silently reverted a fix last session. Construction order verified rather than left to the implementer: registerActions() runs after buildUi(), so both pointers are live and no null guard belongs in the lambda. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysdocs(spec): design for excluding a value from a search, item 86Danilo M.2-31/+194
Two decisions the user made, both recorded with their rejected alternatives so they are not revisited. Excluding from an empty query would mean the whole Maildir minus one value. The menu entry is greyed rather than hidden when the query bar is empty, so the feature stays visible to someone exploring a fresh window, and SearchTerm::exclude returns empty for that case as a second layer against a caller that forgets the guard. The menus cannot see the query bar, so MainWindow pushes the fact down through MessageView::setHasQuery from the textChanged lambda it already runs for the Save button. A callback was rejected as an indirection with one implementation; silently doing nothing was rejected because a live menu entry that does nothing is worse than a greyed one. The backlog entry loses its inline approach and points at the spec, carrying the three constraints that decide whether it can be picked up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysdocs(backlog): record item 86, excluding a value from a searchDanilo M.1-0/+54
The 2026-08-14 reconciliation against the user's notes found one entry with no item here: the right-click search offers "search for this" and "add to search" but no way to add negatively. Cause verified in the code rather than copied from the note. Item 85 shipped the two operations as a single bool, built identically in messageview.cpp:566 and messagedetailsdialog.cpp:92 and branched on in mainwindow.cpp:1654, and SearchTerm has no exclusion form at all. So this is not a missing menu entry over an existing capability; there is no third state for an entry to select. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysrelease: 0.20.0v0.20.0Danilo M.2-1/+8
11 daysMerge: searching from the message pane (item 85)Danilo M.25-115/+1624
Five surfaces in the message pane offer a search built from what they show: the header's subject and date, its sender and recipients on a single-message thread, a tag chip, a body selection, and every header per message in the details dialog. Each offers Search for this, which replaces the query, and Add to search, which narrows it. The details dialog became labelled rows along the way, which the user wanted independently of this feature. Hand tested through every surface, including the case the parenthesising exists for: adding a sender to 'tag:inbox or tag:flagged' narrows it rather than widening it.
11 daysdocs: close item 85, searching from the message paneDanilo M.4-57/+159
Five surfaces in the message pane offer a search built from what they show, replacing the query or narrowing it. The details dialog became rows along the way, which the user wanted independently of this feature. Item 78 is narrowed to the rule shortcut alone and drops to S: item 85 built the menus and item 81 the seeded dialog, so both halves already exist. Its approach text is corrected too, since it claimed the thread list holds a usable sender and notmuch_thread_get_authors returns a display summary, not an address. Three traps recorded in CLAUDE.md: a modal dialog must close before the action it asked for runs, Qt::RFC2822Date validates the weekday against the date, and every query goes through SearchTerm so five surfaces cannot grow five quoting rules.
11 daysfix(details): close the dialog before the search runs, not afterDanilo M.1-4/+8
The connection is direct, so emitting first runs the query synchronously: the model clears and the pane blanks while the modal dialog is still up, holding the m_items it was built from. Closing first leaves no window in which the dialog describes a thread the pane has already dropped.
11 daysfeat(search): run a search asked for from the message paneDanilo M.5-2/+169
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(details): rebuild the message details dialog as rowsDanilo M.6-49/+430
A text box could not carry a per-value context menu without parsing displayed text back into structure, and the user did not want a text box. Each row now holds its own value, its message index and its query, built from the parsed message. Every value label states Qt::PlainText. The QPlainTextEdit this replaced was plain by design rather than by style: header values come from strangers, and a QLabel guesses the format under AutoText.
11 daysfeat(search): search for the selected body textDanilo M.3-0/+88
selectedText() reads the selection with no script injection; JavaScript stays disabled in the profile. The page's standard menu is kept and the entries are added to it below a separator. The quoting is tested through a function taking the text, so it needs no live web engine: a selection is arbitrary prose and can carry quotes, newlines and query syntax, none of which notmuch reports as an error.
11 daysfeat(search): offer the header's fields for searchingDanilo M.3-1/+207
The menu lists what is searchable rather than hit-testing which line of a rich-text label was clicked, which breaks as soon as the label wraps. The values are collected by the pass that renders the header, so nothing parses the markup back into structure. From, To and Cc appear only for a single-message thread, sharing the condition with the header's own display: a thread's recipient differs message to message, and the menu must not offer what the header is not stating. The test fixture's Date: header named the wrong weekday, which Qt::RFC2822Date rejects outright, so no date offer would have been produced from it.
11 daysrefactor(tags): name what the overflow chip's x actually isDanilo M.1-4/+7
The loop was assigning a variable it never read, overwritten on every pass and used only after, which reads as an accumulator and is not one. The overflow chip's position is derived where it is used instead.
11 daysfeat(tags): hit-test a chip in the tag stripDanilo M.4-4/+182
chipRectAt() is the single source of a chip's geometry, used by paintEvent and by the hit test, so the drawn chip and the clickable chip cannot drift. The +N chip yields nothing: it stands for a list of tags rather than one, so there is no single value a search could be built from.
11 daysdocs: fix a weekday in the plan's date fixturesDanilo M.1-3/+9
Qt::RFC2822Date validates the weekday against the date, so 'Thu, 14 Aug 2026' parses as invalid: that day is a Friday. Task 2 hit it and Task 6 carried the same wrong data. The failure is indistinguishable from the timezone-comment trap the date parse exists to handle, so the plan now names it.
11 daysrefactor(mime): expose the Date: header parse as MimeParser::parseDateDanilo M.3-5/+44
The date search needs it and the logic already existed inside a file-local function, including the fix for Qt::RFC2822Date rejecting a string that carries a trailing timezone comment. Extracted rather than rewritten, so the second caller cannot end up without that fix.
11 daysfeat(search): build notmuch terms for the right-click actionsDanilo M.5-0/+335
One place for the query grammar behind every search surface, with no widget involved so it is tested without a painter or a web engine. extend() parenthesises both sides. The query bar may hold a hand-written disjunction, and 'a or b AND c' binds as 'a or (b AND c)', which widens a search meant to narrow it and reports nothing.
11 daysdocs: plan item 85 as eight tasksDanilo M.2-8/+1993
Eight TDD tasks. SearchTerm and the date parse first, since both are pure logic testable without a widget; then one surface per task, the window's wiring, and the suite check. Two findings while planning, both now in the spec. The Date: header parse already exists inside a file-local function in mimeparser.cpp, complete with the fix for Qt::RFC2822Date rejecting a trailing timezone comment, so it is extracted rather than rewritten. And queryRequested is the right precedent but the wrong signature: these actions carry a replace-or-narrow flag, so a second signal sits beside it and the placeholder links keep their gate.
11 daysdocs: design searching from the message pane as item 85Danilo M.2-1/+263
Item 78 asked for a tagging rule built from something visible in a message. Brainstorming narrowed it: a saved query can already be promoted to a rule, so the road from "I see something interesting" to "a rule tags it" exists as search, save, promote. Searching is the missing step and the safe one, since a query costs nothing when it is wrong while a rule runs unattended against real mail. The search half splits out as item 85. Five surfaces gain a context menu with Search for this and Add to search: subject, date and From/To/Cc in the header, tag chips, body selection, and every header per message in the details dialog, which is rebuilt as rows rather than one text box. Item 78 stays open carrying the rule shortcut alone.
11 daysrelease: 0.19.0v0.19.0Danilo M.2-1/+18
11 daysdocs: record the modal that hangs test_mainwindow as item 84Danilo M.2-3/+60
Cause verified by attaching gdb to the hung process rather than inferred: showWarnings() raises QMessageBox::warning from the MainWindow constructor, and nothing offscreen can dismiss it, so any config problem in a test's fixture blocks the constructor forever. Not a defect in the application. The modal is right for a person and the code says why; the defect is that a test cannot dismiss it and the resulting failure is a silent hang rather than an error naming the cause. Corrects item 81's closing note, which blamed the missing maildir key itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysdocs: close item 81, saved query to tagging ruleDanilo M.3-28/+70
Also records item 83's fix and the warning banner in the changelog, which the earlier commit did not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daystest(queries): pin which saved queries offer a tagging ruleDanilo M.1-0/+64
Both halves asserted together, plus a guard proving the generated button carries a menu at all: a test for the absence of a menu item passes against no implementation, which item 82 recorded the hard way. The account section is load-bearing rather than scenery. A generated entry resolving to an empty query is skipped entirely, so without a configured maildir and sent folder the Sent button is never built and the assertion would pass by finding nothing. An account section missing maildir hangs the test outright, which is how this was found. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>