summaryrefslogtreecommitdiffstats
path: root/tests/test_mainwindow.cpp
AgeCommit message (Collapse)AuthorFilesLines
5 hoursfix(sync): stop a local sync reporting itself as a background oneDanilo M.1-0/+119
Reported from hand testing: a manual sync ended with "Sync finished elsewhere" stamped over its own result. Ownership of a lock period was being decided when the lock was RELEASED, by asking MailSync::isRunning(). That question cannot be answered then: the process exits, so isRunning() goes false, and only afterwards does the next poll observe the lock gone. The guard therefore suppressed the message while the sync ran and let it through at the end, up to two seconds after onSyncFinished() had already said what happened. Ownership is now latched when the lock APPEARS, which is the moment isRunning() can still answer, and the matching release is swallowed. onSyncFinished() hands the latch back when it sees exit 75, because a skip means the lock was never ours: if a manual run and the cron run start inside one poll interval, the lock would otherwise be latched as local and that other run's completion swallowed with it. Also renames the messages to "Background sync running/completed" per the user: "finished elsewhere" reads as though the application does not know what is syncing the Maildir, when in fact it is the same script. The tests added here cover the external path and the Unknown state. They do NOT reproduce the reported bug, and were checked against a reverted fix to confirm that: staging it needs isRunning() true at the Running transition and false at the Idle one, which cannot be arranged in test_mainwindow without a configured sync command and a live child process. That was tried and abandoned, it left a process running for the length of the suite and popped a dialog. The ordering and the fix were instead verified against a standalone model of both code paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5 hoursfeat(ui): make multi-select discoverable and stop it opening threadsDanilo M.1-0/+255
Multi-select already worked by Ctrl+click and Shift+click, but nothing in the UI said so and every tag action was keyboard-only, so the Ctrl+T tag dialog could not be reached with a mouse at all. Adds a select_all action on Ctrl+A, registered like every other action so it reaches the Edit menu, the shortcut reference and [keys]; a right-click menu on the thread list built from the same QActions rather than parallel copies; a selection count in the status bar, which is the part that actually teaches the feature by acknowledging a selection while it is being built; and a note in the shortcut dialog for the mouse gestures, which are view behaviour and cannot appear in the generated table. A selection gesture must not open mail or mutate it. Selecting several rows now blanks the message pane and cancels any pending mark-read, rather than rendering each row swept through and queueing it to be marked read. Two Qt behaviours shaped this, both established by probe rather than from memory: - selectAll() emits no currentRowChanged at all and leaves the current index invalid. - currentRowChanged is emitted BEFORE the selection model is updated. The second one caused two distinct faults. Collapsing a multi-row selection back to one row reported the old count, so the guard swallowed the load and the pane stayed blank; that case is handled in onSelectionChanged, which sees the true count. And a Ctrl+click taking the selection from one row to two also reported one, so the thread was loaded, blanked, and then painted back when the queued reply returned from the worker. By the third row the id was already cleared and the reply was discarded, which is why the fault presented as an off-by-one in the threshold rather than as a race. Tests cover the synchronous half. The late-reply guard has no test: MainWindow in tests has no worker, so threadLoaded never fires and the repaint cannot be reproduced in process. Verified by hand instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 hoursfeat(sync): show unsynced edits and offer to sync on exitDanilo M.1-0/+163
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>
11 hoursfeat(threads): mark an opened thread read after a delayDanilo M.1-0/+117
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>
11 hoursfix(query): let the query bar keep Return instead of open_threadDanilo M.1-0/+45
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>
11 hoursfeat: show a paperclip for threads with attachmentsDanilo M.1-0/+44
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>
11 hoursfeat: own the message-pane zoom and persist itDanilo M.1-0/+3
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>
11 hoursfeat: persist window, splitter and column widthsDanilo M.1-0/+61
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>
11 hoursfeat: add menus, a toolbar and a shortcut referenceDanilo M.1-6/+87
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.
11 hoursAdd GPLv2-only license and per-file headersDanilo M.1-0/+18
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>
11 hoursfeat: add MainWindow wiring query, list, message, and syncDanilo M.1-0/+75
Wires the worker thread, thread list, message pane, sync process and undo stack together, and replaces the placeholder main() with real startup: custom URL schemes registered before QApplication, a libnotmuch ABI check, and config loading. Four fixes against the drafted version: - onWorkerError() only set a status label. Its own comment elsewhere claimed it reverted the optimistic update, and the spec requires that; it did not, so a rejected write left the list showing a tag the database never received. The pending change is now recorded and rolled back, and a confirmed tagsApplied clears it so a later unrelated error cannot undo a write that succeeded. - runCurrentQuery() cleared the model but left the undo stack pointing at rows that no longer exist. Undoing after a new query would have written to the database while the visible list stayed put. The stack is cleared with the model. - m_currentMessages was assigned on every thread load and never read. Removed. - buildUi() connected sync output to m_syncLog and errors to m_statusLabel before either existed. Both are constructed before the wiring now. cidPrefix generation lives here, this being its only producer in the application, and is pinned by tests: it must never contain '!' and must be distinct per message, which are the invariants the cid: namespacing rests on. A second test holds registeredActionNames() against KeyMap::knownActions(), since those two hand-maintained lists drifting either way silently breaks a user's key binding. Mutation-verified that dropping an action fails the test by name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>