/* * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs * Copyright (C) 2026 Danilo M. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License version 2 as * published by the Free Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. */ #pragma once #include #include #include #include #include #include #include #include #include "config.h" #include "htmlbuilder.h" #include "keymap.h" // Included rather than forward-declared: SyncPhaseTracker is held by value, so // its size must be known here. MailSync itself stays a forward declaration. #include "mailsync.h" #include "syncmonitor.h" #include "tagcolors.h" #include "types.h" class QAction; class QLineEdit; class QMenu; class ThreadListView; class QLabel; class QPushButton; class QComboBox; class QPlainTextEdit; class QSplitter; class QProgressBar; class QTimer; class ThreadListModel; class MessageView; class MailSync; class NotmuchWorker; class QueryCompleter; class TagRulesDialog; class MainWindow : public QMainWindow { Q_OBJECT public: explicit MainWindow(const Config &config, QWidget *parent = nullptr); ~MainWindow() override; /// Every action name registerActions() installs. Derived from the actions /// themselves rather than hand-maintained, so it cannot drift from what is /// really registered. QStringList registeredActionNames() const; /// The thread currently shown in the message pane, empty when it is blank. /// /// Empty is what "the pane is blanked" means internally: a late-arriving /// load is discarded rather than painted, so no thread can reappear. QString currentThreadId() const { return m_currentThreadId; } /// True while an edit is held back because a sync holds the write lock. /// Exposed for tests: the deferral is otherwise only observable by watching /// the worker, which test_mainwindow has no database to drive. bool hasEditAwaitingSend() const { return !m_heldEdits.isEmpty(); } /// Whether the undo stack still holds anything. Exposed so a test can show /// that a rejected write did not take unrelated history down with it. bool canUndo() const { return m_undoStack.canUndo(); } /// The cid: namespace prefix for the nth message of a thread. /// /// MainWindow is the only producer of this value in the application. It /// must never contain '!', which is the separator that keeps one message's /// cid: references from resolving to another's. static QString cidPrefixForIndex(int index); /// What the sync command returns when another run already holds the lock. /// /// EX_TEMPFAIL from sysexits.h. A skip is not a failure: the other run is /// doing the work, and with a cron timer every ten minutes a click landing /// inside one is routine. Reporting it as an error would show a log pane /// and an alarming status for a situation that needs neither. /// `assets/mailsync.sh` is the reference implementation of this contract. static constexpr int kSyncSkippedExitCode = 75; /// How long a transient status message stays before the bar falls back to /// the thread count. Long enough to read a sentence, short enough that a /// stale "Sync complete" does not sit there describing the present. static constexpr int kStatusMessageMs = 6000; /// Path of the machine-written UI state file. Deliberately not /// Config::defaultPath(): the config is hand-edited and must never gain a /// base64 geometry blob, nor be rewritten on exit (QSettings does not /// preserve comments or key order). static QString uiStatePath(); /// Kernel lock table every MainWindow's SyncMonitor watches, "/proc/locks" /// unless a test overrides it. /// /// A test seam, deliberately NOT a config key: /proc/locks is not something /// a user would ever set, and a wrong value silently disables background /// sync detection rather than failing loudly. Without this every window a /// test builds observes the machine's real sync state, so a test asserting /// on the sync button fails whenever the user's cron sync happens to be /// running (item 38). static void setLocksPathForTesting(const QString &path); static QString locksPath(); /// How many commands are on the undo stack. /// /// A test seam. The undo QAction is always enabled and checks canUndo() /// when triggered, so its enabled state says nothing about whether a /// command was pushed, which is what "this did nothing" has to assert. int undoDepthForTesting() const { return m_undoStack.count(); } /// The ids the last tag change was sent for, and whether they were thread /// ids or message ids. /// /// Exposed because the difference is invisible from outside otherwise: a /// message row routed down the thread path produces the same undo depth and /// the same status text while tagging every sibling in the thread. A /// mutation that made exactly that change passed the whole suite. QStringList pendingThreadIdsForTesting() const { return m_pendingThreadIds; } QStringList pendingMessageIdsForTesting() const { return m_pendingChange.messageIds; } /// The generation a worker reply must carry to be accepted. /// /// A test seam: onQueryFinished() discards a reply whose generation is /// stale, so a test standing in for the worker has to know the current one. quint64 currentGenerationForTesting() const { return m_generation; } /// The generation a database-stats reply must carry to be accepted. /// /// A test seam, for the same reason as the one above: onDatabaseStatsReady /// discards a reply belonging to a dialog that has since been closed and /// reopened, so a test standing in for the worker needs the current value. quint64 statsGenerationForTesting() const { return m_statsGeneration; } /// Whether a stale-thread recovery is still waiting for its result. /// /// A test seam. The recovery target is cleared as a matter of course by any /// query the user runs, so "is it still set immediately after the button" /// is the only way to see that it survived the slot that set it. bool hasPendingRecoveryForTesting() const { return !m_recoverThreadId.isEmpty(); } /// The placeholder's queries, in the order requestCounts() asks for them. /// /// A test seam. The worker's reply is paired with these POSITIONALLY, so a /// test standing in for it has to know the order, and that order now /// depends on config rather than on a fixed list. QStringList placeholderQueriesForTesting() const { return placeholderQueries(); } /// The helper lines as the pane would render them. QList placeholderHelpersForTesting() const { return placeholderHelpers(); } /// The generation the next counts reply must carry to be accepted. quint64 countsGenerationForTesting() const { return m_countsGeneration; } /// The query bar's text, and the account the selector is scoped to /// (empty for "All accounts"). Both are what a rule preview writes: the /// bar so the user can see and edit what ran, and the selector because /// runQuery() wraps the text in the selected account's scope, which would /// double-scope a rule query that already names its own path. QString queryTextForTesting() const; QString selectedAccountForTesting() const; /// Scopes the view to one account, as choosing it in the selector does. /// A test for the rule preview needs this: with no account selected the /// box already sits at "All accounts", so asserting that a preview leaves /// it there passes whether or not the preview clears it. void selectAccountForTesting(const QString &key); /// Runs a rule preview without the dialog, which the offscreen platform /// cannot click a button in. void previewRuleQueryForTesting(const QString &query) { onRulePreviewRequested(query); } protected: void closeEvent(QCloseEvent *event) override; /// Claims Return back for the query bar. Return is bound to open_thread as /// a WindowShortcut, and a shortcut outranks the focused widget, so without /// this the action fires from inside the bar and the query never runs. bool eventFilter(QObject *watched, QEvent *event) override; public: /// Whether the result of a query is shown as a flat list of threads rather /// than as an expandable tree. /// /// Only the Sent button asks for Yes. Every other route runs the no-arg /// slot, which passes No, so flat mode cannot outlive the view that asked /// for it: the same query typed by hand comes back as a tree. enum class FlatResult { No, Yes }; Q_ENUM(FlatResult) private: /// The real query runner. Kept off the slot list deliberately: a slot with /// a defaulted argument does not satisfy QObject::connect, which matches /// signal and slot arity at compile time, so the zero-argument slot below /// is what widgets connect to. void runQuery(FlatResult flat); private slots: void runCurrentQuery() { runQuery(FlatResult::No); } /// Brings back a thread that stopped matching, and restores the reader's /// place inside it. /// /// Runs `thread:` so the whole conversation is listed rather than the /// single message, then expands it and selects `messageId` once the rows /// exist. Both steps are queued round-trips to the worker, so the ids are /// remembered in m_recoverThreadId / m_recoverMessageId and acted on as the /// replies arrive. /// /// A slot because MessageView's notice connects to it, and because the /// sequencing above is only testable by driving it through the same entry /// point the button uses. void recoverStaleThread(const QString &threadId, const QString &messageId); /// Selects the remembered message once its thread's rows have loaded. void applyPendingRecovery(); void onThreadsReady(const QVector &threads, quint64 generation); void onQueryFinished(int total, quint64 generation); void onThreadSelected(const QModelIndex ¤t, const QModelIndex &previous); /// Keeps the status bar's selection count and the multi-select guard in /// step with selections that never move the current index. void onSelectionChanged(); /// Pops up the thread-list context menu, preserving a multi-row selection /// the click lands inside. void showThreadContextMenu(const QPoint &pos); void onThreadLoaded(const QVector &messages, quint64 generation); /// Asks the worker for a thread's reply tree when its row is expanded. void onThreadExpanded(const QModelIndex &index); /// Fills in the expanded thread's message rows. void onThreadTreeLoaded(const QVector &nodes, quint64 generation); /// Renders the single message a message row asked for. void onMessageLoaded(const QVector &messages, quint64 generation); void onWorkerError(const QString &message); void onSyncFinished(bool success, int exitCode); /// Shows a message that describes an event and takes it back after a few /// seconds, restoring the last query's thread count. /// /// Use this for events ("Sync complete"), never for state: the selection /// count must persist while the selection does. A private slot so tests can /// drive it through the meta-object. void showTransientStatus(const QString &text); /// Reacts to a sync started outside this window, by cron or by hand. /// /// A private slot rather than a plain method so tests can drive it through /// the meta-object without widening the public API. void onExternalSyncStateChanged(SyncMonitor::State state); /// Starts a sync and shows that it started. Every route in goes through /// here: the toolbar, the menu, the shortcut and the button. /// /// A private slot for the same reason as the two above: a test needs to /// start a real run through the meta-object to exercise the output /// handling, without this becoming public API. void startSync(); /// A tag mutation the worker has confirmed reached the database. Counts it /// as unsynced, since reaching the index is not reaching the mail store. void onTagsApplied(const TagChange &change); void onAllTagsReady(const QStringList &tags); /// Thread counts for the placeholder's helper lines, in the order /// requestPlaceholderCounts() asked for them. void onCountsReady(const QVector &counts, quint64 generation); /// Fills in the overview dialog's counts when the worker answers. Does /// nothing if the dialog has since been closed. void onDatabaseStatsReady(const DatabaseStats &stats, quint64 generation); /// Runs a query the user clicked on the placeholder pane. void onPlaceholderQueryRequested(const QString &query); /// Runs one tagging rule's query in the thread list, so the user can see /// which mail it collects. The rules dialog stays open; the point is to /// compare the rule against its results. void onRulePreviewRequested(const QString &query); /// Opens the auto-tagging rules editor, or raises the one already open. void showTagRulesDialog(); /// Message counts for the rules dialog's queries, in the order it asked /// for them. Does nothing if the dialog has since closed, or if a newer /// request has superseded this one. void onRuleCountsReady(const QVector &counts, quint64 generation); private: void buildUi(); /// Restores window geometry, splitter and thread-list header widths. /// A missing or rejected blob leaves the buildUi() defaults in place. void restoreUiState(); /// The thread row containing an index: itself for a thread row, its parent /// for a message row. QModelIndex threadRowOf(const QModelIndex &index) const; /// Selects a whole row. QTreeView has no selectRow of its own. void selectRowAt(const QModelIndex &index); /// Selects the top-level thread row at `row`. void selectThreadRow(int row); void saveUiState() const; void registerActions(); void buildMenus(); void wireWorker(); /// Asks the worker to re-enumerate the database tags for the completer. void requestAllTags(); /// Shows the placeholder pane and asks the worker to refresh its counts. /// /// **The single route to a blank pane.** Every site that used to call /// MessageView::clear() goes through here, so the pane is never left empty /// by accident and the counts are refreshed exactly when they are about to /// be looked at. A count goes stale the moment a tag is edited, and one /// nobody is looking at is not worth keeping fresh. void showPlaceholderPane(); /// The helper lines, built from the last counts received. Rendered with /// whatever the previous answer was until the new one lands, so the pane /// never flashes empty while the worker replies. /// One placeholder line: the query it counts, and how to label the answer. /// /// The label is a callable rather than a string because the count is not /// known until the worker replies, and `tr("%n ...")` has to be given the /// number to pick its plural form. /// /// Query and label travel together deliberately. The version this replaced /// held them in two arrays indexed in parallel, where inserting an entry in /// one and not the other put a real number against the wrong name. struct PlaceholderLine { QString query; std::function label; }; /// The placeholder's lines, in render order. /// /// Built per call rather than cached: the sent and drafts lines come from /// config, and a cache would be a second source of truth for the pairing /// the counts reply depends on. QList placeholderLines() const; /// Just the queries, in the order requestCounts() asks for them. QStringList placeholderQueries() const; QList placeholderHelpers() const; void showWarnings(); void showShortcutReference(); void showAbout(); /// The Maildir overview (item 34): what notmuch knows about the database, /// plus the account list, which comes from config since notmuch does not /// model accounts at all. /// /// Opens immediately showing the counts as pending and fills them in when /// the worker answers, rather than blocking: counting every message is not /// free on a large database and a dialog that hangs first is worse than one /// that populates. void showMaildirOverview(); /// Creates a QAction, binds it to the sequence KeyMap holds for `name`, /// and registers it. `name` is the action name used in [keys]. QAction *addAction(const QString &name, const QString &text, const QString &description, const std::function &handler); void tagSelected(const QStringList &add, const QStringList &remove, const QString &description); /// Starts, restarts or cancels the mark-read timer for a newly opened /// thread. Cancels outright for a thread that is not unread, so an already /// read thread never schedules a write that would change nothing. void scheduleMarkRead(const ThreadSummary &thread); /// Removes `unread` from the thread the timer was armed for, if it is still /// the one on screen. void markCurrentThreadRead(); /// Redraws the unsynced-edits indicator from pendingEditCount(). void updatePendingIndicator(); /// Arms the debounce that syncs a confirmed tag edit out on its own /// (item 71). Does nothing when the delay is negative, when no sync command /// is configured, or when nothing is actually pending. void scheduleAutoSync(); /// Starts the debounced automatic sync, unless a sync is already running /// (local or external) or the edits it would carry are already gone. /// /// Q_INVOKABLE so a test can fire the debounce without waiting it out. Q_INVOKABLE void runAutoSync(); /// Records one confirmed (message, tag) change, cancelling it against an /// opposite change already outstanding for the same pair. void recordPendingEdit(const QString &messageId, const QString &tag, bool added); /// Net changes the index holds that a sync has not carried over. int pendingEditCount() const; /// Shows or hides the "syncing" state: the progress bar and a disabled /// Sync button. /// /// The bar is INDETERMINATE by design. mbsync reports no percentage and /// the script's output is unstructured, so a bar that filled from left to /// right would be inventing a fraction nobody knows. An indeterminate one /// says "working, duration unknown", which is the truth. void setSyncBusy(bool busy); /// Reassembles lines from a sync output chunk and updates the status label /// when the phase or its detail changes. void feedSyncPhase(const QString &chunk); /// Removes `unread` from every thread in the current view, as one write and /// one undo entry, ignoring the selection. void markAllRead(); /// Enables or disables the actions that claim to act on a whole view, /// according to whether the result set is complete. void updateViewWideActions(); /// Applies the sync progress bar and button state from BOTH sync sources. /// /// One function of both, never two assignments: with a local and a /// background sync each writing the widgets independently, whichever /// finished second would win and re-enable the button while the other was /// still running. void updateSyncControls(); /// Opens the tag dialog on the current selection and applies its result. /// /// The only route to an arbitrary tag: every other tag action writes a /// hardcoded name. void editTagsOnSelection(); /// Set once the user has answered the exit prompt, or once a sync started /// for exit has finished. Stops closeEvent asking a second time, and is /// what lets the deferred close through. bool m_closeApproved = false; /// True while a sync started by the exit prompt is running. The window /// stays open until it finishes: killing the process mid-sync is exactly /// the loss the prompt exists to prevent. bool m_syncingForExit = false; /// Sends a tag change for a set of threads without touching the undo stack. /// Both tagSelected() and ThreadTagCommand route through this. /// /// Invokable so a test can record an edit against a known account without a /// worker: this is where m_editedAccounts is populated, and item 54's /// draining of it cannot be observed otherwise. Q_INVOKABLE void sendThreadTagChange(const QStringList &threadIds, const QStringList &add, const QStringList &remove, const QString &description); /// The same for individual MESSAGES, without touching the undo stack. /// Both tagSelected() and MessageTagCommand route through this. void sendMessageTagChange(const QStringList &messageIds, const QStringList &add, const QStringList &remove, const QString &description); /// Undoes the optimistic model update for a write the worker rejected. void revertPendingTagChange(); /// Whether a write sent now would block the worker on notmuch's write lock. /// /// True only for a sync KNOWN to be running. `SyncMonitor::State::Unknown` /// deliberately does not count: it means `/proc/locks` could not be read, /// and holding every edit on a platform that cannot observe the lock at all /// would strand them permanently. bool aSyncHoldsTheWriteLock() const; /// Sends every edit held while the lock was busy, oldest first. void flushHeldEdits(); /// Re-runs the current query and reconciles the result into the model. /// /// The non-destructive counterpart to `runCurrentQuery()`, and what a sync /// fires: nothing is cleared, so the selection, the expanded threads, the /// undo stack and the message being read all survive. New threads appear /// where the sort puts them and threads that stopped matching leave. /// /// Does nothing when no query has run yet, since there is nothing to /// re-run. void refreshCurrentQuery(); /// Shows or hides the message pane's "no longer matches" notice. /// /// Called after a refresh, which is the only thing that can remove a row /// from under a reader. A thread read out of an Unread view is the ordinary /// case: the pane keeps rendering it, correctly, while the list no longer /// offers it anywhere, and without this the message quietly becomes an /// orphan with no route back. void updateStaleThreadNotice(); /// A tag change not yet sent to the worker, because a sync held the write /// lock when the user made it. /// /// Held rather than sent because the read-write open BLOCKS: measured /// 9.158s against a 12s lock hold, returning SUCCESS, not an error. Sending /// into that freezes the worker thread, so every later query and thread /// load queues behind it. The rows show the change meanwhile, which is /// honest: it is what the user asked for and it is going to be applied. struct HeldEdit { QStringList threadIds; TagChange change; }; /// FIFO, because a sync lasts ~35s and the user can keep tagging through /// it. Order matters: two edits touching one thread must reach the database /// in the order they were made, or the later one does not win. QVector m_heldEdits; friend class ThreadTagCommand; friend class MessageTagCommand; Config m_config; KeyMap m_keyMap; TagColors m_tagColors; QThread m_workerThread; NotmuchWorker *m_worker = nullptr; ThreadListModel *m_model = nullptr; MessageView *m_messageView = nullptr; MailSync *m_sync = nullptr; /// Derives "which half of the sync is running" from the output stream, so /// the status bar says more than "Syncing...". Reset at the start of each /// local run. SyncPhaseTracker m_syncPhase; /// Holds the tail of a chunk that did not end on a newline, since /// QProcess::readAll() splits wherever it happens to. QString m_syncLineBuffer; /// Watches the sync lock for runs this window did not start. SyncMonitor *m_syncMonitor = nullptr; /// True while the lock the monitor can see is held by this window's own /// sync. Latched when the lock is taken, because by the time it is released /// MailSync::isRunning() is already false and can no longer answer "was /// that ours?". bool m_localSyncHoldsLock = false; /// True while a sync this window started is running. Half of the input to /// updateSyncControls(). bool m_localSyncBusy = false; /// True while a sync this window did NOT start holds the lock. The other /// half. Tracked here rather than read back from SyncMonitor so the state /// the UI acted on is the state it was told about. bool m_externalSyncBusy = false; QUndoStack m_undoStack; QLineEdit *m_queryEdit = nullptr; QueryCompleter *m_queryCompleter = nullptr; /// Its own type, not the QTreeView base. The strip painting and the /// expander column are ThreadListView's, and holding the base here only /// hid that from every reader. ThreadListView *m_threadView = nullptr; /// Right-click menu for the thread list, holding the same QActions the /// menu bar does. QMenu *m_threadContextMenu = nullptr; QSplitter *m_splitter = nullptr; /// Below this the message pane shows a sliver of a rendered mail and is /// not worth the space it occupies. This is a floor, reached only when a /// restored position does not fit, so it is set at the width mail is /// readable at rather than the width it is merely visible at: at 200 the /// placeholder's own text wraps every couple of words. static constexpr int kMinMessagePaneWidth = 300; QComboBox *m_accountBox = nullptr; QComboBox *m_sortOrder = nullptr; QLabel *m_statusLabel = nullptr; /// Expires a transient status message. See showTransientStatus(). QTimer *m_statusTimer = nullptr; /// The message m_statusTimer armed for, so it takes back only its own. QString m_transientMessage; /// What the status bar falls back to: the last query's thread count. QString m_defaultStatus; /// Says how many tag changes have not been seen to reach the mail store. /// Hidden entirely at zero rather than reading "0 unsynced", which is noise. QLabel *m_pendingLabel = nullptr; /// Indeterminate, shown only while a sync runs. See setSyncBusy(). QProgressBar *m_syncProgress = nullptr; /// The last counts the worker answered, one per placeholderLines() entry. /// Empty until the first reply, which renders the pane without its helper /// lines rather than with three zeroes that would be a lie. QVector m_placeholderCounts; /// Discriminates a counts reply from a superseded request, the same way the /// query generation does. A reply for an older request is dropped rather /// than repainting the pane with counts taken before the last edit. quint64 m_countsGeneration = 0; /// Set when a sync ends in failure, cleared when one succeeds. Drives the /// placeholder's sync line, which appears only when something needs /// attention, so it must survive until the next successful run. bool m_lastSyncFailed = false; /// The overview dialog's counts label while that dialog is open, null /// otherwise. A QPointer because the dialog is deleted on close and the /// worker's reply can arrive afterwards: a raw pointer would dangle for /// exactly as long as the count takes on a large database, which is /// precisely when the user is most likely to close it first. QPointer m_overviewCounts; /// Discriminates a stats reply from a dialog that has since been closed /// and reopened, so an old answer cannot fill in a newer dialog. quint64 m_statsGeneration = 0; /// The open rules dialog, or null. Held so a counts reply can reach it, /// and cleared when it closes: a reply that arrives after the dialog is /// gone must find nothing rather than a dangling pointer. A QPointer for /// the same reason m_overviewCounts is one, and the window is open wider /// here, since counting every rule takes seconds. QPointer m_tagRulesDialog; /// Generation of the rules dialog's counts request. /// /// Its OWN counter, deliberately not m_generation. That one is the QUERY /// generation, and onThreadsReady, onQueryFinished, onThreadLoaded, /// onThreadTreeLoaded and onMessageLoaded all compare against it directly: /// bumping it here would discard whatever thread load was in flight when /// the user pressed Count matches, blanking the message pane for a reason /// that has nothing to do with the query. Not m_countsGeneration either, /// though that one is closer: it belongs to the placeholder pane's THREAD /// counts, and sharing it would let each cancel the other's reply. The two /// arrive on different signals (countsReady against messageCountsReady), /// so they can never be confused for one another and need not share a /// counter. quint64 m_ruleCountGeneration = 0; /// The generation of a REFRESH query, run after a sync to bring the list /// up to date without disturbing it. /// /// Numbered from the same counter as an ordinary query, so a refresh and a /// user query can never share an id, but tracked separately because the two /// consume their results differently: an ordinary query appends into a /// cleared model as batches arrive, while a refresh accumulates every batch /// and reconciles once at the end. Zero when no refresh is in flight. /// /// A user query started while a refresh is running silently supersedes it: /// the refresh's batches are still collected but its result is dropped, for /// the same reason the generation counter exists at all. Reconciling it /// would fight the query the user just typed. quint64 m_refreshGeneration = 0; /// Threads collected from a refresh query, complete only once its /// queryFinished arrives. /// /// Held rather than applied per batch because reconcile() needs the WHOLE /// result to tell a thread that stopped matching from one that simply has /// not arrived yet. Reconciling batch by batch would delete every row the /// first batch did not contain, emptying the list and refilling it, which /// is the reset this exists to avoid. QVector m_refreshThreads; /// The thread and message a stale-thread recovery is waiting to select. /// /// Recovery spans two queued round-trips (the query, then the reply walk), /// so the target cannot be a local variable. Cleared once the selection /// lands, or by any query the user runs in the meantime: that is them /// choosing to go somewhere else, and restoring a selection into a result /// they did not ask for would yank the view. QString m_recoverThreadId; QString m_recoverMessageId; /// The thread the pane's current MESSAGE belongs to. /// /// Selecting a message row clears m_currentThreadId (the pane shows one /// message, not a conversation), so without this a reader three replies /// deep has no thread to check against the refreshed list, and the stale /// notice never appears for them. Not obtainable from the model after the /// fact: ThreadListModel::threadIdForMessage() searches the rows, and by /// the time this is needed the thread has left them. QString m_currentMessageThreadId; /// True while the status bar is showing this window's own "Background sync /// running..." message. /// /// A refresh after a cron sync is deliberately silent, so it writes nothing /// to the bar. That left the running message standing after the sync /// finished, because the "completed" message it replaced was the only thing /// that ever cleared it. Silence means saying nothing NEW, not leaving a /// stale claim on screen: this marks the one string the Idle branch is /// entitled to retire, so it cannot overwrite a selection count or anything /// else the user is actually reading. bool m_announcedExternalSync = false; /// Holds the sync log and its close button, so the pane can be dismissed. QWidget *m_syncLogPane = nullptr; QPlainTextEdit *m_syncLog = nullptr; /// Action name (as used in [keys]) to the QAction implementing it. Owned /// by the window through the QObject parent, not by this hash. QHash m_actions; /// One-line description per action, for the shortcut reference. Kept /// beside the actions so the dialog is generated, never hand-written in /// parallel with them. QHash m_actionDescriptions; /// The tag list last received from the worker. Held here and not only in /// the completer so a mutation can ask whether it introduced a tag the /// completer does not yet offer, without a round trip. QStringList m_knownTags; quint64 m_generation = 0; /// True once the running query has reported its total, so the model holds /// the whole result set rather than the batches that have arrived so far. /// Gates mark_all_read, which cannot honestly say "all" before then. bool m_queryComplete = false; /// Whether the CURRENT view is the Sent one, and therefore whether it is /// flat and carries recipients. /// /// Held rather than recomputed because a background refresh re-runs the /// same query without going through the Sent button: without this, the /// first cron sync would silently turn a Sent view back into a tree of /// senders while the user was reading it. bool m_sentView = false; QString m_lastQuery; QString m_currentThreadId; /// The message a MESSAGE row is showing, empty whenever the pane holds a /// whole thread. The two are mutually exclusive and each clears the other, /// so a late reply can tell which kind of selection it belongs to. QString m_currentMessageId; /// The selection count last written to the status bar, so it can be taken /// back without clobbering a message some other action put there. QString m_selectionMessage; /// Confirmed tag mutations not yet known to have reached the mail store. /// /// A count of its own rather than QUndoStack::isClean(), which cannot serve /// here: 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. /// /// A lower bound on what is outstanding, never a guarantee: the user's cron /// can run notmuch new without the application noticing. /// /// NET state rather than a tally of writes. Keyed "\n", /// value true for added and false for removed; a pair that reverts is /// erased rather than stored, so an edit and its inverse leave nothing /// behind and the map cannot grow without bound. QHash m_pendingTagEdits; /// Confirmed changes carrying no message ids, which cannot be netted /// against anything. Counted separately rather than dropped: understating /// the indicator is the direction that costs the user work. int m_unnettablePendingEdits = 0; /// Marks the open thread read once it has been on screen long enough. /// /// Single-shot and RESTARTED on every selection change, never stacked: /// arrowing down a list must mark only the thread still selected when it /// fires, not each one passed through. QTimer *m_markReadTimer = nullptr; /// The thread m_markReadTimer will mark read. Compared against the current /// selection when it fires, so a timer that outlives its thread does /// nothing rather than marking the wrong one. QString m_markReadThreadId; /// Debounces the automatic sync that follows a tag edit (item 71). /// /// Single-shot and RESTARTED by every confirmed edit, for the same reason /// m_markReadTimer is: tagging a multi-row selection confirms one write per /// thread, and one sync per thread is exactly what a debounce exists to /// prevent. QTimer *m_autoSyncTimer = nullptr; /// The optimistic update awaiting confirmation, kept so a worker error can /// put the model back. Only the most recent one: mutations are sent from /// the UI thread one user action at a time. TagChange m_pendingChange; QStringList m_pendingThreadIds; /// Account keys whose mail store has edits a sync has not yet carried, /// for item 49's per-account sync. /// /// Deliberately NOT netted the way m_pendingTagEdits is. That map tracks /// the INDEX, where removing a tag and re-adding it leaves nothing /// outstanding; this tracks the MAIL STORE, where both writes have already /// renamed files that mbsync still has to propagate. Netting this to empty /// would skip the very account whose files changed. /// /// Populated where the threads are known, since TagChange carries message /// ids and the account is a property of the thread. Cleared only by a /// SUCCESSFUL sync, alongside the pending-edit map. QSet m_editedAccounts; /// The channel names for m_editedAccounts, resolved through the config. /// Empty means sync everything, which is what a fetch with nothing pending /// has to do. /// Invokable for the same reason as sendThreadTagChange(): it is the only /// view onto m_editedAccounts, and a count that reaches zero while the set /// stays full looks correct and still syncs the wrong channels. Q_INVOKABLE QStringList pendingSyncChannels() const; }; /// Undo entry for a tag change over a set of threads. /// /// Stores thread ids rather than message ids, so undo re-resolves them on the /// worker and stays correct even if the selection has moved on. class ThreadTagCommand : public QUndoCommand { public: ThreadTagCommand(MainWindow *window, const QStringList &threadIds, const QStringList &add, const QStringList &remove, const QString &description) : QUndoCommand(description), m_window(window), m_threadIds(threadIds), m_add(add), m_remove(remove), m_description(description) {} /// The stack calls redo() when the command is pushed. The change has /// already been sent by that point, so the first call is skipped. void redo() override { if (m_firstRedo) { m_firstRedo = false; return; } m_window->sendThreadTagChange(m_threadIds, m_add, m_remove, m_description); } void undo() override { // Inverted: what was added is removed and vice versa. m_window->sendThreadTagChange(m_threadIds, m_remove, m_add, QStringLiteral("Undo %1").arg(m_description)); } private: MainWindow *m_window; QStringList m_threadIds; QStringList m_add; QStringList m_remove; QString m_description; bool m_firstRedo = true; }; /// Undo entry for a tag change over individual MESSAGES. /// /// Stores message ids, unlike ThreadTagCommand, and that difference is the /// point rather than an inconsistency: a message row acts on one message, so /// re-resolving its thread on undo would restore tags across every sibling the /// action never touched. class MessageTagCommand : public QUndoCommand { public: MessageTagCommand(MainWindow *window, const QStringList &messageIds, const QStringList &add, const QStringList &remove, const QString &description) : QUndoCommand(description), m_window(window), m_messageIds(messageIds), m_add(add), m_remove(remove), m_description(description) {} /// The stack calls redo() when the command is pushed, by which point the /// change has already been sent, so the first call is skipped. void redo() override { if (m_firstRedo) { m_firstRedo = false; return; } m_window->sendMessageTagChange(m_messageIds, m_add, m_remove, m_description); } void undo() override { m_window->sendMessageTagChange( m_messageIds, m_remove, m_add, QStringLiteral("Undo %1").arg(m_description)); } private: MainWindow *m_window; QStringList m_messageIds; QStringList m_add; QStringList m_remove; QString m_description; bool m_firstRedo = true; };