summaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
AgeCommit message (Collapse)AuthorFilesLines
6 hoursfeat(tags): add an Edit tags dialog on Ctrl+TDanilo M.1-1/+33
Five hardcoded tags were the only ones reachable from the UI: archive, delete, spam, flag and toggle_unread. For an application whose purpose is organising mail by tag, applying any other one meant leaving for a terminal. Item 26 of the usability backlog, raised by the user asking how to add a tag and finding they could not. One dialog rather than separate add and remove actions, at the user's choice: filing something under a new tag while dropping inbox is one thought, not two. Type tags to add or remove, comma separated, or clear a checkbox to drop a tag already on the selection without retyping its name. Both fields complete against the tag list MainWindow already holds for the query completer. Completion is a guard against typing shoppping beside shopping, never a whitelist: inventing a tag is the entire point, so any valid name goes through whether or not it exists yet. Tri-state checkboxes carry the multi-thread case, and are where the risk is. A tag on some selected threads shows partially checked, and leaving it alone changes nothing; the opposite reading would silently tag threads the user never looked at. A tag already on every thread and left checked is likewise not a change and is not sent as one. Tag names are validated before anything is applied, through a free function so the rules are testable on their own. Empty, a leading dash (notmuch's CLI reads it as removal, making such a tag a trap), whitespace and control characters are refused by name and reason. Nothing is applied until the whole set passes, since the user cannot tell which half of a partial change landed. TagDialog is pure UI: handed the vocabulary and the current state, returning two lists, contacting no worker. That is what lets its fifteen tests run without a notmuch database. Integration is a single call to the existing tagSelected(), so undo, the optimistic model update, the combined multi-row query and the completer refresh for a brand-new tag all come for free. One test assumption was wrong and the code was right: a case asserted that QStringLiteral("null\0byte") truncates at the null and reads as empty. It does not, so the null is caught as a control character. The test was corrected rather than the validator. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6 hoursdocs: add item 26, no way to tag from the UIDanilo M.1-0/+43
The user asked how to add a tag to a message and the answer is that you cannot: every tagging action writes a hardcoded name, so archive, delete, spam, flag and toggle_unread are the only tags reachable from the UI. For an application whose purpose is organising mail by tag, that is a hole worth its own item rather than being folded into item 25. The plumbing is already there. tagSelected() takes arbitrary add and remove lists, applyTagsToThreads() handles multi-row selections in one query, undo works, and the completer already holds every tag in the database, which is what makes completing the dialog's input the obvious guard against creating 'shoppping' beside 'shopping'. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6 hoursdocs: add items 24 and 25 from the user's notesDanilo M.1-0/+60
Right-click actions on the thread list, and select-all for bulk tagging. Item 24 records that there is no context menu anywhere in the application: no contextMenuEvent override and no CustomContextMenu policy in src/. The actions themselves already exist as QActions, so the work is presentation, with one trap worth naming: right-clicking does not change the selection in Qt, so a menu built naively would act on the row under the cursor while the user is looking at several selected rows. Item 25 is smaller than it sounds, and the entry says why. The thread view is already ExtendedSelection and tagSelected() already acts on every selected row through one combined query, so Ctrl+click bulk tagging works today. What is missing is a select_all action, which does not exist for the list, and any indication that multi-select is possible at all. It also carries a caution: select-all over a 10k-thread query turns a rare accident into a routine keystroke, and the combined-query design should be measured against a real query before the binding ships rather than assumed to scale. Both are the user's own observations; the plan did not carry either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6 hoursfeat(sync): show unsynced edits and offer to sync on exitDanilo M.1-2/+53
Tagging changes the notmuch index at once, but the mail store only hears about it on the next sync, and nothing said so. Quitting with tagging outstanding was silent. Items 18 and 19 of the usability backlog, built together because the second needs the first's counter. The counter cannot be QUndoStack::isClean(), which is the obvious candidate and the wrong one: the undo stack is cleared on every query, since its entries refer to rows the new result set discards. Tag a thread, run any query, and the stack is empty while the change is still unsynced. m_pendingEdits is its own count, incremented where a write is CONFIRMED rather than where one is sent, so an optimistic update the worker later rejects cannot leave the indicator claiming an edit that never landed. Only a successful sync resets it: clearing on failure would assert the changes had reached the mail store when the sync is exactly what failed to put them there. It is shown in the status bar, hidden entirely at zero, and described as a lower bound rather than a guarantee, since an external notmuch run can carry changes over without this application noticing. On exit, sync_on_exit in [general] takes ask, always or never. Three values rather than a bool because "prompt me", "just do it" and "do nothing" are three behaviours and true/false expresses two; an unknown value warns by name, since a typo there silently changes what happens to unsynced work. The prompt offers three buttons for the same reason: a user who hit Quit by mistake needs a way back that is not "sync". A sync started at exit holds the window open until it finishes rather than being killed mid-run, and a sync that FAILS does not quit, because quitting there would discard the user's choice silently. With no sync command configured the prompt degrades to a plain warning instead of offering a sync that cannot run. This is not a destructive-action confirmation of the kind CLAUDE.md forbids. Those cover tag mutations, which keep undo instead of a dialog. This asks about losing work at the one point where undo cannot help. The tagsApplied lambda became a named slot, which is better structure and also what lets a test drive it: the worker is deliberately parentless because it moves to its own thread, so reaching it with findChild to emit the real signal cannot work, and contorting the test to try was the wrong instinct. Testing a modal needed its own care. A test that sends a close event hangs forever if an unexpected dialog opens, because the modal spins its own event loop; CloseProbe polls for activeModalWidget, closes it and records that one appeared, turning "a dialog opened" into an assertion rather than a hang. Also removes a stray qDebug left in the open_thread action by the earlier Enter-key investigation, which had reached two commits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6 hoursdocs: add items 21-23 and refine item 5 from the user's notesDanilo M.1-0/+109
Four items from the user's own notes that this document did not carry. Item 5 gains two concrete sub-items: a star column for flagged threads, mirroring the paperclip, and the observation that everything looks unread. The second is recorded with a warning rather than as a defect, because bold is already conditional on isUnread() in the model; either the list really is mostly unread or bold leaks through some other path, and that has to be reproduced before the font logic is touched. Item 21, better default shortcuts, records why the defaults moved once already: a bare letter cannot be a menu accelerator, and a bare capital parses to a key no keystroke emits. Both still constrain a second pass, as does the rule against testing reachability with synthetic input. Item 22 is the translatability audit CLAUDE.md has owed since the tr() rule was written, plus the loading machinery, which does not exist at all: no QTranslator, no .ts files, no CMake rule. Those are separate sizes and the item says so. Item 23, saving a query from the UI, carries the one real design question with it: whether the write lands in the hand-edited config or the state file. Saved queries are user intent rather than machine state, which argues against the split item 1 established, so the decision is flagged rather than assumed. It also notes that item 23 may answer postponed item 10 as a side effect, since account-scoped saved queries were exactly what item 10 proposed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6 hoursfeat(threads): mark an opened thread read after a delayDanilo M.1-2/+46
Opening a thread left it tagged unread, so the unread count never matched what had actually been read and the app was quietly wrong every day it was used. Item 6 of the usability backlog. A single-shot timer, armed when a thread is selected and restarted rather than stacked, so arrowing down a list marks only the thread still selected when it fires and not every one passed through. Configurable through mark_read_delay_ms in [general], defaulting to 2000: zero marks read at once, and any negative value disables the behaviour, which is why the value is neither clamped nor warned about at either end. The automatic change deliberately does NOT go on the undo stack. It routes through sendThreadTagChange() rather than tagSelected(), because undoing an action the user never took is worse than leaving a thread read, and toggle_unread already gives them a direct way back. It still funnels through the single applyTags path; what differs is only whether the inverse is pushed, which is a window-level decision above the worker. An explicit toggle_unread cancels any pending timer, or marking a thread unread by hand would be reversed a moment later and the key would look broken. Two guards beyond the plan, both from asking what happens when a timer outlives the thread it was armed for. Arming is skipped for a thread that is not unread, so no write is scheduled that would change nothing, and the handler re-checks that its thread is still selected and still unread before writing, so a stale timer does nothing rather than tagging the wrong thread. The plan expected the rapid-arrow case to need a database and a manual check. It needs neither: ThreadListModel takes threads through appendBatch(), so the case is unit-tested. All three tests were confirmed to fail against deliberately broken versions, one arming for read threads and one creating a timer per selection instead of restarting one. Item 7 is closed in the same pass. The user verified against real mail that HTML messages already open as HTML, which is what the item asked for, so it is recorded as done with no code changed. The prefer_html key it floated was not added: nobody has asked to default to plain text, and Ctrl+H already switches a thread by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6 hoursfeat(message): show From/To/Cc and add a details dialogDanilo M.1-1/+32
MimeParser has filled To and Cc all along and HtmlBuilder simply never interpolated them, so both were parsed on every message and then discarded. The header strip showed the subject and a message count and nothing else, which is item 2 of the usability backlog. The header now adapts to what it can say honestly. A thread holding one message shows From, To and Cc under the subject, where every field is unambiguous. A thread holding several keeps showing the subject and the count alone: the recipient differs message to message, and once the user has replied there is no single address the thread is addressed to, so naming one would be a guess presented as a fact. Per-message detail is what the dialog is for. That dialog lists Subject, From, To, Cc, Date and Message-Id for every message, numbered when there is more than one, in a read-only plain-text widget. Plain text is the security decision, not a stylistic one: these values come from strangers and the dialog exists to show them verbatim, so the format that cannot interpret markup is the right one. The header label is RichText and every value interpolated into it is escaped, since an unescaped From injects into the application's own chrome rather than into the sandboxed page. Reached by a Details... button beside the subject and by Ctrl+Shift+D. Both, because a shortcut alone restates the complaint this backlog opened with. The binding is shifted because Ctrl+D is delete, and the destructive action keeps the key it already had rather than being moved to make room. An empty Cc omits its row instead of printing a label with nothing after it. Both header shapes were rendered to PNG and inspected, not only asserted. The new button also exposed a latent flaw in an older test: attachmentButtonLabels() identified attachment buttons by excluding the one other button's label, so it counted the details button as an attachment as soon as one existed. It now finds the bar by object name and reads only its children. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6 hoursdocs: record the item 2 decision, postpone 10, add items 18-20Danilo M.1-4/+178
Item 2 (message details) gains the user's decision on where the details go. The header adapts to what it can say honestly: a single-message thread shows From, To, Cc and Subject, while a multi-message thread keeps showing only subject and count. Everything else moves to a popup behind a button on the right of the header. An earlier draft also put a recipient line on the thread header, which forced a choice between the union of recipients and their intersection and would have needed real address parsing to compute either. The user called that overcomplicating and dropped it, so no address parsing is needed and the item is UI work over strings MimeParser already fills. The item's own "check before building" question is answered in place: To and Cc are parsed at mimeparser.cpp:344-345 and then dropped at the renderer, which never interpolates them. The larger task it warned about does not exist. Item 10 is postponed at the user's request rather than dropped: the complaint was real and the cheap first fix it proposes still stands, it is simply not wanted now. Only its startup-query half ever shipped. Items 18 and 19 come from the user's own notes and were missing here: a visual cue for unsynced edits, and a sync-on-exit prompt with a config option. 18 records a finding that shapes both: the QUndoStack looks like a record of pending edits but is cleared on every query, so it cannot drive the indicator and a separate counter is needed. Item 20 records, unspecified, that the user's mental model of the thread view differs from what was built. Nothing is designed there yet; the next step is asking what they pictured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6 hoursdocs: document query completion config and shortcutDanilo M.1-1/+1
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.
6 hoursbuild: add a SlackBuild under assets/slackbuildDanilo M.1-1/+1
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>
6 hoursfeat: make attachments reachable from the message paneDanilo M.1-1/+47
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>
6 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>
6 hoursfeat: choose the startup query by nameDanilo M.1-0/+15
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>
6 hoursfeat: own the message-pane zoom and persist itDanilo M.1-1/+33
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>
6 hoursfeat: persist window, splitter and column widthsDanilo M.1-1/+19
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>
6 hoursfeat: use the application iconDanilo M.1-1/+39
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.
6 hoursfeat: show that a tag action landedDanilo M.1-0/+38
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.
6 hoursfeat: add menus, a toolbar and a shortcut referenceDanilo M.1-3/+32
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.
6 hoursdocs: add post-0.1.0 usability backlog and app iconDanilo M.1-0/+349
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.