aboutsummaryrefslogtreecommitdiffstats
AgeCommit message (Collapse)AuthorFilesLines
10 hoursdocs: comment out the live example config in the READMEDanilo M.1-2/+2
The configuration block is presented as something to copy, and every optional key in it is commented out with ';' except two. Copying the block therefore activated whatever those two happened to say: three extra mimetypes nobody chose, and a sync command pointing at a path that does not exist on the reader's machine. Neither was harmful (extra_mimetypes only appends to the built-in list, and an unresolvable sync command just disables the button with a tooltip), but both are config acquired by accident rather than by decision. Comment them out verbatim. The extra_mimetypes line in particular has to stay legible rather than be deleted: it is the only worked example of the '|' and ',' separators, and the reason those two characters differ is subtle enough that the prose above it needs the illustration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 hoursfix(query): let the query bar keep Return instead of open_threadDanilo M.3-5/+100
Pressing Return in the query bar moved focus to the thread list and left the query unrun, so a query typed at the keyboard could not be executed at all. Return is bound to open_thread as a Qt::WindowShortcut, and a shortcut is dispatched before the focused widget ever sees the key. Qt withholds a plain-LETTER shortcut from an editable widget, which is why every other binding in the map was safe here, but Return is not a letter and gets no such protection: the action fired from inside the bar, its handler called setFocus() on the thread list, and QLineEdit::returnPressed was never emitted. Accept the ShortcutOverride for Return and Enter on the query bar, which tells Qt the focused widget wants the key as ordinary input and stops the shortcut being dispatched. Narrow by design, one widget and one key, so open_thread keeps working everywhere else in the window. Three earlier hypotheses were tested and disproven before this one, and two synthetic probes wrongly reported the binding as harmless: real input sends ShortcutOverride first and only dispatches the shortcut if nothing claims it, while QTest::keyClick skips that round trip entirely. The new test drives the override exchange rather than the keystroke and fails against the old code. The comment claiming no filter was needed said the letter rule covered this case; it did not, and it now says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 hoursfix(completion): stop the key filter handing itself the key it forwardsDanilo M.3-1/+156
Pressing Tab or an arrow key with the completion popup open crashed the application outright. QCoreApplication::sendEvent re-runs APPLICATION-level event filters. The navigation branch forwarded the key to the popup from inside a filter installed on qApp, so the very same event came back to the filter that had just sent it. The popup was still visible, the popupVisible() guard still passed, and it forwarded again: unbounded recursion ending in a stack overflow rather than in any diagnosable error. Reproduced at 9176 recursive QueryCompleter::eventFilter frames, with a standalone Qt probe confirming the re-entry independently. Guard the filter with m_forwarding, checked before the switch so it covers every branch rather than the navigation keys alone. A key the filter is itself redelivering now falls through to the popup instead of being claimed a second time. The existing tests missed this because they exercised the accept path without ever forwarding an event. arrowNavigationDoesNotRecurse drives Down through the grabbing popup and asserts the selection actually moved, so it fails on a fix that merely swallows the key; against the old code it takes the process down with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 hoursfix(completion): filter keys on the application, not the line editDanilo M.3-3/+208
Showing the popup takes focus away from the query bar and the popup window grabs the keyboard, so keys pressed while it is up are delivered to the popup. An event filter installed on the line edit therefore never ran at the one moment it had to, leaving Tab to move focus to the next widget and Return to reach the thread list and open a message. Install the key filter on the application instead, which sees events before any widget receives them. It returns immediately unless our own popup is visible, so it cannot affect keyboard handling elsewhere. The FocusIn filter stays on the line edit, where it is correctly scoped: it only fires with the popup down. Accepting a completion now also reopens the popup when the caret lands somewhere more can be offered, so taking "tag:" goes straight on to the tag list instead of needing a second complete_query. The chain stops on a stem that is already a complete candidate, which is what every accept produces. The mouse path chains identically. The previous tests passed against the broken code because they posted events straight to the line edit, bypassing the delivery path a real keypress takes. The new tests route keys through the active popup and run against a real X display; offscreen does not grab the keyboard and cannot reproduce this class of bug.
10 hoursfix(completion): drive the popup instead of QLineEdit::setCompleterDanilo M.3-6/+210
QLineEdit::setCompleter hands completion to the line edit, which then resets the completer's completionPrefix to the widget's entire text on every keystroke. The prefix has to be the stem, so once the query grew past its first token the whole-line prefix matched no candidate, the popup stopped appearing, and the two reported symptoms followed: nothing was there for Tab to accept, and Tab fell through to focus navigation. Attach the completer with setWidget instead, which keeps the popup anchored without ceding control of the prefix. complete() dereferences widget() unconditionally, so leaving it unset segfaults rather than degrading. Opening the popup then becomes ours to do on every edit. Extend the existing event filter to route the keys the popup needs while it is visible, and install it unconditionally now that it does more than the completion_on_focus case. Enter accepts a completion only while the popup is up, so returnPressed still runs the query when it is closed. The existing tests called acceptCompletion() directly and so never touched the widget, which is why neither bug was caught. Add four tests that drive the real widget path plus one covering both settings of completion_on_focus; the first two fail against the old code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 hoursdocs: document query completion config and shortcutDanilo M.2-1/+51
Records what shipped rather than what the plan drafted: path: is the only prefix beyond tag:/is:/date:/mimetype: that offers values, and completion_on_focus defaults to false. The extra_mimetypes syntax needs both separators explained, since ',' splitting is QSettings' own behaviour and '|' exists only because a description may contain a comma.
10 hoursfeat(completion): wire query completion into the main windowDanilo M.4-1/+99
Adds the complete_query action, whose registration the assertion in registerActions() has been demanding since keymap gained the name: a known action nothing implements would have been a silently dead binding. Also lands the QueryCompleter half of the manual trigger, which the keymap commit left behind: triggerCompletion() and the focus-in filter gated on completion_on_focus. Tags refresh at startup, after a sync, and after a mutation introduces a tag not already known, since that is the tag most likely to be typed again. onAllTagsReady deliberately drops the generation the signal carries: a tag list is not an ordered query result, so a later one is always at least as good as an earlier one and discarding on staleness could only throw away a good list.
10 hoursfeat(completion): render the popup with descriptionsDanilo M.3-0/+301
The free-form date hint is a footer label rather than a model row: a row would be filtered away by the first non-matching keystroke and could be selected and inserted, producing a query that errors. QCompleter::setPopup takes a QAbstractItemView, so the label cannot be laid out beside the view in a container widget. The footer sits in space reserved with setViewportMargins inside the list view instead. setItemDelegate must run after setPopup, not before: setPopup installs a plain QStyledItemDelegate of its own and discards whatever was already set, which silently drops the description column. Accepting replaces exactly the span the tokenizer identified rather than QCompleter's own completion prefix, which is a different span once a prefix or a range bound is involved. Four tests drive that path directly instead of through synthetic key events, since whether a key needs Shift is a keyboard-layout property and could not decide the question. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 hoursfeat(completion): select candidates per contextDanilo M.3-0/+209
tag: and is: share the tag list, since notmuch aliases them. path: offers each account maildir in both bare and recursive forms, the latter being what Account::scopedQuery builds and not something a user would guess. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 hoursfeat(config): add completion_on_focus and extra_mimetypesDanilo M.3-0/+123
Mimetypes are the one completion list with no enumerator, so the user can extend it. Entries append to the built-ins and a malformed one is skipped with a problem recorded rather than dropping the whole list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 hourskeymap: register complete_query bound to Ctrl+SpaceDanilo M.2-0/+13
KeyMap::defaultBindings() is the single source of truth for shortcuts: the menus, the shortcut reference dialog and loadDefaults() all read it, so registering here makes the binding appear in the reference and stay rebindable from [keys] without any of them disagreeing. Ctrl+Space is a modifier plus a named key, so it sidesteps the bare capital trap in normalizeSequence() and needs no Shift on any layout. Verified it parses to a single non-empty combination that round-trips to "Ctrl+Space", and it collides with no existing default. Note test_mainwindow now fails its everyKnownActionIsRegistered() assertion: MainWindow does not yet implement complete_query. That wiring is a separate task, and the assertion firing is the intended signal.
10 hoursfeat(completion): add the prefix, date and mimetype vocabulariesDanilo M.4-0/+142
Keywords are syntax and stay untranslated; the descriptions beside them are prose and go through tr(). The tables are free functions with no QObject to inherit tr() from, so the file declares a VocabularyStrings context with Q_DECLARE_TR_FUNCTIONS rather than borrowing QObject::tr, which would file every string under the QObject context.
10 hoursfeat(worker): list every tag in the databaseDanilo M.3-0/+66
Query bar completion cannot offer tag names without a way to enumerate them, and libnotmuch had no call wired up for it. Follows the existing generation-counter pattern; the result crosses the thread boundary as a QStringList. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 hourstest(completion): make the lower-bound span test discriminateDanilo M.1-3/+7
date:las..today put the caret at the end of the lower bound, where the whole-bound span and the typed-so-far span are both 3 characters long, so the test passed even against a tokenizer that truncates at the caret and never sees the range separator. Moving the caret mid-bound with date:lastweek..today separates the two: 8 under correct code, 3 under the truncating one. Mutation-verified in both directions.
10 hoursfeat(completion): complete both bounds of a date rangeDanilo M.3-7/+105
Each side of '..' is an independent value against the same model. Entries that are themselves ranges are withheld once a range exists, since date:1week....today is malformed. The token is read to its full extent rather than truncated at the caret: the separator deciding which bound is being edited can sit to the right of the caret. Stems stay caret-bounded so matching never uses untyped text.
10 hoursfeat(completion): complete nothing inside a quoted literalDanilo M.2-0/+33
subject:"foo bar has the cursor in free text, where offering keywords would be wrong.
10 hoursfeat(completion): recognise value context after a prefixDanilo M.2-4/+55
The replace span covers the value only, so accepting a completion never overwrites the prefix that selected it.
10 hoursfeat(completion): add the query cursor-context tokenizerDanilo M.5-0/+174
Prefix completion only so far: the token under the cursor, bounded by whitespace or an opening parenthesis rather than by the start of the line.
10 hoursdocs: add the query completion implementation planDanilo M.2-3/+1658
Twelve tasks, TDD throughout, tokenizer first: the parsing rules are where the defects will be and they need neither a widget nor a database to test. CompletionEntry gets its own header. Config needs it and QueryCompleter needs Config, so declaring it in querycompleter.h would make the two headers include each other. Also corrects the spec: NmTags already exists in nmraii.h, it does not need adding.
10 hoursdocs: make the mimetype completion list user-extensibleDanilo M.1-3/+44
Mimetypes are the one completion list with no enumerator and an open-ended set, so [completion] extra_mimetypes appends to the built-ins. Appending rather than replacing means a typo or a short list cannot leave completion worse off than the defaults. Entries are comma-separated with an optional description after '|'. The two separators differ because QSettings splits comma lists itself, so a description containing a comma would otherwise be torn into two entries. The other lists stay fixed: prefixes come from notmuch, paths from the configured accounts, dates are closed once symbolic and relative forms are covered, and tags are read from the database. A user-editable copy of any of them would only drift from its source. For prefixes specifically, the upgrade path is deriving the list from the installed notmuch, not making it editable.
10 hoursdocs: spec query bar completion, require translatable stringsDanilo M.2-0/+246
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.
10 hoursfeat(about): custom About dialog with icon, copyright and project linkDanilo M.1-7/+46
Replace QMessageBox::about with a QDialog laid out in two columns: the application icon on the left at 40%, the version, description, copyright and AI-assistance notice on the right at 60%. A full-width row below both carries the project URL. The copyright line, GPLv2 notice and AI-assistance disclosure were absent from the About window despite being present in the source headers and the README.
10 hoursbuild: point the SlackBuild at the 0.4.1 tarballDanilo M.1-3/+3
The URL and checksum are taken from the published snapshot rather than predicted: sbodl fetches it and reports "md5sum matches OK", and the tarball is confirmed to carry the QTMAILDIR_BUILD_TESTS option, so -DQTMAILDIR_BUILD_TESTS=OFF now takes effect where it was silently ignored with 0.4.0. The copy of the .info inside the v0.4.1 snapshot itself still names 0.4.0, since a snapshot cannot contain its own checksum. That copy is not the one anyone builds from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 hoursrelease: 0.4.1v0.4.1Danilo M.4-5/+18
Packaging only. The 0.4.0 tarball predated QTMAILDIR_BUILD_TESTS, so a package built from it accepted -DQTMAILDIR_BUILD_TESTS=OFF and ignored it. This tag is the first whose source honours the flag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 hoursbuild: add a SlackBuild under assets/slackbuildDanilo M.8-4/+218
Packages the application for Slackware, following SBo conventions with two deliberate departures: the tag is _danix rather than _SBo and the package type is txz rather than tgz, since this is not an SBo submission. sbolint reports exactly those two as errors and nothing else; the template comments it warned about are gone. Written against the install layout the build really produces, checked by staging it: one binary, one .desktop entry and one scalable icon, no libraries and no man or info pages. The template's .la removal, man and info compression and perllocal.pod cleanup would all act on nothing here, so they are left out rather than carried along as dead code. doinst.sh keeps only the desktop-database and icon-cache updates. The download URL and checksum are verified rather than assumed: sbodl fetches the tarball and reports "md5sum matches OK". Adds QTMAILDIR_BUILD_TESTS, defaulting to ON so the ordinary build is unchanged. A packaging build has no use for the test binaries, and building them pulls in Qt6::Test to produce nothing that ships. Note the 0.4.0 tarball predates this option, so with that source the flag is accepted but does nothing; the README says so. notmuch is the only dependency outside Slackware. Qt6 including WebEngine, gmime and cmake are all stock, which is what REQUIRES reflects. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 hoursrelease: 0.4.0v0.4.0Danilo M.3-1/+62
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 hoursfeat: make attachments reachable from the message paneDanilo M.7-3/+639
The attachment bar had been an empty placeholder since it was written: MessageView created it and added it to the layout, and nothing ever put anything in it. MimeParser had been extracting attachments the whole time and Attachment::saveTo() already carried the path-traversal guard, so the backend needed calling rather than writing. The bar holds one "Attachments (N)..." button whatever the count. One button per file was built first and was wrong: a thread with sixteen of them made the bar as wide as the window, pushed the splitter over and left the thread list a few pixels wide. The button opens a dialog listing message number, filename and size with a Save each, and a "Save all..." when there is more than one. Save all writes into a new subdirectory named "<date> <subject>" inside a parent the user picks, rather than dropping sixteen files loose among whatever is already there. Zipping was considered and rejected: Qt ships no zip API, so a real archive meant a new build dependency or shelling out to /usr/bin/zip at runtime, and a subdirectory answers the actual requirement. The picker names the subfolder before the user commits to a location. The subject is attacker-controlled and becomes a directory name, so attachmentFolderName() sits beside the other guards in mimeparser.cpp: it strips separators, control characters and leading dots, caps the length, and falls back to a generated name. Its test asserts that every hostile subject still resolves inside the parent directory. Two defects surfaced while using it, both silent: saveTo() overwrites an existing file, and several messages in one thread commonly attach the same filename. Saving that thread destroyed six of sixteen files while reporting all sixteen as saved. The batch path now uses saveWithoutOverwriting(), which appends " (2)" before the extension and keeps a compound extension whole. Qt::RFC2822Date rejects a Date header that carries a timezone comment, "+0200 (CEST)", which is legal per RFC 5322 and common in real mail. Qt refuses the entire string rather than ignoring the comment, so every such message lost its date prefix. Comments are stripped before parsing. Opening an attachment in its default application is deliberately not included: handing a file from a stranger to xdg-open is a different security decision from writing it where the user asked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 hoursfeat: show a paperclip for threads with attachmentsDanilo M.6-2/+155
An attachment was only discoverable by opening the thread. A narrow leftmost column now marks the threads that carry one. No new worker query is involved: notmuch applies the "attachment" tag while indexing, so ThreadSummary already holds what this needs. The marker is a glyph rather than an icon resource, which ships no new asset and inherits the row font, so it strikes through with a doomed thread like every other cell. It falls back to "*" where the system font cannot draw U+1F4CE, since an unrenderable codepoint reads as breakage rather than as a marker. Two silent Qt behaviours had to be handled, both found by probe: QHeaderView::restoreState() returns true for a blob saved against fewer columns and applies the old widths shifted one place right. Adding a column in front would therefore have mangled every existing saved layout with no error to detect it by. The column count is now stored beside the blob and a mismatch discards it, so the widths reset once on upgrade instead of landing on the wrong columns. QHeaderView's default minimumSectionSize is 58px on this platform, and setColumnWidth() clamps to it without reporting the smaller value back, so the column could not be narrow at all until it was lowered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 hoursdocs: add backlog items 15 to 17 from usage notesDanilo M.1-0/+103
Attachments turned out to be a real gap rather than a discoverability one. MessageView creates the attachment bar and adds it to the pane, but nothing ever populates it: m_attachmentBar appears nowhere else in the codebase, so it has never displayed anything. MimeParser already extracts attachments and Attachment::saveTo() already carries the path-traversal guard, so the backend needs calling, not writing. Item 16 makes delete a toggle, with the open question of what a mixed selection should do. Item 17 needs a new worker call, since there is no way to list tags today. Also records that this document's numbering and the user's own notes have diverged, so a reference to "item 13" stays resolvable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 hoursrelease: 0.3.0v0.3.0Danilo M.2-1/+52
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 hoursfeat: choose the startup query by nameDanilo M.6-3/+148
The app opened whichever saved query sorted first alphabetically, which is not a choice anyone made: [queries] is read through childKeys(), so savedQueries().first() means "Flagged" before "Inbox" before "Unread" rather than anything the user expressed. [general] startup_query names the entry to open and defaults to Unread, so a fresh install comes up on the unified unread list. Saved-query button order is untouched and stays alphabetical. A name matching no saved query falls back to the first one rather than starting with an empty view. That is reported as a problem only when the user actually wrote the name; the built-in default naming a query they never created is not something they got wrong, and warning about it would fire on every launch of a config that has no Unread entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 hoursfeat: own the message-pane zoom and persist itDanilo M.13-3/+385
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>
10 hoursfeat: persist window, splitter and column widthsDanilo M.4-6/+168
Resizing the window, the splitter or a thread-list column was undone by the next launch. State now round-trips through a separate settings file at ~/.local/state/qtmaildir/uistate.conf, written on close and read at startup. The state file is deliberately not the user's config: a base64 geometry blob does not belong in a hand-edited file, and rewriting that file on exit would drop its comments and key order, which QSettings does not preserve. Two details that are easy to get wrong: QStandardPaths::StateLocation appends both the organization and the application name, and both are "qtmaildir" here, so it resolves to ~/.local/state/qtmaildir/qtmaildir. The path is built from GenericStateLocation instead, matching Config::defaultPath(). Restore runs after buildMenus() rather than at the end of buildUi(): QMainWindow::restoreState() matches toolbars by object name and silently drops the position of one that does not exist yet. Every restore is conditional on a non-empty blob, so a missing or rejected state file leaves the built-in defaults instead of producing a zero-size window. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 hoursrelease: 0.2.0v0.2.0Danilo M.2-4/+33
10 hoursfeat: use the application iconDanilo M.8-4/+141
The icon was committed in a previous session and referenced nowhere: no qrc, no .desktop entry, no setWindowIcon. It is wired up now, as a window icon, a desktop entry, and install rules placing both into hicolor and share/applications. resources.qrc belongs to the executable rather than to qtmaildir_lib. A qrc compiled into a static library registers itself from a global initialiser, and the linker discards that object because nothing references it: the build succeeded, qInitResources_resources() was present in the .a, and QFile::exists(":/icons/qtmaildir.svg") still returned false at runtime. Verified loading at 16, 32 and 64 pixels after the move. Toolbar and menu actions take icons from the system theme by their standard names, so they match the rest of the desktop rather than shipping bespoke art. A theme lacking one leaves that action as text, which still works.
10 hoursfeat: let an account set its chip colour and labelDanilo M.4-2/+77
Two optional keys in an [account.<key>] stanza. color fills the chip, label sets its text. Both belong to the account rather than to [tagcolors] because an account tag is a different taxonomy: which mailbox a thread arrived in, not what state it is in. label is display only. "account-provider-work" is a lot of row for one bit of information, but the notmuch tag is never renamed, so existing queries and external tagging are unaffected. Unset falls back to the account key, and an empty label is ignored rather than rendering a blank chip.
10 hoursfeat: render tags as chips instead of a text columnDanilo M.13-15/+1052
Spelled out per row, tags ran to 500 pixels of largely repeated text and took most of the thread list's width. The column is gone; tags render as coloured chips split by what they actually mean. An account tag says which mailbox a thread arrived in, and draws as a chip in front of the subject. A functional tag says what state a thread is in, and those fill one row under the message pane. One row keeps the message area from shifting between threads with different tag counts, so whatever does not fit collapses into a +N chip that names the rest in its tooltip. TagColors resolves a colour by exact tag first, then by top-level prefix, so a single "shopping" entry covers shopping/amazon and shopping/nike while shopping/amazon can still override its own. That matters at 96 tags. Built-in defaults cover the usual state tags, and anything left unconfigured falls back to a hash of the name, stable so a chip does not change colour as the list scrolls.
10 hoursfeat: make every thread list column resizableDanilo M.1-7/+10
Subject was Stretch, which computes its own width and discards a drag, so it alone could not be resized. Every column is Interactive now. Nothing absorbs spare width as a consequence, so the view scrolls horizontally instead of squeezing columns when their total exceeds the viewport. Per-pixel, so scrolling does not jump a column at a time.
10 hoursfix: let the thread list columns be resized againDanilo M.1-4/+9
Tags, Date and From were set to ResizeToContents while fixing the pushed-off-screen Tags column. That mode computes the width itself and discards a drag, so the columns stopped being resizable. Verified: with ResizeToContents a request for 250px yields 150, with Stretch 478, with Interactive 250. The reorder alone already fixed the original bug, since Subject stretches and is last, so nothing can be pushed past it. Locking the other three was unnecessary and also blocked the saved-column-widths item, which needs widths a user can actually set. They are Interactive again, with starting widths a drag overrides.
10 hoursfeat: show that a tag action landedDanilo M.7-5/+218
Selecting a thread and hitting Delete changed nothing on screen, so there was no way to tell the action had stuck. The tag was always applied: applyTagChange() emitted dataChanged across the row, and the Tags column did update. But Subject was set to stretch while Tags came after it, so Subject took all free width and pushed Tags out of view. The feedback lived in the one column that could not be seen. Columns are now Tags, Date, From, Subject, with Subject stretching last so nothing can be pushed off the right edge. A thread tagged deleted or spam fills its whole row, muted red or orange with white struck-through text, through the background, foreground and font roles, so no cue depends on one column remaining visible. Strike-through accompanies the fill on purpose: it survives a theme that overrides backgrounds and reads without colour. Bold for unread still composes with it. Archive adds no tag, so an archived row is left unstyled for now.
10 hoursfix: keep the shortcut reference inside the screenDanilo M.1-12/+45
Fourteen actions in one table made a dialog taller than the display, which pushed its own title bar off the top. The rows are split into two columns of seven, with the closing note spanning both. QMessageBox is replaced by a plain QDialog. The message box wraps its text at a narrow fixed width, which broke every description into a column of single words and was most of the height: 719x1084 before, 1426x366 after.
10 hoursfeat: add menus, a toolbar and a shortcut referenceDanilo M.8-130/+439
Actions were a QHash of std::function dispatched by an event filter, which nothing could put in a menu. They are QActions now, bound from KeyMap so a [keys] override reaches the menus as well as the keyboard. Menu bar covers every action; the toolbar carries only Sync, Archive, Delete and Undo. Help > Keyboard shortcuts is generated from the actions, so it shows what the keys really do rather than a copy that drifts. spam and load_remote gained defaults, having been unreachable without a hand-written binding. The event filter is gone. Probing showed QAction shortcuts are dispatched before the focused widget sees the key, so they beat QAbstractItemView's type-to-search without one, and Qt already suppresses plain-letter shortcuts while an editable widget has focus. Dropping the filter's blanket guard also lets Ctrl+Q work while the query bar has focus. registeredActionNames() is derived from the actions rather than hand-maintained, so the two drift tests it needed are replaced by checks that a configured binding reaches its action. No confirmation dialogs: tag mutations still answer to undo.
10 hoursfix: bind shortcuts users can actually pressDanilo M.3-32/+225
Typing a capital emits Shift+<key>, but QKeySequence::fromString() folds the case of a bare letter away: "N" parsed to plain Key_N, a combination no keystroke produces. The N, F and G defaults (toggle_unread, flag and sync) therefore never fired, and neither would any hand-written capital in [keys]. normalizeSequence() rewrites a bare capital to Shift+<letter> and is shared by the defaults and the override pass. As a side effect "y" and "Y" become distinct keys rather than a collision that dropped one. Defaults move to modifier shortcuts throughout. A single letter cannot be a QAction shortcut without stealing that letter from every text field in the window, and the menus in the next commit need real accelerators. defaultBindings() is now the one source of truth for them.
10 hoursdocs: add post-0.1.0 usability backlog and app iconDanilo M.2-0/+354
Collects the items found while actually using 0.1.0. Two clusters dominate: state that does not survive restart (splitter, zoom, account selection) and actions reachable only by memorized keys. The plan is open by design rather than a fixed release scope. Numbering is stable so notes referring to an item keep meaning the same thing. Decisions recorded while triaging: - UI state goes to its own file, not qtmaildir.conf. That file is hand-edited and rewriting it on exit would eat comments QSettings does not preserve. - Auto-mark-read stays off the undo stack. An explicit toggle_unread action already exists, so Ctrl+Z need not undo an action the user never took. - Zoom is Chromium's, not ours. No setZoomFactor call exists in src/, so persisting it means taking ownership of zoom first. Icon is a tag rather than an envelope, since tagging is the core interaction and an envelope would not distinguish it from any other mail client. Verified legible at 16x16, which is where it is mostly seen.
10 hoursdocs: remove personal details from the verification recordDanilo M.3-20/+21
The manual verification notes named the real threads used to test against a live mailbox. Most of it was incidental, but one was not: the automated sender identified in items 12, 13 and 16-18 is a healthcare provider's patient-notification service, and naming it disclosed a medical relationship. Also removed a correspondent's username, a locatable GitHub thread id, a real maildir name in the spec's example config, and absolute home paths. Every finding is restated generically and none lost substance: "a message with 3 inline cid: parts" carries the same technical weight as naming the sender did. Counts and timings are kept deliberately, since those are the evidence behind the claims rather than anything identifying. Commit history and the v0.1.0 tag were checked and were already clean, so no history rewrite is needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 hoursdocs: add README, changelog and release procedurev0.1.0Danilo M.3-0/+351
README covers what the project deliberately does not do (no POP, IMAP or SMTP, no send in v1, no confirmation dialogs), the verified dependency versions, build and test commands, the full config format with the reasons behind its two surprises, the keybinding table, and the security posture of the message view. Two facts in it were checked rather than assumed: the `spam` and `load_remote` actions are registered but have no default binding, so they are documented as unreachable until bound; and the attachment path guard does exist as described. The Release build was also verified to pass all 11 test binaries, which matters because Q_ASSERT compiles out there and one of the cid: invariants leans on an assertion in debug. CHANGELOG.md follows Keep a Changelog and records 0.1.0 along with the current known limitations. docs/RELEASING.md records what semver means for a mail client (the config format and action names are the public interface) and the steps, including that the version is bumped in exactly one place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 hoursfeat: surface the version and adopt semantic versioningDanilo M.2-1/+9
The version was declared in the project() call and used nowhere. It is now generated into version.h from that single declaration, so nothing repeats the literal, and it reaches the places it is actually wanted: --version, --help, the window title, and QApplication. --version and --help are answered before the web engine schemes are registered and before QApplication is constructed. Printing one line does not need a GUI, and both must work on a machine where the GUI cannot open. Staying at 0.1.0 rather than calling this 1.0.0: under semver, 0.x is where the interface may still change, and for this project the interface is the config file format and the bindable action names. Both are one manual verification pass old. 1.0.0 becomes a deliberate decision to stop changing them under users. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 hoursAdd GPLv2-only license and per-file headersDanilo M.41-1/+1101
Confirmed with the maintainer as v2-only rather than v2-or-later. LICENSE is the official text from gnu.org. Every file under src/ and tests/ carries the matching notice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 hoursdocs: record manual verification items 16-20Danilo M.1-15/+66
Tag mutation (16-18) verified against three low-stakes 2023 notifications, with the index inspected before and after every step and the mailbox returned to its exact starting state. Bulk archive resolved two threads in one combined query, and undo re-resolved thread ids while those threads were outside the displayed result set, which is the case that design exists for. Sync (19-20) verified with the real mailsync.sh. start() does not block, a concurrent run is refused, and a run colliding with the flock returns exit 1 in 2 ms without corrupting anything. One caveat recorded rather than fixed: the script redirects its output to its own logfile, so MailSync captures nothing and the log pane stays empty even on failure. qtmaildir shows what the command emits and this command emits nothing; piping instead of redirecting in mailsync.sh would populate the pane with no change here. Twenty-one of twenty-two items now verified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 hoursdocs: record item 15 and summarise the manual passDanilo M.1-4/+41
Eleven of twenty-two items verified. Items 16-18 remain deferred until the tag-mutation path can be run with someone watching, 19-20 need a sync script, and 13 is unreachable with this mailbox and covered by a unit test instead. Records what the pass was worth: three defects, each living in the gap between components that were individually tested and correct. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>