aboutsummaryrefslogtreecommitdiffstats
AgeCommit message (Collapse)AuthorFilesLines
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>
11 daysfeat(queries): create a tagging rule from a saved queryDanilo M.1-0/+21
Right-click a stored saved query and the rules dialog opens on a new rule carrying its query, with the tags left empty and focused. Generated entries are excluded: their query is composed from the accounts, so a rule made from one would freeze a snapshot that goes stale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysfeat(rules): seed the rules dialog even when it is openDanilo M.3-3/+57
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>
11 daystest(rules): pin a seeded id against colliding with a ruleDanilo M.1-0/+40
The name comes from queries.json, so nothing had ever checked it against the ids in rules.json. Replacing the matched rule would retag mail against a query the user never associated with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysfeat(rules): open the rules dialog on a seeded ruleDanilo M.3-0/+109
The seed is a whole TagRule rather than a query string, so item 78 can reuse the same path to seed from a sender. It is a pending edit like one made with Add rule: appended, selected, Add tags focused, and written only on Save. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysdocs: plan item 81, saved query to tagging ruleDanilo M.1-0/+832
Six tasks, TDD, one commit each. Two things the spec did not anticipate and the plan has to carry: the rules dialog is non-modal and single-instance, so a second request must seed the dialog already open rather than being dropped, and the seeded id is uniqued against the working list rather than the file, since that list can hold unsaved rules whose ids collide just as hard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysdocs: spec turning a saved query into a tagging ruleDanilo M.2-38/+150
Item 81. A context-menu action on a stored saved query, seeding the rules dialog with the query and a sanitised id, tags left empty and focused. Three decisions worth the record. The backlog's own proposal, a checkbox in the Save query dialog, is rejected: it would make one dialog write both queries.json and the shared rules.json, and SaveQueryDialog is deliberately pure UI that writes nothing. Generated entries are excluded, since their query is composed from the accounts at runtime and a rule made from one would freeze a snapshot that goes stale when an account is added. And the empty tags are load-bearing rather than an omission: validate() refuses a rule that tags nothing, so the one field the user must supply is the one the dialog opens on. This also turns out to be a single-repo change. The rule it creates is an ordinary one, so mailrules.py is untouched; the backlog's note that item 81 spans two repos was about the file it lands in, not the work. Item 78 becomes a second caller of the same seeded-dialog path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysfix(rules): stop a rule with a spaced name from vanishing on saveDanilo M.8-137/+877
A rule named "justeat orders" in the field labelled Name was written to rules.json correctly and then dropped by every reader, because load() required ^[a-z0-9][a-z0-9-]*$ and the save path validated nothing. The rule stayed in the file, invisible in the dialog, never applied by the post-new hook, and the next save from the dialog would have deleted it outright. The asymmetry was the defect, not the pattern. TagRules::validate() is now the single predicate: the dialog refuses to save against it, and load() uses it to repair rather than drop, so a rule that fails is visible and fixable instead of silently discarded. - The typed name is sanitised into an id when the field is committed, so the field shows what will reach the file. uniqueId() suffixes a collision, since sanitising is many-to-one and can manufacture the duplicate that load() then drops. - An already-legal id is never rewritten, including one like "a---b" that sanitising would otherwise collapse. Rewriting valid ids would churn a file mailctl also reads. - A bad id loads repaired, with the warning kept: what is on disk is not what the hook runs until the file is saved back. Deliberately not mirrored into mailrules.py. The hook tags real mail unattended every ten minutes, where silently renaming an id is worse than dropping the rule; the file converges as soon as the dialog saves. No format change, so no version bump and no two-repo commitment. The load warning was not missing: it had been showing "1 rule could not be read and was skipped" on every open, in the same font and colour as the intro prose two lines above it, and read as more explanation. It is now a red banner beside Save, with an icon and a dismiss button, and it says the rules need attention rather than that they were skipped, which is no longer true. Dismissal is per-appearance only; a persistent one would re-hide the problem that went unnoticed for a session. Both new dialog tests were confirmed to fail with the sanitiser reverted, and the banner's styling, position and dismissal each fail under mutation. 20 of 20 suites green, 34 tests in test_tagrules. Closes item 83. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysrelease: 0.18.0v0.18.0Danilo M.2-27/+35
12 daysMerge branch 'saved-queries': saved queries in a file of their ownDanilo M.14-67/+2431
Items 23 and 82. Saved queries move out of the [queries] INI section into ~/.config/qtmaildir/queries.json, gaining the three things the INI could not express: an order, a pinned flag, and a per-query account scope. They can now be created, edited, reordered, unpinned and deleted from the UI rather than only by hand-editing a config file. The INI could not carry order at all: QSettings reads a section through childKeys(), which sorts alphabetically and never follows the file, so the buttons could not be arranged. Migration reads the old section once, marks every entry pinned so nothing moves on the first launch, and leaves the config file byte-identical, since rewriting it with QSettings would drop the user's comments and key order. Sent stops being a hardcoded button and becomes an ordinary entry carrying "generated": "sent". Its query is still composed from the accounts every time it runs, so correcting a folder name still needs no edit here, but the row now follows one rule instead of having one member the user could not move, rename or remove. Item 82 was found by hand-testing item 23 and fixed on the same branch: saving worked and nothing else did, so a saved query could be created and never changed. It also uncovered a defect that predated it, where rebuilding the row with deleteLater() left the stale row answering findChild(), which was already reachable from the save path. Hand-tested throughout: the migration against the real config, saving, unpinning, deleting, reordering, the read-only query field on a generated entry, and an unpin surviving a restart.
12 daysfix(queries): stop writing keys that carry no informationDanilo M.2-4/+62
Saving a generated entry wrote `"query": ""` and `"flat": true` alongside its generator. Both reload correctly, so nothing was broken, but queries.json is meant to be hand-edited and each redundant key is one more thing to read past. A generated entry has no query of its own, and the sent generator already implies flat. Written now only when they say something, which is the rule `pinned` and `account` already followed: `query` is skipped for a generated entry in favour of `generated`, and `flat` is skipped when the generator implies it. Omitting `flat` is only safe because loadSavedQueries() reapplies it from the generator, so the two are coupled: the mutation that stops reapplying it fails this test and one other, in both suites. That is deliberate, since a round-trip test can otherwise pass while quietly writing less than it reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfeat(queries): edit, pin and delete a saved query from the UIDanilo M.8-8/+455
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 daysfeat(queries): make Sent a saved query rather than a fixed buttonDanilo M.7-31/+413
The user asked whether the default queries could be unified with Sent. The answer runs the other way: Sent joins the saved queries rather than the saved queries becoming hardcoded. Inbox, Unread and Important are complete strings that depend on nothing and can never go stale, so generating them would buy nothing and would cost the four things the file just gained: reordering, unpinning, renaming and deleting. Hardcoding them would also make them undeletable, which is a regression for anyone who does not want one of them. Sent is different only in that its query CANNOT be stored: it is composed from every account's `sent` key, so a stored copy goes stale the moment a folder is renamed. That is a property of Sent, not of "default queries". Storing the GENERATOR rather than its output keeps both halves: `"generated": "sent"` still resolves from the accounts at click time, and the entry is an ordinary row that can be reordered, renamed, unpinned or removed. The row now follows one rule instead of carrying one member the user did not own. Two properties had to travel with the entry. The composed query, resolved through Config::resolvedQuery() so what lands in the bar is what actually ran; and FLAT mode, since a sent view lists messages and a threaded one folds every reply back into the conversation the user sent one message into. The sent generator implies flat rather than trusting the file to say so, because a hand-edited row would otherwise produce a threaded sent view. An unknown generator is reported but the row is KEPT: a later build may know it, and dropping it here would delete it from the file on the next save, which is the same data loss the unknown-field handling exists to prevent. A generator whose accounts configure nothing is skipped entirely, exactly as the hardcoded button was hidden rather than offering one that finds nothing. Eight new tests. The four pre-existing Sent tests reach this through migration and were left alone, which is what proves the migrated path still behaves; the new ones cover a STORED file, which is the path every launch after the first takes. Mutations: a generator resolving to nothing fails three, ignoring flat fails two, and not skipping an empty generator fails one. A rename test guards the property the change exists for, since anything keyed on the literal name "Sent" would break it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfix(queries): give Save query a clearer icon and a labelDanilo M.2-2/+35
document-save is the floppy/disk shape. It means "write a file somewhere", which leaves the user to guess what is being written, and next to a row of saved-query buttons it reads as an unrelated control. Saving a query is bookmarking a search, so bookmark-new is the icon every desktop already uses for "keep this for later". Verified to resolve with real art in the desktop's actual theme rather than assumed present. The button also shows its label now instead of the icon alone. It sits among text buttons, and an icon on its own next to them reads as a different kind of control; it is also the one action whose meaning an icon cannot carry, since "save" is a familiar shape whose question is always "save what?". The toolbar is unaffected and still follows the desktop's own button style. The label is the button's own text rather than the action's. "&Save query..." is menu phrasing, and setDefaultAction copies it verbatim, so the button rendered an accelerator ampersand and the ellipsis that promises a dialog. The action keeps both for the menu it lives in, and the test asserts the override survives setDefaultAction rather than trusting that it does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfeat(queries): right-align the More queries menu, and keep the row when ↵Danilo M.2-4/+84
nothing is pinned The saved-query buttons are the row's content and read as a set; the overflow menu is a control over that set, so it belongs apart from them rather than trailing the last button. Moving the stretch above it pushes it to the right edge. Doing that exposed a latent defect in the same function. The row hid itself when its layout held nothing but the stretch, which was written as a count of one and happened to be right only because the stretch went last. With the stretch moved the count changes, and the condition as written would have hidden a row holding only the menu: a config with saved queries but none pinned would have had no route to any of them, the menu buried along with the row. The check now counts the content added before the stretch and treats an unpinned query as content in its own right. Both are mutation-checked. Putting the stretch back at the end fails the alignment test, and restoring the old hide condition fails the new one, which asserts the row survives with nothing but unpinned queries in it. The alignment is asserted on the layout's own ordering rather than on x coordinates, since a geometry assertion would also pass for a row that merely ran out of width. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfix(queries): put the Save query button beside the query barDanilo M.6-4/+114
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.9-52/+755
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(queries): store saved queries in queries.jsonDanilo M.3-15/+566
First half of item 23. The storage moves out of the [queries] INI section into ~/.config/qtmaildir/queries.json; the UI that writes it comes next. The INI could not express order. QSettings reads a section through childKeys(), which sorts alphabetically and never follows the file, so the saved-query buttons could not be arranged and config.cpp carried a comment saying a hand-rolled parser would be needed to change that. queries.json is an ordered array and nothing sorts it on load. That also makes room for the two fields the save dialog needs: pinned, which decides whether a query is a button or a menu entry, and account, which scopes it. account stores the account KEY, not the maildir path, so it does not duplicate config that already lives in the account section and go stale when the user edits it. Config::resolvedQuery() composes through Account::scopedQuery(), whose parentheses are load-bearing: path:... and a or b binds as (path:... and a) or b, so an unparenthesised disjunction escapes its scope and matches every account. A key naming an account that no longer exists resolves to the bare query rather than a scope built from an empty maildir, which would be path:"/**" and match everything. Migration reads [queries] once, when queries.json is absent, marks every entry pinned so the query row does not empty on the first launch after an upgrade, and leaves the INI section untouched. Stripping it would mean rewriting a hand-edited file with QSettings, which drops comments and key order across the whole file. The format follows rules.json in shape only: a version and unknown fields preserved at both levels, so a file written by a later build survives a save from this one. None of its two-implementation machinery is here, because queries have exactly one reader; the version constant says so where a future reader will look. A file whose version this build does not know is refused AND blocks the save, so a newer document is never overwritten with a lossy reading of itself. Twelve tests, each checked against a mutation that puts the corresponding bug back: sorting on load fails three of them, stripping the INI section after migration fails the byte-identical assertion, concatenating the scope without parentheses fails the disjunction test, and dropping unknown-field preservation fails the round trip. The migration test compares the INI file's BYTES rather than re-reading it through QSettings, which would have passed against a rewrite that kept every value while dropping the comments. startup_query still resolves by name, but its fallback now returns the first entry in the user's own order rather than the alphabetically first one. That is user-visible for a config whose startup_query matches nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysdocs: spec the saved-query file, and reduce item 23 to a pointerDanilo M.2-105/+228
Item 23 had grown past what a backlog entry should hold: a storage format, a migration, a dialog and a layout change. This document's own rule says a fully specified item moves to specs/ and leaves behind the two or three things that decide whether it can be picked up, the way items 53, 63 and 76 went. The entry now carries the observation, the three deciding constraints and the relation to item 10, and points at the spec for the rest. The spec pins what was still loose. The JSON is an ordered array, since the ordering is the whole reason for moving off [queries], and nothing may sort it on load. A query's account scope stores the account KEY, the INI group suffix, rather than the maildir path, so it does not duplicate config that already lives in one place and go stale when the user edits it; the scope then composes through Account::scopedQuery(), whose parenthesisation is load-bearing for the same reason it is in the rules hook, an unparenthesised disjunction escapes its scope and matches every account. Migrated entries are pinned, so the query row does not silently empty on the first run after upgrade, and migration order is alphabetical because that is genuinely all the INI knows. Sent stays out of the file: it is generated from allSentQuery() rather than stored, and folding it in would mean writing a per-account path query into stored config, which is the duplication the account-key decision just rejected. The testing section is written against the traps already recorded in CLAUDE.md. The migration test asserts the INI file is byte-identical rather than re-reading it through QSettings, which would pass against a rewrite that preserved values while dropping comments; the round-trip test asserts order, which is the property the INI could not provide; and the dialog is left to a hand test, because the offscreen platform cannot assert sizing at all and a Cancel goes through done(int) rather than closeEvent. Every code reference in the spec was checked against the files rather than copied from the entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysdocs: move item 23's saved queries to a JSON file of their ownDanilo M.1-20/+72
The user proposed managing saved queries the way the tagging rules are managed, in a JSON file rather than in the INI. It is a better answer than either option the entry had been weighing, a per-query pinned flag or a [general] pinned_queries list, because those each solved one problem and this solves three. Order is the one that could not be solved any other way. [queries] is read through childKeys(), which returns keys alphabetically rather than in file order, so the saved-query buttons appear alphabetically today and there is no way to arrange them; config.cpp already carries a comment saying a hand-rolled parser would be needed to change that. A JSON array is ordered intrinsically. On top of that the document has room for the pinned flag the two tiers need, and for the per-account scope the save dialog wants, which SavedQuery has nowhere to put: it is {name, query} and nothing else. The entry takes the shape of rules.json but explicitly not its machinery. rules.json is JSON because two independent implementations have to agree on it, this repo and mailctl's mailrules.py, and the unknown-field preservation and version handshake exist to keep them from destroying each other's writes. Queries have one reader, so only the versioned-document-with-unknown-fields part is worth carrying over. Migration reads [queries] once when queries.json is absent, writes the JSON, and leaves the INI section in place. Stripping it would mean rewriting a hand-edited file with QSettings, which drops comments and key order across the whole file and is the exact loss this decision was made to avoid; leaving it costs a few stale lines and keeps a downgrade working. Reading both forever was rejected as two sources of truth for one thing. This also retires the open question the entry had carried since 2026-08-04, where the write should go. Both of the original answers were poor, one machine-writing the user's hand-edited config and the other filing user intent as window state under ~/.local/state. A machine-written JSON document beside the hand-written INI is the cleaner split, in ~/.config so it lands in a config backup. Two constraints recorded that the build would otherwise meet late. startup_query names a saved query and must keep resolving, and its "first entry" fallback quietly changes meaning from alphabetically-first to first-in-the-user's-order, which is user-visible and belongs in the changelog. And the README documents [queries] in three places, one of which explains the alphabetical button order this change removes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysdocs: record the user's design for item 23, and split item 81 out of itDanilo M.1-19/+91
Item 23 said only that a query could not be saved from the UI, and left the presentation as a one-line sketch. The user described what they actually want: a Save query button beside the search bar, opening a dialog that takes a name and an account scope; saved queries split into two tiers, with a few kept visible as buttons and user-made ones behind a menu; and the button row moved onto a row of its own once it no longer holds everything. Two things came out of writing it down. There is no built-in default query set in the code at all: every entry in [queries] is user-written and renders identically, and Sent is the lone exception because it is built from allSentQuery() rather than living in [queries]. The two tiers therefore need a mechanism that does not exist yet, either a per-query pinned flag or a [general] pinned_queries list, and that config format choice is the one decision left on the item. The design also settles the question the entry had left open: a query the user named and scoped in a dialog is intent rather than machine state, so it goes in qtmaildir.conf beside the hand-written ones, at the cost of QSettings reformatting a hand-edited file on first save. The README has to say so. The save-as-a-filter half is split out as item 81. A saved query is a view and costs nothing if it is wrong; a rule is applied to real mail by the post-new hook every ten minutes and lives in the rules file that this repo and mailctl implement independently. Folding it into 23 would make a presentation change carry a two-repo commitment, so 23 can now ship without it. Item 81 records the constraints it will hit: a stored query carries no scope and the hook parenthesises it, which matters more here than usual because a query saved for a view is often a disjunction, and the hook refuses to remove unread or inbox, so the dialog must say so rather than failing silently. Item 23's relation to item 10 firms up as a result. Account scope in the save dialog answers item 10's remaining half as a side effect, so the entry now says that outright, and says not to reopen item 10 to do it: the user postponed it and asked that the rest not be proposed unprompted. Also corrects a stale reference. The entry claimed a SavedQueryBar class shows the saved queries; no such class exists or ever has, and the buttons are built inline in MainWindow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysdocs: split the closed backlog items out to a companion fileDanilo M.4-4511/+4570
Item 73. The backlog kept every item's full Observed/Cause/Approach section forever, including the sixty-eight that are closed, and had reached 5056 lines: past the point where it could be read in one pass, and past the point where a tool could open it at all. The closed sections move to 2026-08-03-post-0.1.0-usability-closed.md, taking the backlog to 570 lines. The status table stays where it was and remains the index of all 80 items, so a closed item keeps its row, its date and its outcome beside the open ones; only its evidence moved. Nothing was renumbered and nothing was deleted, which the item required: the numbering is cited from commit messages, from CLAUDE.md and from the specs, and both files share one sequence, so item 42 is `## 42.` in whichever file holds it. The split was done by script and verified by set difference rather than by reading: every non-blank line of the original appears in one of the two files, zero missing, and the only lines not in the original are the new file's header. All 80 numbers resolve, every open item has its section in the backlog, every closed one in the archive, with no duplicates and no orphans. Two things the item's own approach did not anticipate. Three cross-references said "see below" and their targets had just moved, so rows 60 and 75 and the header's note on item 20's parked branch now say where the entry went. And the cause was never the fifty done sections, it was that nothing moved a section on the day its item closed; doing this once buys a few months and then item 73 returns. The rule in "Adding to this document" now requires the move on the closing commit, and CLAUDE.md tells a future session that grepping the backlog for a closed item's evidence will find the table row and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysrelease: 0.17.0v0.17.0Danilo M.2-1/+31
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>