aboutsummaryrefslogtreecommitdiffstats
path: root/tests
AgeCommit message (Collapse)AuthorFilesLines
34 hoursfix: handle every rule answer, and keep its failure out of errorOccurredDanilo M.2-8/+84
A second Apply overwrote the pending token, so the first rule's answer was dropped as stale although its writes had landed: no undo, no pending count, no accounts. A write's answer cannot be superseded, so the token is gone and every ruleApplied() is handled. Pending counts are keyed by generation for the same reason, since a second Apply before the first count returned dropped the first Apply entirely. applyRule() no longer emits errorOccurred(). That path reverted an unrelated in-flight user write and its message was then overwritten by "nothing to change". The failure travels in the reply instead: ruleApplied(description, changes, error). openReadOnly() and writeTags() take an optional error out-parameter so the rule's path stays quiet. The window shows the error, still records and offers undo for whatever landed first, and names the rule from the reply's description. Item 212. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
34 hoursfix: build a backfill's undo from the worker's answer, not the queueDanilo M.1-0/+141
Apply no longer arms commands on the shared confirmation queue. A backfill can run for a long time; a gesture made meanwhile cleared the queue, and the backfill's confirmation was then credited to the user's command, so undoing that command inverted the backfill over every message it touched. MainWindow counts the whole rule, confirms, and sends applyRule() with a token. onRuleApplied() drops a stale token, records each operation's effective change through the bookkeeping onTagsApplied() shares, and pushes one MessageTagCommand per operation with the ids it moved, inside one undo macro. Every configured account is marked edited, since a backfill reaches mail no row shows and a narrowed sync could otherwise clear the pending count without pushing it. Nothing moved shows the nothing-to-change status instead of "done". Also: a count of -1 says the rule could not be counted, and the confirmation says undo lasts until the next search, which is when runQuery() clears the stack. Item 212. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
34 hoursfeat: a worker entry point that applies a rule to existing mailDanilo M.1-19/+137
applyRule() replaces applyTagsToQuery(). It resolves the messages the whole rule would move once, then writes one tag at a time over that fixed list, and answers with ruleApplied(token, changes): one TagChange per operation carrying the ids it actually moved. Always answered, even when nothing moved, and never through tagsApplied(), so the window can build the undo entry from the answer instead of the shared confirmation queue that user gestures also use. Resolving once matters when the query names a tag the rule removes: re-resolving after the first write matched nothing and wrote half the rule. Writing per tag keeps each effective set exact, so the undo never strips a tag a message had before. applyTags() keeps its behaviour; its body is now writeTags(), which returns the changed ids without emitting. Item 212. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
34 hoursRevert "fix: credit a tag confirmation to the command whose tags it names"Danilo M.1-30/+0
This reverts commit a30b4c2. A backfill will stop arming commands on the shared confirmation queue altogether, so matching confirmations by tags is no longer needed, and its drop-ahead rule could discard a live command when an unarmed write's tags matched a later one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
34 hoursfix: credit a tag confirmation to the command whose tags it namesDanilo M.1-0/+30
onTagsApplied() handed each confirmation to the head of the waiting queue. The worker stays silent for a write that moved nothing, so when one operation of a rule found nothing to do (another writer got there between the count and the write), the next operation's ids were credited to it, and its undo inverted the wrong tag on the wrong messages: item 176 through a race. A confirmation now goes to the first waiting command whose add and remove sets equal the change's, and the commands ahead of it are dropped with no ids, so their undo stays a no-op. Equal-tag commands, the two halves of a mixed selection, still match in send order. No match credits nobody. TagCommand carries its own add and remove to make the comparison. Items 176 and 212. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
34 hoursfeat: apply a saved rule to existing mail, confirmed and undoableDanilo M.1-0/+91
The dialog's Apply reaches MainWindow, which counts the messages the rule would actually move, confirms with that count, then writes through the worker. The rule is written one tag at a time inside one undo macro, because the worker's effective set is per message: a single +a -b write would make the undo strip a tag from messages that already had it. Each undo entry is filled from its own confirmation, so it reverts exactly what moved. Refused while a sync holds the write lock, checked again after the confirmation closes. Item 212. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
34 hoursfix: keep the rules banner true after Apply, delete and copyDanilo M.1-1/+82
The dialog stays open after Apply, so a refusal from a failed attempt and the load warnings about a file Apply has just rewritten both outlived what they described. A successful Apply rereads the file and redraws the banner. Deleting the last rule now disables Apply, and copying or deleting a rule re-checks which rules the sync hook will skip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
34 hoursfeat: apply a rule from the rules dialog, and opt a rule into archivingDanilo M.1-0/+100
Apply saves first, so only what the hook would run is applied, then asks the owner to apply the saved rule. A checkbox maps to allow_archive, and the banner names every rule the sync hook will skip. Items 212 and 213. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
35 hoursfeat: apply a tag change to every message a query would moveDanilo M.1-0/+34
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
35 hoursfeat: an optional allow_archive field on a tagging ruleDanilo M.1-0/+73
Both readers learn it together, as the rule format requires. No version bump: the field is optional, and each writer emits it only on a rule that removes inbox, so a file without one saves unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
35 hoursfeat: build the query for what a tag change would moveDanilo M.1-0/+44
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
35 hoursfix: keep bulk tag actions linear, and say when they landDanilo M.2-0/+86
Select all on a 10k-row search, then Archive, froze the window with one CPU pinned. gdb on the live process showed the UI thread cubic before the write was ever sent: each optimistic row write emitted dataChanged, whose handler refreshed the action labels by walking the whole selection, and each step of that walk looked the message up with a linear scan of every thread. ThreadListModel now answers id lookups from a lazily rebuilt hash, invalidated by its own structural signals and by hand at the two in-place id changes. MainWindow::batchModelWrites() holds the label refresh while a loop of optimistic writes runs, then refreshes once. Two list membership tests became sets. The announcement also used to fade on a timer whether or not the write had landed. It now reads "applying" until a round trip through the worker's queue says the write has run, then "done". An error takes the label back from a late "done". Item 211. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01feat: scroll the thread list smoothlyDanilo M.1-0/+48
The list scrolled per ITEM, Qt's default, so a wheel notch jumped several whole cards at once and a touchpad's small deltas were quantised into card-sized jumps. The eye lost track of how far the list had moved. The view now scrolls per pixel, so a touchpad moves it continuously, and ThreadListView::wheelEvent turns each wheel notch into a 160ms eased glide of exactly one card. Notches arriving mid-glide add to its target rather than restarting from where it has got to. Pixel deltas and modified wheels pass through to Qt unchanged. The cost of per-pixel scrolling is that a card can rest half above the top edge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01fix: open a conversation's first message from the overviewDanilo M.1-0/+55
Clicking the first message in a conversation overview's unread list did nothing visible. applyPendingDashboardSelection() matched the conversation row on the root's message id, so it reselected the row that was already current and the overview stayed on screen. Since item 177 setThreadMessages keeps a conversation's first message as child 0, so it has a row of its own. Only a thread of one now answers for its message on its own row, the same guard 236ac86 added to the recovery. AGENTS.md still said a thread's first message is never among its children, which has been true only for a thread of one since item 177. It is corrected to say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01fix: double a quote in a search term, as notmuch expectsDanilo M.1-2/+6
SearchTerm::quote() escaped an embedded double quote with a backslash. notmuch has no backslash escape inside a quoted term: it doubles the quote, and a backslash-escaped one ENDS the term. Selected text holding a quote therefore let the rest of the selection reach the query as syntax. Measured on a throwaway index: under a `tag:nomatch and` scope, the backslash form of `x " or tag:inbox or id:"` matched every message, while the doubled form matched none. quote() now doubles the quote and leaves a backslash alone, since notmuch reads it literally. Truncation still runs first, so a cut cannot split a doubled pair. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29fix: open a conversation's first message itself for --messagecli-selectorsDanilo M.1-9/+200
--message naming the first message of a multi-message conversation selected the conversation row, so the pane showed the dashboard rather than the message. The recovery matched the conversation row on its own message id, which is the root's. The rule is that --message always targets the message, shown inside its expanded thread, and that --thread is the way to ask for the conversation. Since item 177 setThreadMessages keeps a conversation's first message as child 0, so the root already has a row of its own. applyPendingRecovery() now lets the thread row answer for a named message only when that row is not a conversation, which leaves a thread of one opening on its message as before, and finds the root among the children like any reply. The provisional pass still selects the thread while the tree loads and is refined to the root's row when the children arrive. The same recovery serves the stale notice and a double-click. A stale notice raised while reading the root's row now reopens that row, which is the same request. A double-click on a conversation row asks for the conversation, so it no longer names the root and still lands on the dashboard. recoveryOnTheFirstMessageSelectsTheThreadRow asserted the old behaviour on the pre-177 premise that the root has no child row; it is retargeted as recoveryOnTheFirstMessageSelectsItsOwnRow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29fix: select a launch's row whatever the keyboard last heldDanilo M.1-0/+104
A --message launch handed to a window already in use switched the list to the conversation and made the reply row current, and the message pane stayed empty. The same launch into a fresh window worked. selectRowAt() selected the row and then called the view's setCurrentIndex(). QAbstractItemView::setCurrentIndex() asks selectionCommand() what to do with the selection, and with no event to read it answers from QGuiApplication::keyboardModifiers(), which is the modifier state of the last input event the application received rather than anything the user is doing now. With Control in that state the command is a Toggle: the row selected a line earlier was deselected again, onThreadSelected() refused a current row that is not selected, and nothing was loaded. The provisional thread row the recovery selects first went the same way, so the pane never left the placeholder. A fresh process has received no input at all, which is why only a window in use was affected. The current index is now moved through the selection model with NoUpdate, so the selection stays exactly what select() made it. Every caller of selectRowAt() shared the defect (the stale-thread recovery, double-click, the dashboard's message entries and the launch selectors) and every one of them means "select exactly this row", so all of them take the fix. The test puts a window into use, leaves Control as the last modifier the application saw, launches --message for a reply in another thread, and asserts the reply is current, selected, and rendered in the pane. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29fix: find a launch's thread in any account, and name a missDanilo M.1-0/+154
recoverStaleThread() runs thread:<id> in the account dropdown's scope, so a --thread or --message for a conversation in another account than the one the window was left on came back with no rows, and the list went blank with nothing said. A thread or message selector now switches the dropdown to All accounts first, unless the launch named an account with --account, in which case that scope is kept. For a message the switch waits until the id has resolved, so a message that does not exist leaves the dropdown alone. When the selector's own thread:<id> query returns no rows, the miss is named in the status bar, reusing the existing "No thread matched" and "No message matched" strings, and the view that was on screen when the launch arrived comes back: the dropdown, the bar's text and the list, re-run in the scope it was built in. The notice waits for that query to land, since the query writes its row count to the bar and would cover it. A refused thread id and an unresolved message restore the dropdown the same way, and re-run nothing, since the list never changed. The judgement is keyed to a flag the selector paths set after the query and runQuery() clears, so the stale-thread notice and double-click, which share recoverStaleThread(), behave as before. README and CHANGELOG describe the selectors as they now behave: --account alone opens its startup view, the other two look in every account unless narrowed, brackets are accepted, and a miss keeps the view the user had. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29fix: show the account's startup view for a lone --accountDanilo M.1-0/+88
The account dropdown deliberately does not re-run the query: by hand the user picks a filter next. A launch from another program has no next click, so `--account work` on its own moved the dropdown and changed nothing the user could see. When the account selector applies and no thread or message was given, the startup view now runs again in the new account. The constructor's startup path moves into runStartupView() and both callers use it, so the view is resolved the same way in each: a generated filter is asked for the account's own query rather than having its all-accounts query wrapped in the account's path. The test starts on the trash view because its per-account query is the account's own trash path; for a tag filter the generated and the wrapped queries are the same string, and a test on one passed against the wrap. It asserts on the generated string and on the rows, and fails with the wrap put back. anEmptySelectorSetChangesNothing also asserts that no query ran, which the bar and the dropdown alone cannot show. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29fix: accept a bracketed Message-ID in the message selectorDanilo M.1-0/+47
A Message-ID is written <local@domain> in every header a caller copies it from, and notmuch stores it without the angle brackets, so `--message '<abc@example.org>'`, the form the README itself shows, matched nothing and was reported as a miss. applySelectors() now trims the value and strips one enclosing pair of brackets before anything else sees it, so the worker's resolve and the recovery's selection target both use the bare id. Stripping for the resolve alone would open the right thread and then fail to select the message inside it, which is why the test targets a reply rather than a thread root. No refusal of a slashed id was added: notmuch 0.39 reads `/.../` as a regex for mid: but not for id:, measured as 0 matches for id:"/.*/" on an index where mid:"/.*/" matches every message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29fix: render the status bar as plain textDanilo M.1-0/+2
A launch selector that matches nothing is echoed into the status label so the user can see which value missed. That value comes from another program's command line or from the single-instance socket, and a QLabel left at Qt::AutoText guesses whether a string is markup, so a selector shaped like HTML would have been rendered rather than shown. The format is now stated rather than guessed, the same reasoning AGENTS.md records for MessageDetailsDialog. No existing status message carries markup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29feat: apply the launch selectors to the windowDanilo M.1-0/+252
One entry point, called both at startup and by the socket handler when a later launch arrives. Two paths would drift, which is the lesson this file has already learned from every other pair. The account moves first, because a built-in filter composes with the dropdown and a query run before it would carry the old scope. The thread case reuses recoverStaleThread() whole, as item 91's double-click already does. A message id resolves to its thread first through the worker: opening a message means opening its conversation with that message selected. A thread id must be hex or it is treated as a miss and never reaches notmuch. recoverStaleThread() builds thread:<id> unquoted, which is safe for ids notmuch handed out and not for ones from another program's command line, where "0000 or tag:inbox" would widen the query to the whole inbox. The check uses anchoredPattern() because a PCRE $ also matches before a trailing newline. An empty selector set changes nothing. A bare launch against a running window means raise yourself, and a raise is not a navigation. Also drops an empty public: label left in notmuchworker.h by the previous commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29feat: resolve a Message-ID to its thread idDanilo M.1-0/+59
For --message (item 200), which knows an id and needs the conversation: opening a message means opening its thread with that message selected, never an id: query showing one card out of a conversation (item 91). The id is quoted before it reaches notmuch, unlike every other id in this class. Those came out of notmuch; this one comes off another program's command line, and notmuch parses garbage happily while matching nothing, so an id carrying query syntax would be read AS syntax with no error anywhere. The quoting is local rather than SearchTerm::quote(): that escapes an embedded quote with a backslash, which notmuch does not honour inside a phrase, so the quote closed the phrase and the injected query matched a real thread (the new test failed against it). notmuch escapes a quote by doubling it. threadIdForTesting() keeps its name and gains a shared helper rather than being promoted: it is documented as not being a slot, and the new entry point has to answer asynchronously. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29feat: add the single-instance socketDanilo M.2-0/+143
A QLocalServer under the state directory. The first launch listens; a later one connects, hands over its selectors and exits. Connect-first ordering, and on Qt 6.11 the probe is the ONLY guard for a live instance: with UserAccessOption, listen() binds in a private directory and renames the socket onto the path, which replaces whatever is there, a stale file and a live socket alike. Measured: with the probe disabled, a second launch takes the first one's socket and both handover tests fail. The same rename is what reclaims a stale file after a crash; the removeServer() retry on AddressInUse is kept for a listen that binds in place. The server reads each connection asynchronously and parses on disconnect, rather than blocking in waitForReadyRead() on the UI thread. The client's one write followed by a disconnect is what marks the payload complete, a reader past the payload cap is aborted, and a connection that never hangs up is dropped after two seconds. A connection that writes nothing at all is the other launch's probe and is ignored without a warning. A socket that cannot be created does NOT stop the window opening. A read-only state directory costs single-instance behaviour, which is a degradation; it must not cost the user their mail client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29feat: parse the launch selectors as a value typeDanilo M.2-0/+174
--account, --thread and --message, plus the payload that crosses the socket. A value type with no GUI dependency: it is parsed before QApplication exists and both halves need tests no window has to be built for. QDataStream rather than a line-based payload, because a Message-ID may contain a newline. Every read is status-checked, which is what catches a truncated payload: a short read otherwise leaves the fields default-constructed and a half-written id would be applied as an empty one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29test: keep the cron-drain test off the real sync statusDanilo M.1-2/+6
aSuccessfulCronSyncDrainsTheEditedAccounts named a log but no status file, so Config fell back to ~/.local/state/qtmaildir/syncstatus.json and the test passed or failed on the developer's last cron run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24feat: close the calendar's event pane with EscapeDanilo M.1-0/+19
Escape was scoped to the pane and only cancelled an edit, so once an event was selected nothing closed the pane again. It is a window shortcut now, labelled Close event: with no edit open it clears the selection and the pane hides. With an edit open it cancels only when focus is inside the pane, so a stray Escape after clicking the grid cannot discard the form. That guard needed the month grid to take focus on a click, which it never did; the view-scoped PgUp/PgDn, Delete and Ctrl+Z were likewise reachable only by Tab until now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24fix: drop orphaned overrides when a series stops, plus review minorsDanilo M.4-0/+120
2026-09-24docs: document the calendar, translate it, file its follow-upsDanilo M.1-0/+20
Give RepeatRule its own Q_DECLARE_TR_FUNCTIONS context so lupdate extracts describe() and the ordinal words, mirroring the array's QT_TRANSLATE_NOOP; without it lupdate warned and RepeatRule/last was never extractable.
2026-09-24feat: open the calendar from the View menu and the toolbarDanilo M.1-0/+26
2026-09-24fix: add the toolbar New button, sync the month controls, and let a stale ↵Danilo M.1-0/+49
save be retried
2026-09-24feat: the calendar window, with undo and a stale-safe saveDanilo M.2-0/+223
2026-09-24feat: lay out the calendar's month grid without a painterDanilo M.2-0/+82
2026-09-24test: a write during a run queues one more calendar syncDanilo M.1-3/+34
2026-09-24feat: debounce the calendar sync command after writesDanilo M.2-0/+95
2026-09-24test: pin the calendar config's reported error pathsDanilo M.1-0/+50
2026-09-24feat: read the calendar's config keysDanilo M.1-0/+52
2026-09-24feat: write calendar files atomically, refusing stale onesDanilo M.2-0/+125
2026-09-24test: an occurrence delete keeps the exdates already thereDanilo M.1-0/+19
2026-09-24feat: edit or delete one occurrence of a repeating eventDanilo M.1-0/+74
2026-09-24fix: compare sameMeaning overrides by recurrence id and check the uidDanilo M.1-0/+38
2026-09-24feat: edit a calendar event in place and write new onesDanilo M.1-0/+152
2026-09-24feat: an event is editable only when the user organises itDanilo M.1-0/+32
2026-09-24fix: expand a long dense series completely and flag overrides' own all-dayDanilo M.1-0/+33
2026-09-24feat: expand calendar occurrences in each event's own zoneDanilo M.1-0/+137
2026-09-24fix: an empty RRULE means does-not-repeat, not a custom ruleDanilo M.1-0/+13
2026-09-24feat: model the calendar's repeat control as RRULE textDanilo M.1-0/+110
2026-09-24feat: read every time form and load a calendar vdirDanilo M.1-0/+123
2026-09-24feat: parse one calendar event with libical (item 206)Danilo M.3-0/+107
2026-09-18fix: sanitise untrusted contact names on insertionDanilo M.1-7/+76
A vCard FN is untrusted and ContactStore faithfully decodes `\n` to a real newline, so `Evil\nBcc: x` was inserted raw into a recipient field and flowed through splitRecipients() to MessageBuilder. contactInsertionText() only quoted a name carrying a comma or a double quote, so every other RFC 5322 special (<, >, ;, @) and any control character reached the header unguarded. The name now has control characters and whitespace runs replaced by single spaces, and every non-empty name is quoted, with `\` escaped before `"`. Quoting contains all the specials in one step. The parser is left faithful; this is fixed at the consumer/trust boundary. Tests: a name with a decoded newline inserts no control character and yields one recipient; a name with <, >, ;, @ is quoted and yields one recipient. The three tests that expected an unquoted plain name now expect the quoted form.