aboutsummaryrefslogtreecommitdiffstats
path: root/CLAUDE.md
AgeCommit message (Collapse)AuthorFilesLines
42 hoursdocs: close out compose and send, and drop the v1/v2 splitDanilo M.1-6/+29
Task 13 of item 123, the documentation that was left when the code was finished. The README claimed "sending is not implemented. Compose, reply, forward and send are planned for v2 and need a companion send script that does not exist yet", which had been false for three days. It also documented none of `trash`, `send_command` or the `[compose]` section: `trash` has been effectively mandatory since item 103 and its absence breaks Delete, so a user reading only the README could not configure a working install. Both are item 122, which closes with this. Every default in the new [compose] block was read out of config.h rather than from the prose around it, which was worth doing: send_html defaults to TRUE and the first draft of this documented it as false, and the key is autosave_interval_ms rather than the autosave_delay_ms that reads more naturally. The v1/v2 language is retired rather than updated. The project follows semver on its user-visible surface and those labels described a scope split that stopped being true when compose shipped. CLAUDE.md says so explicitly rather than deleting the sentence, since the older spec and plan documents still use the phrase and are historical records: read it there as "before compose". CLAUDE.md's architecture diagram listed none of the compose units. It does now, and it distinguishes the classes from the namespaces, because most of them are namespaces of free functions over values: the markdown, the MIME assembly and the account-picking are all testable without a widget. There is no FormatToolbar class, which the first draft of this diagram invented, and that is the exact mistake the paragraph under it already warns about for QueryBar and AttachmentBar. Also records item 148 from the notes: Ctrl+W does not close the composer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q2koFevoSxTLhfexJTZWQd
5 daystest(keys): a shortcut is a chosen subset, not a requirement, item 132Danilo M.1-3/+5
everyActionHasAShortcut() was written when the action list was short and every action plausibly deserved a chord. Item 123 adds six more, and under that rule each one consumes a key sequence whether or not anyone would ever press it. Rarely-used actions were being given chords to satisfy a test rather than because a user wanted them. everyActionIsReachableFromAMenu() is the rule that actually matters, and it already has the right shape: it is what stops an action shipping invisible, which is the defect item 103 found when `restore` was reachable by a chord and by nothing a user could see. Discoverability comes from the menu. A shortcut is an accelerator for the things done often. Nothing replaces the deleted test and nothing else needed changing: showShortcutReference() already prints `(unbound)` for an empty sequence, so the code anticipated this and only the test forbade it. Verified rather than assumed: with `tag_rules` unbound in defaultBindings(), an action that is registered, menu-reachable and carries an icon but has no chord at all, the full suite passes. Before this commit it failed. CLAUDE.md's "adding an action is FIVE places" paragraph is updated, including its count of how many are test-enforced, which drops from four to three.
5 daysdocs: close item 124, record the spinner defect, keep 121 openDanilo M.1-0/+21
Item 124 shipped and is proven: the index moved from a 7200rpm platter to NVMe with the mail staying at /data/Mail. Cold start went from 38618 ms to 668 ms for a complete walk, and 2008 ms to 50 ms for the first rows. Counts held at 49174 messages / 5594 inbox / 100 tags at every step, and Delete then Restore round-tripped through the account's trash by hand. Item 121 stays open, and its entry now says why. The measured platter figures are the evidence FOR building the indicator, not against it: a mechanical disk is the cheap configuration, not an exotic one, and a user with a large Maildir on spinning rust has nowhere to migrate to. Fixing one developer's hardware is not fixing the application. The constraint that pointed at item 124 as the answer is replaced by one saying the opposite, and prefaulting stays rejected on its own merits since it is worst on the low-memory machines most likely to have a slow disk. Item 125 is new, found by hand during the migration. mailsync.sh exits 75 (EX_TEMPFAIL) when another run holds the lock, and the sync indicator never clears; because an edit made during a sync is held until the sync ends, a Delete sat queued for a completion that could not arrive and looked like it had done nothing. Nothing was lost, since held edits reach the disk, but the user cannot tell that. Also records in CLAUDE.md that notmuch_database_get_path() is not the mail root, that database.hook_dir defaults into the index directory and silently stops post-new under a split config, and that the ordinary fixture layout cannot tell the two accessors apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
6 daysi18n: translate the trash strings, and document the trash keyDanilo M.1-6/+46
The three new strings from the cleanup action, translated into Italian. lrelease reports 383 finished and 0 unfinished; an unfinished string is silently dropped and ships as English inside an otherwise Italian UI. The changelog gains an Upgrading section for the mandatory `trash` key, the new optional `inbox` key and the `Del` binding, and states the consequence that cost real mail on this branch: a folder name that does not match the server is created rather than reported, mbsync adopts it, and under Create Both it propagates to the server where other clients see it. CLAUDE.md is corrected on two counts. Adding an action is five places, not four; the fifth is a menu, and nothing enforced it until this branch added everyActionIsReachableFromAMenu(). And the trash design is recorded: why the origin lives in a tag, why those tags are joined by a tab rather than a space, and why Restore resolves against the database rather than the model. Also repairs a race in deletingTwiceLeavesNoOriginTagBehind(). Its guard ran a query through the bar in the gap between the file rename and the tag writes, and a query bar run in that gap returns zero rows forever, since QTRY_VERIFY re-reads rowCount() and never re-runs the query. Measured 3 failures in 12 runs, each burning a full 15s timeout; 0 in 8 after asking the database directly, with the runtime down from 45s to 0.3s. 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-7/+188
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 dayschore: move the SlackBuild out to the my-slackbuilds repoDanilo M.1-3/+8
The packaging under assets/slackbuild/ had been stale since 0.7.0 and was deliberately not SBo compliant, carrying a _danix tag and a txz package type because it was never an SBo submission. It is not what the user builds from, so it lived here collecting drift: it predated the translations added in 0.23.0 and would have shipped a package without them. It now lives in the my-slackbuilds repo under qtmaildir/, rewritten from the SBo cmake template rather than carried across, at 0.24.0 and building from the GitHub release tarball rather than a cgit snapshot whose checksum can change when the tarball is regenerated. That repo has the workflow for bumping it and an nvchecker stanza that picks up releases from here. README points at the repo instead of describing a local directory, and keeps the build invocation so the section still tells a Slackware reader what to do. CLAUDE.md's release procedure drops the "do not bump this as a side effect" warning, which has nothing left to warn about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 daysfeat(i18n): wire translations and ship an Italian one (item 22)Danilo M.1-1/+48
Nothing loaded a translation before this: no QTranslator, no .ts file and no build rule, so every string was English whatever the locale said. The language now comes from the environment, LANG=it_IT.UTF-8, and any other locale runs in English as before. The audit found that the tr() discipline was largely holding, and found eight strings that could never be translated into any language. kFields[] in tagrulesdialog.cpp declared the rule-builder field labels with QT_TR_NOOP inside an anonymous namespace, where lupdate reports "tr() cannot be called without context" and extracts nothing, while the use site calls TagRulesDialog::tr() on them at runtime. From, To, Cc, Subject, Tag, Folder, Attachment and Date: the whole vocabulary of the rule builder, absent from every translation file that could ever exist. The source compiles and reads correctly; only lupdate reveals it. Q_DECLARE_TR_FUNCTIONS is not the fix for that case, though it is the fix for a free function calling tr(). Measured against lupdate: a class carrying the macro beside the array still extracts 0 strings, because the context must be attached to the literal itself. QT_TRANSLATE_NOOP names it explicitly and matches the tr() that already reads them, so the use site needed no change. Twenty configuration and keybinding warnings were not translatable either. They are user-facing, reaching the status label and the "Configuration problems" dialog. Config already had the tr() macro; KeyMap needed it. Translating the filter labels then broke startup_query, found in hand testing: a filter's name is a translated label, so `startup_query = Inbox` matched nothing where the filter shows as "In arrivo". The application opened a different view and reported the user's own working config as invalid. Resolution matches the generator as well now, which is stored in queries.json and identical in every locale; the translated name still works. The regression test installs a real QTranslator rather than a stub, since the bug lives in the gap between the stored string and the displayed one, and it writes a queries.json because the warning it asserts on is guarded by a non-empty saved-query list: without one the branch never runs and the test passes against a broken check. main.cpp's --help and --version stay bare printf, as they run before QApplication exists and no translator could serve them. Verified per the backlog's own standard, that lupdate output is the evidence rather than reading: 355 strings extracted with zero context warnings, where before there were 327 with eight; lrelease reporting 355 finished and 0 unfinished; the built .qm loaded in a standalone probe printing "From -> Da" and both Italian plural forms; and the install rule placing it where main.cpp looks. test_translations guards it and was mutation checked, failing on an emptied translation and naming the defect when QT_TRANSLATE_NOOP is reverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 daysdocs: close items 93 and 95, record their trapsDanilo M.1-0/+28
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.
11 daysdocs: close item 66, open 87 and 88, record the row-number trapDanilo M.1-4/+27
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 daysdocs: close item 36, narrow item 66 with a negative resultDanilo M.1-3/+26
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 daysdocs: close item 85, searching from the message paneDanilo M.1-3/+41
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(rules): stop a rule with a spaced name from vanishing on saveDanilo M.1-0/+13
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 daysdocs: split the closed backlog items out to a companion fileDanilo M.1-0/+9
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 daysdocs: the window size cannot be restored under a tiling compositorDanilo M.1-0/+19
Item 75 shipped claiming the rules window remembers its size. It does not, and no code here can make it. Hyprland tiles the window to fill its slot, so the size dragged belongs to the tile. saveGeometry stores frameGeometry beside normalGeometry and restoreGeometry restores the normal one, which stays at whatever resize() last set it to. Decoded from the real state file after a hand test: frame 2248x806, normal 760x664. The dialog restores 760 correctly and still opens tiled. Three diagnoses were tried before this one and each was disproved by a probe rather than argued away: that restoreGeometry rejected the blob as off-screen, that the layout overrode a geometry applied before the first show, and that a test could tell the broken and fixed versions apart. The last one matters most: the offscreen platform returns an identical frame for both, so a size assertion passed against the bug and a mutation restoring it left the suite green. That assertion is not reinstated. The column widths, which are what actually works, keep their test. The changelog and the backlog entry are corrected to say what ships, and CLAUDE.md gains both the tiling-compositor trap and the rule that the offscreen platform cannot test window sizing at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfix(rules): save the window size on Cancel and Save, not only on XDanilo M.1-0/+11
The geometry was saved from closeEvent, and neither dialog button sends one: Cancel calls reject(), Save calls accept(), and only the window manager's X button produces a QCloseEvent. So the size and the column widths were kept for the one route out of three that a user almost never takes, and a resize followed by Cancel came back forgotten. The save moves to a done(int) override, which both buttons funnel through and which QWidget::close() also reaches. The test that covered this passed against the bug because it asserted with close(). It now drives all three routes rather than trusting one to stand for the others, and shows the dialog before the close leg: close() on a widget that was never visible returns early without reaching done(), so that assertion would otherwise prove nothing. Both traps recorded in CLAUDE.md, since neither is specific to this dialog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysdocs: never run test binaries without the offscreen platformDanilo M.1-1/+19
tests/CMakeLists.txt sets QT_QPA_PLATFORM=offscreen for ctest only, so a binary invoked directly inherits the desktop's setting and throws real windows onto the user's screen. Each test function builds its own MainWindow, so one direct run of test_mainwindow flashes over a hundred windows. Launching the application unasked is the same problem: running it is a hand test and belongs to the user.
12 daystest(mainwindow): stop the suite reading the real kernel lock tableDanilo M.1-0/+13
Item 61. An init() fixture gives every test its own empty lock table in a QTemporaryDir, so no test observes the machine's real sync state. The failure was never intermittent in the usual sense: 0 failures in 30 runs with no lock held, 30 in 30 with one held. It presented as three tests failing that never mention syncing, and cost three misdiagnoses. The three tests that already used the seam each restored "/proc/locks" when finished, which was itself the defect: it handed the real table to whichever test ran next, so one test opting in re-exposed all the others. Those restores are gone and cleanup() leaves the temporary path in place. noTestCanSeeTheRealLockTable guards the fixture, since a silent revert would go back to failing for reasons no assertion mentions. Verified with the lock deliberately held: 3 failures before, 119/119 after, full suite 19/19. Mutation-checked by disabling the fixture, where the guard fails first and a real test fails behind it.
12 daysdocs: record the one coupling between qtmaildir and mailctlDanilo M.1-0/+60
The two tools are independent except for ~/.config/mailrules/rules.json, which has two independent implementations agreeing by test rather than by shared code. That is the only way work here can break mailctl, so it now has a named procedure: change both readers, bump the format version only for a breaking change, run both suites, and verify the round trip by hand since no automated test spans the repos. Also records that the backlog covers the mail system rather than this binary alone. Item 44 already shipped as commits in both repos and future format work will too.
12 daysdocs(backlog): record item 44 as doneDanilo M.1-0/+23
The tagging rules moved from the shell post-new hook to a shared JSON store both qtmaildir and mailctl read. Seventeen real rules were converted, each keeping its shell comment as a note, and the conversion was proved against the real index before anything was installed. Four findings are recorded in CLAUDE.md rather than only here, because they will outlive the item: a stored query carries no scope and the hook parenthesises it (a disjunction would otherwise escape tag:new and match everything); notmuch's parser rejects almost nothing, so a test asserting a provoked query failure fails against correct code; rule counts must count messages rather than threads; and a count request must not bump the query generation, which would blank the message pane.
14 daysfeat(panes): draw the pane marks from shipped SVGs, not font glyphsDanilo M.1-0/+15
Items 70 and 69, the second folded into the first as item 70's own size note predicted it should be. The panes drew their state marks as font glyphs: U+1F4CE for an attachment and U+2605 for a flagged thread, each with a fallback for a font that cannot render it. Both fell back to "*", so on such a font a flagged thread and one carrying an attachment were indistinguishable, which is a defect the fallback introduced rather than prevented. What a mark looks like was also the desktop's decision rather than this application's, and the panes are exactly where it should not be: the user asked for the toolbar and menus to keep following their icon theme while the panes stop. Six marks now ship in assets/icons/marks/: flagged, attachment, passed, replied and the two expander triangles. QIcon::fromTheme still resolves every toolbar and menu icon and was not touched. Licensing chose the shapes. The look came from a GPL3 icon theme, and this project is GPLv2-only, which are incompatible: GPLv2's "no further restrictions" clause bars shipping GPL3 assets in a v2-only work. The six were drawn fresh in the same idiom instead, with no path data copied. The idiom is generic: solid single-path silhouettes at 16x16 with no strokes. They are compiled in as string literals rather than loaded from a .qrc. src/CMakeLists.txt already records why resources belong to the executable: a qrc in the static library registers itself from a global initialiser the linker drops. The tests link the library, so a resource-based mark would be missing exactly where it needs asserting. assets/icons/marks/ stays the editable source. One asset serves both palettes. Every payload paints with fill="currentColor", which QSvgRenderer renders black rather than resolving, so Marks::pixmap composites the wanted colour with CompositionMode_SourceIn. A mark then takes the card's own pen colour and follows selection and the read/unread dimming without a second variant to keep in step. CardLayout reserves a rect per mark and CardDelegate paints into it. The marks were glyphs inside the subject STRING, so their width came free from the text metrics; as icons the geometry has to know they are there or the subject runs underneath them. The expander pill had the same trap, its triangle being a glyph in expanderLabel(), and now reserves that width explicitly. Item 69's part: passed and replied were words in the tag strip and are marks beside the subject now. The message pane's header carries the flagged and attachment marks next to the subject, per the user's decision that the right pane needs those two and only outside the message area. A duplicate that no test caught is worth recording. Every geometry assertion passed while a card showed passed as BOTH an arrow and a green tag chip: the chip filter had no reason to know a mark had appeared. It was found by rendering real cards to an image and looking at them. isDrawnAsAMark() is now one list consulted by both PillTagsRole and MessageOwnTagsRole, since two copies drifting apart is how a tag ends up drawn twice on one row and not at all on another. Fourteen tests: nine in test_marks, four in test_cardlayout, one in test_threadlistmodel. Mutation-checked at four points, each failing a test: the subject ignoring the marks, the flag not indenting the subject, the pill forgetting the triangle's width, and the recolour composite removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10docs: record the card list, and correct what CLAUDE.md claimsDanilo M.1-45/+75
The architecture section described ThreadListView as existing to paint a strip across columns. That was true until this change and is now the opposite of true: it survives only for the expander hit-test. Kept as one paragraph of history, since it explains the file's shape, but no longer stated as current behaviour. Two traps are recorded inverted rather than deleted, because the rule survived its own reason changing. The reply indent is still asserted on where the TEXT lands, but where visualRect lies has flipped: it used to report an indent the text did not have, and now reports none while the text is indented. And Q_ENUM is documented as insufficient for a queued Q_ARG, which cost a silently dropped sort argument. Item 60's recorded cause was wrong and is corrected in place. It was read off master, where the row arithmetic really is current.row() + 1; the branch had already fixed it a commit earlier with threadRowOf(). The entry stays, with the correction, because the reasoning was sound and the tests it demanded now exist. Items 20, 51 and 53 are marked built on the branch rather than done. Nothing is merged and the user has not seen it, which is the whole point of Task 10.
2026-08-10docs: record message rows, and the user's verdict on themDanilo M.1-9/+64
CLAUDE.md described a QTableView over a table model, which has not been true since the view port. Updated with the traps the port produced, each of which shipped a plausible-looking broken build before being caught: - A tree numbers rows per parent, so nothing may be keyed on a row NUMBER. - drawBranches runs before the row's cells, so an expander on a content column is painted over by the delegate's background. - setRootIsDecorated(false) removes the style's HIT AREA along with its indicator, leaving a glyph that renders and does nothing. - isExpanded and setExpanded are keyed on column 0. - A reply's indent must beat the account chip's width, and visualRect reports the indent correctly even when nothing is visibly indented. - paintEvent runs after the cells, so a full-row fill erases their text. Also the notmuch ownership rule, which is a double-free if undone: messages reached through a thread are freed with it, so walkReplies holds them raw against this file's own RAII convention. Item 20 is marked built, not done, and item 53 records why. The user's verdict on the finished result was that the table view does not fit the use, said with every cue in and working. That is a design finding rather than a defect: the item shipped exactly what its four decisions specified, and all four were the user's own choices. Recording it as a defect would misattribute the cause; recording nothing would leave the next session building on a rejected design. Item 53 carries the cause verified in code rather than guessed. A message row fills the same five columns as a thread row (threadlistmodel.cpp:275-283 mirroring :428-431), so replies land on the same rigid column boundaries as the threads around them, and the eye reads columns before indentation or tint. The reference the user gave has no column rules through its reply rows at all, and that absence is the one thing three added cues cannot supply.
2026-08-09docs: make the GitHub Release part of cutting a releaseDanilo M.1-0/+38
The repo reached v0.12.0 with fourteen tags and zero GitHub Releases, because every release had stopped after commit, tag and push. The user noticed and asked for the backfill, then asked that it not happen again. Records the whole procedure rather than the missing step alone, since nothing had written it down: changelog section, version bump, signed tag, push to both remotes, then `gh release create` with the body taken from that version's changelog section rather than composed fresh. Also records that the version choice follows semver on the user-visible surface pre-1.0, which is why 0.12.0 was minor rather than patch, and that the stale SlackBuild version is deliberately not part of this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07docs: record that QString::arg does not collapse %% into %Danilo M.1-0/+13
printf does, and the habit transfers silently. In generated CSS it is quietly destructive: a percentage written %% reaches the browser malformed, and a browser drops that one declaration and renders the rest, so the pane still paints and nothing looks broken. The 0.11.0 placeholder lost its mask, its glow and both radial gradients this way. Records the review lesson alongside it, which generalizes further than the rule: a geometry probe endorsed that layout because it measured only properties carrying no percentage. A probe that cannot see the thing that breaks reports success forever. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07docs: record how rendering probes mislead, and ThreadListViewDanilo M.1-4/+42
A session was spent chasing a defect that did not exist, because three separate rendering probes returned confidently wrong results and each was believed until contradicted. The specific failure modes are worth recording, since the next attempt will otherwise rediscover them: counting lit pixels cannot tell bold from regular in either direction, viewport()->render() returns blank images in several ordinary situations, and a "saturated pixel" threshold catches the antialiased edge of the selection highlight and passes whatever the code does. The bug that started it was not in the code at all: the desktop's Qt font was configured Bold in qt6ct, so every row rendered bold and setBold() changed nothing. Also records ThreadListView in the architecture diagram, with the three consequences of painting a row-wide strip in a per-cell view that are easiest to undo by accident. 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-3/+13
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-04docs: print the open backlog as a table at session startDanilo M.1-0/+32
Extends the reconciliation step rather than adding a second one: after diffing the user's notes against the backlog, render what is open as a table with a short description, the size, and one note saying what decides whether the item can be picked up now. Then stop. The user picks. Recommending a single item reads as though the choice were already made. Defects are flagged apart from enhancements, because they read alike in a numbered list and do not deserve equal billing: item 28 sat as "a counter is wrong" while the indicator was quietly lying about whether the user's work was safe to quit on. Records two gotchas found by running the grep: item 12 lives in the deferred table, which has different columns and no size, and a status cell is prose rather than a keyword, so "open, on demand" and "open, unspecified" both mean open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04docs: record items 28-35 from the user's notes, and how to keep them in syncDanilo M.1-0/+20
The backlog is downstream of the user's own notes, which they add to while using the app. Comparing the two turned up nine entries with no item here, two of them defects rather than enhancements: - Re-adding `unread` after the automatic mark-read counts 2 unsynced changes when the mail store is back where it started. The counter counts writes and never decrements, so any add-then-remove of the same tag inflates it (item 28). - The Sync button stays enabled while a background sync holds the lock. That was a written constraint of item 27 and shipped unbuilt, which is the strongest argument for doing this comparison at all (item 29). The rest are enhancements: a useful blank right pane, for which the user has produced the two logo images added here; a default button on the quit prompt, recorded as needing a repro because the code does set one; Esc to blank the pane; expiring status messages; a Maildir overview; and an automatic refresh after a sync. Item 35 is the one 0.8.0 deliberately did not build. A background sync reports rather than refreshes, because runCurrentQuery() clears the undo stack, the selection and the message pane. A non-destructive refresh is real work, so it is an item rather than a flag. Item 27 gains its outcome, including that its own proposed approach was the wrong one: flock -n acquires in order to test, so polling with it would have caused the exact skips the sync script reports. CLAUDE.md now carries the reconciliation step, since a note saying "X is broken" is a bug report that will sit in a personal file indefinitely unless someone goes looking. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04docs: record the two selection-model traps found in 987a9e7Danilo M.1-0/+21
currentRowChanged fires before the selection model updates, and selectAll() emits it not at all. Both cost real debugging, and the first caused two distinct faults in one change, so they belong beside the setCompleter trap rather than only in commit history. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04feat(sync): bring mailsync.sh here, print to stdout, exit truthfullyDanilo M.1-0/+10
The reference sync script lived in the companion mailctl project, which documents that it never calls it: sync and organization are separate there on purpose. qtmaildir is the only thing that runs it programmatically, and two of its behaviours were live bugs here, so it belongs in this repo where the constraints on it are. It printed nothing a caller could see. Everything was redirected to $LOGFILE, so MailSync captured an empty stream and the sync log pane stayed blank, a known limitation carried since 0.1.0. It now writes to both through tee: cron keeps its log, and the pane has something to show. It also ended in an unconditional exit 0, discarding statuses it had already computed. qtmaildir believes that exit code: a failed mbsync reported success, cleared the unsynced-changes count, and would have allowed a sync-on-exit to quit over a sync that never happened, which is exactly the case the exit prompt exists to prevent. It now exits with mbsync's status, or notmuch's when mbsync succeeded. The statuses move through files rather than shell variables because the run block is piped into tee, which puts it in a subshell where an assignment does not survive. Verified against stub mbsync and notmuch binaries in a fake HOME, so no mail or database was touched: output reaches stdout, the log still gets every line, and a failing mbsync or notmuch produces its own exit code rather than zero. Both properties are now recorded in CLAUDE.md, since they exist for this application's sake and an edit that looks like a cleanup would remove them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04fix(tags): complete every tag in the list, not only the firstDanilo M.1-0/+15
Reported by the user: in the Edit tags fields, the first tag suggested existing tags and the second did not. Typing a comma, a space and a letter offered nothing. QLineEdit::setCompleter hands completion to the line edit, which overwrites the completer's prefix with the widget's ENTIRE text on every keystroke. These fields hold a comma-separated list, so once one reads "unread, fl" that whole string is matched against the tag names, nothing matches, and completion silently stops after the first tag. Confirmed with a probe: the prefix really is "unread, fl" and the completion count really is zero. Attach with setWidget instead, which keeps the popup anchored without ceding control of the prefix, and drive it from the token under the cursor on every edit. Setting the prefix from a textEdited handler while leaving setCompleter in place does NOT work, which was the first attempt: the line edit sets it again afterwards. Accepting a candidate needed the same treatment, and is the other half of the fix. QCompleter's own insertion replaces the whole field, so taking "flagged" from the popup would have discarded every tag already typed. replaceCurrentToken() overwrites only the token under the cursor and keeps the separator's spacing, so the result is "unread, flagged" rather than "unread,flagged". This is the same defect QueryCompleter hit in c98b179. Having now cost two debugging rounds, it is written into CLAUDE.md as a Qt trap rather than a property of either class, together with the reason a test using setText() passes against it: setText does not drive a completer at all, so the keys have to be typed. Both new tests were confirmed to fail against setCompleter before the fix was kept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04docs: spec query bar completion, require translatable stringsDanilo M.1-0/+6
Design for backlog item 17. Completion covers query prefixes, tag values, date values, path values and mimetype values, each carrying a description so the bar documents the query language while it is typed. A new QueryCompleter class owns it, with the cursor-context tokenizer as a pure function so the parsing rules are testable without a widget or a database. NotmuchWorker gains an all-tags call, which did not exist. Addresses for from:/to: are out of scope: libnotmuch exposes no all-addresses call. Both prefixes still appear so the vocabulary reads complete. Account maildirs belong to path:, not folder:. Account::scopedQuery builds path:"<maildir>/**", and folder: is a different matcher in notmuch, against the folder name rather than the directory path. Also records the translatable-strings rule in CLAUDE.md. Existing code is not yet audited against it.
2026-08-04feat: own the message-pane zoom and persist itDanilo M.1-1/+16
Zoom was Chromium's, not the application's: the web view handled the keys natively and never told anyone, so there was no value to save. qtmaildir now owns it. Zoom in, out and reset are real actions, in the View menu and rebindable through [keys], and the factor is persisted to the UI state file. Ctrl+wheel over the body zooms and Ctrl+middle-click resets, both filtered by ancestry from an application-level filter: the events are delivered to an internal QQuickWidget the web view creates lazily, so a filter on the view itself never sees them. The factor is clamped to 0.5 - 3.0, and NaN, infinity, zero and negative values fall back to 1.0, since a corrupt state file must not be able to leave the pane unreadable with no visible way back. Both risks the plan flagged turned out not to exist, verified by probe rather than assumed. The application QAction wins over the web view's native zoom key, so the tracked factor cannot diverge from what is on screen. And the factor survives setHtml(), so the web view is the single source of truth and needs no reapply per render. A third finding is worth recording because it produced a wrong fix first. A probe using QTest::keyClick() reported Ctrl++ as a dead binding, and a test was written asserting that. Both were wrong: Ctrl++ is exactly what the '+' key emits on an Italian layout, confirmed against the real keyboard, and it is the shipped default. Whether a symbol needs Shift is a property of the layout, not of Qt, and keyClick() reproduces neither. The test now only checks that every default parses, and the comment in defaultBindings() says not to re-derive this from synthetic input. Ctrl+= is a second binding for reset, skipped when [keys] gives it to something else. Also fixes a pre-existing bug the new config key exposed. [general] entries were read as "general/<key>", which matches nothing: QSettings' INI backend treats a section literally named [general] as its own fallback section and strips the prefix. notmuch_config had therefore never worked. Both keys are now read without it; the file format is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04docs: add CLAUDE.md with build commands and architectureDanilo M.1-0/+124
Records the non-obvious constraints that require reading several files to reconstruct: the two-thread split with no notmuch pointer crossing it, generation counters instead of cancellation, the close-before-reopen ordering the exclusive write lock forces, and the web view's fail-closed interceptor contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>