diff options
Diffstat (limited to 'src/mainwindow.cpp')
| -rw-r--r-- | src/mainwindow.cpp | 407 |
1 files changed, 385 insertions, 22 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 0a9f9d8..7cd2769 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -154,7 +154,7 @@ void MainWindow::closeEvent(QCloseEvent *event) return; } - if (!m_closeApproved && m_pendingEdits > 0 + if (!m_closeApproved && pendingEditCount() > 0 && m_config.syncOnExit() != Config::SyncOnExit::Never) { // Not a destructive-action confirmation, which CLAUDE.md forbids for @@ -167,7 +167,7 @@ void MainWindow::closeEvent(QCloseEvent *event) const auto answer = QMessageBox::warning( this, tr("Unsynced changes"), tr("%n tag change(s) have not been synced, and no sync command " - "is configured. Quit anyway?", "", m_pendingEdits), + "is configured. Quit anyway?", "", pendingEditCount()), QMessageBox::Discard | QMessageBox::Cancel, QMessageBox::Cancel); if (answer == QMessageBox::Cancel) { @@ -181,7 +181,7 @@ void MainWindow::closeEvent(QCloseEvent *event) box.setIcon(QMessageBox::Question); box.setWindowTitle(tr("Unsynced changes")); box.setText(tr("%n tag change(s) have not been synced.", "", - m_pendingEdits)); + pendingEditCount())); box.setInformativeText(tr("Sync before quitting?")); QPushButton *sync = box.addButton(tr("Sync and quit"), QMessageBox::AcceptRole); @@ -189,6 +189,15 @@ void MainWindow::closeEvent(QCloseEvent *event) box.addButton(tr("Quit anyway"), QMessageBox::DestructiveRole); box.addButton(QMessageBox::Cancel); box.setDefaultButton(sync); + + // The default is set correctly and Qt agrees (isDefault() and + // hasFocus() are both true on it), but qt6ct-style draws no + // visible default-button decoration, so Enter's target is + // invisible on this desktop. Naming it in the text costs nothing + // and does not fight the theme. + // ponytail: text, not a styled button. Restyling the button means + // overriding the user's theme, which is worse than a sentence. + sync->setText(tr("Sync and quit (default)")); box.exec(); if (box.clickedButton() == sync) { @@ -325,8 +334,29 @@ void MainWindow::buildUi() // The status label is created first: the sync wiring below can report into // it before the rest of the UI exists. m_statusLabel = new QLabel(this); + m_statusLabel->setObjectName(QStringLiteral("statusMessage")); statusBar()->addWidget(m_statusLabel); + // Transient messages describe an EVENT and go stale: "Sync complete" reads + // as the present tense until something else overwrites it. State messages, + // the selection count above all, describe what is true right now and must + // not expire while it stays true, so only showTransientStatus() arms this. + // + // ponytail: one timer beside the label, not QStatusBar::showMessage(). + // That would mean moving off addWidget() and reworking the permanent + // widgets beside it, for the same behaviour. + m_statusTimer = new QTimer(this); + m_statusTimer->setObjectName(QStringLiteral("statusTimer")); + m_statusTimer->setSingleShot(true); + m_statusTimer->setInterval(kStatusMessageMs); + connect(m_statusTimer, &QTimer::timeout, this, [this]() { + // Only take back a message this timer armed. Anything written since is + // newer and more relevant than the default. + if (m_statusLabel->text() == m_transientMessage) + m_statusLabel->setText(m_defaultStatus); + m_transientMessage.clear(); + }); + // Beside the sync status rather than as a widget competing with it: the two // say related things and reading them apart would be worse than reading // them together. @@ -409,6 +439,7 @@ void MainWindow::buildUi() m_syncLogPane->hide(); m_syncButton = new QPushButton(tr("Sync"), central); + m_syncButton->setObjectName(QStringLiteral("syncButton")); m_sync = new MailSync(m_config.syncCommand(), this); m_syncButton->setEnabled(m_sync->isAvailable()); if (!m_sync->isAvailable()) { @@ -417,7 +448,7 @@ void MainWindow::buildUi() } connect(m_syncButton, &QPushButton::clicked, this, [this]() { if (!m_sync->start()) { - m_statusLabel->setText(tr("Sync already running")); + showTransientStatus(tr("Sync already running")); return; } // Fresh run, fresh output: leaving the previous run's lines in place @@ -430,6 +461,15 @@ void MainWindow::buildUi() m_syncLog->appendPlainText(chunk.trimmed()); }); + // Syncs this window did not start. The user's cron runs the same script + // every ten minutes, so mail arrives and tags change while the window sits + // idle, and until now nothing here noticed. + m_syncMonitor = new SyncMonitor(SyncMonitor::defaultLockPath(), + QStringLiteral("/proc/locks"), this); + connect(m_syncMonitor, &SyncMonitor::stateChanged, + this, &MainWindow::onExternalSyncStateChanged); + m_syncMonitor->start(); + queryRow->addWidget(m_accountBox); queryRow->addWidget(m_queryEdit, 1); queryRow->addWidget(m_syncButton); @@ -488,6 +528,16 @@ void MainWindow::buildUi() &QItemSelectionModel::currentRowChanged, this, &MainWindow::onThreadSelected); + // Separate from currentRowChanged: a selection can grow without current + // moving at all. Ctrl+click adds a row and leaves current where it was, and + // selectAll() emits no currentRowChanged whatsoever (verified against + // Qt 6.11). Both are multi-select gestures that have to blank the pane and + // cancel a pending mark-read, so neither can rely on the current-index + // signal to notice them. + connect(m_threadView->selectionModel(), + &QItemSelectionModel::selectionChanged, + this, &MainWindow::onSelectionChanged); + m_messageView = new MessageView(central); m_messageView->setTagColors(&m_tagColors); connect(m_messageView, &MessageView::statusMessage, @@ -564,8 +614,29 @@ void MainWindow::registerActions() tagSelected({}, { QStringLiteral("inbox") }, tr("Archive")); }); addAction(QStringLiteral("delete"), tr("&Delete"), - tr("Add the deleted tag"), [this]() { - tagSelected({ QStringLiteral("deleted") }, {}, tr("Delete")); + tr("Add or remove the deleted tag"), [this]() { + // A toggle, like toggle_unread: pressing Delete twice is the natural + // way to say "no, put it back", and adding a tag that is already there + // is a no-op the user cannot see. + // + // One direction for the WHOLE selection. Toggling each thread + // independently would leave one keystroke with the selection in two + // states, which is worse than either outcome, so undelete only when + // every selected thread is already deleted. + const QModelIndexList rows = + m_threadView->selectionModel()->selectedRows(); + bool allDeleted = !rows.isEmpty(); + for (const QModelIndex &index : rows) { + if (!m_model->threadAt(index.row()).isDeleted()) { + allDeleted = false; + break; + } + } + + if (allDeleted) + tagSelected({}, { QStringLiteral("deleted") }, tr("Undelete")); + else + tagSelected({ QStringLiteral("deleted") }, {}, tr("Delete")); }); addAction(QStringLiteral("spam"), tr("Mark &spam"), tr("Add spam and remove inbox"), [this]() { @@ -645,7 +716,7 @@ void MainWindow::registerActions() if (m_undoStack.canUndo()) m_undoStack.undo(); else - m_statusLabel->setText(tr("Nothing to undo")); + showTransientStatus(tr("Nothing to undo")); }); addAction(QStringLiteral("sync"), tr("&Sync"), tr("Run the configured sync command"), [this]() { @@ -659,6 +730,29 @@ void MainWindow::registerActions() m_queryEdit->setFocus(); m_queryCompleter->triggerCompletion(); }); + addAction(QStringLiteral("clear_pane"), tr("Clear &message pane"), + tr("Blank the message pane without changing the selection"), + [this]() { + // A view change, not a mail change: the selection, the query and the + // undo stack are all left alone. + // + // m_currentThreadId is cleared with the pane, not merely alongside it. + // A threadLoaded still in flight for that id would otherwise paint the + // thread straight back, which is the queued-reply race documented in + // CLAUDE.md. + m_currentThreadId.clear(); + m_messageView->clear(); + m_markReadTimer->stop(); + m_markReadThreadId.clear(); + }); + addAction(QStringLiteral("select_all"), tr("Select &all threads"), + tr("Select every thread in the current result list"), [this]() { + // A registered action rather than the view's built-in SelectAll key, so + // it reaches the Edit menu, the shortcut reference and [keys] the same + // way every other binding does. That is the whole point: multi-select + // already worked, it was simply invisible. + m_threadView->selectAll(); + }); addAction(QStringLiteral("quit"), tr("&Quit"), tr("Quit qtmaildir"), [this]() { close(); }); @@ -680,6 +774,8 @@ void MainWindow::buildMenus() editMenu->addSeparator(); editMenu->addAction(m_actions.value(QStringLiteral("focus_query"))); editMenu->addAction(m_actions.value(QStringLiteral("complete_query"))); + editMenu->addSeparator(); + editMenu->addAction(m_actions.value(QStringLiteral("select_all"))); auto *messageMenu = menuBar()->addMenu(tr("&Message")); messageMenu->addAction(m_actions.value(QStringLiteral("archive"))); @@ -731,6 +827,29 @@ void MainWindow::buildMenus() action->setIcon(icon); } + // Right-click on the thread list. Built from the same registered QActions + // as the menu bar, never from parallel copies: a [keys] override then shows + // the right shortcut here too, and an action cannot end up doing one thing + // from the menu bar and another from the context menu. + // + // Every entry applies to the whole selection already, since they all funnel + // through tagSelected(), so this needs no multi-row special casing. + m_threadContextMenu = new QMenu(this); + m_threadContextMenu->setObjectName(QStringLiteral("threadContextMenu")); + m_threadContextMenu->addAction(m_actions.value(QStringLiteral("archive"))); + m_threadContextMenu->addAction(m_actions.value(QStringLiteral("delete"))); + m_threadContextMenu->addAction(m_actions.value(QStringLiteral("spam"))); + m_threadContextMenu->addSeparator(); + m_threadContextMenu->addAction(m_actions.value(QStringLiteral("toggle_unread"))); + m_threadContextMenu->addAction(m_actions.value(QStringLiteral("flag"))); + m_threadContextMenu->addAction(m_actions.value(QStringLiteral("edit_tags"))); + m_threadContextMenu->addSeparator(); + m_threadContextMenu->addAction(m_actions.value(QStringLiteral("select_all"))); + + m_threadView->setContextMenuPolicy(Qt::CustomContextMenu); + connect(m_threadView, &QTableView::customContextMenuRequested, + this, &MainWindow::showThreadContextMenu); + // The frequent subset only. A toolbar holding every action is as // unreadable as no toolbar. auto *toolBar = addToolBar(tr("Main")); @@ -787,6 +906,18 @@ void MainWindow::showShortcutReference() "</tr></table>") .arg(left, right)); + // Mouse selection is view behaviour, not an action, so it cannot appear in + // the table above however the table is generated. Said here because it is + // otherwise undiscoverable: nothing in the UI hints that a thread list + // takes more than one row at a time. + auto *selectionNote = new QLabel( + tr("<b>Thread list:</b> <tt>Ctrl</tt>+click adds or removes a single " + "row, <tt>Shift</tt>+click extends the selection to a range. Tag, " + "archive and delete all apply to every selected thread."), + &dialog); + selectionNote->setTextFormat(Qt::RichText); + selectionNote->setWordWrap(true); + auto *note = new QLabel( tr("Rebind any of these in the <tt>[keys]</tt> section of " "<tt>qtmaildir.conf</tt>, using the action name."), @@ -799,6 +930,7 @@ void MainWindow::showShortcutReference() auto *layout = new QVBoxLayout(&dialog); layout->addWidget(label); + layout->addWidget(selectionNote); layout->addWidget(note); layout->addStretch(); layout->addWidget(buttons); @@ -970,7 +1102,74 @@ void MainWindow::onQueryFinished(int total, quint64 generation) { if (generation != m_generation) return; - m_statusLabel->setText(tr("%n thread(s)", "", total)); + // The query's own result is what the bar says when nothing more pressing + // is happening, so a transient message falls back to it rather than to + // nothing. + m_defaultStatus = tr("%n thread(s)", "", total); + m_statusLabel->setText(m_defaultStatus); +} + +void MainWindow::showThreadContextMenu(const QPoint &pos) +{ + const QModelIndex index = m_threadView->indexAt(pos); + if (!index.isValid()) + return; // Right-click on empty space below the rows. + + // Right-clicking a row that is already part of the selection must leave + // that selection alone: the actions apply to every selected thread, so + // collapsing to the clicked row here would silently narrow a deliberate + // multi-row selection to one. Right-clicking outside it selects that row + // instead, which is what every other list does. + if (!m_threadView->selectionModel()->isRowSelected(index.row())) + m_threadView->selectRow(index.row()); + + m_threadContextMenu->popup(m_threadView->viewport()->mapToGlobal(pos)); +} + +void MainWindow::onSelectionChanged() +{ + const int selected = m_threadView->selectionModel()->selectedRows().size(); + if (selected <= 1) { + // Clearing the count here would wipe whatever the last action reported + // ("Archive: 3 threads"), which is the more useful message once the + // selection is gone. Only a count this function wrote is taken back. + if (m_statusLabel->text() == m_selectionMessage) + m_statusLabel->clear(); + m_selectionMessage.clear(); + + // Collapsing a multi-row selection back to one row has to load that + // row here, and cannot be left to onThreadSelected. currentRowChanged + // is emitted BEFORE the selection model is updated (verified against + // Qt 6.11), so when a click collapses three rows to one, that handler + // still sees three selected, takes the multi-select branch and returns + // without loading anything. Only this signal sees the real count. + const QModelIndex current = m_threadView->currentIndex(); + if (current.isValid() + && m_model->threadAt(current.row()).threadId != m_currentThreadId) { + onThreadSelected(current, QModelIndex()); + } + return; + } + + // The count is the part that actually teaches multi-select: it acknowledges + // the selection while it is being built, rather than only after an action + // has already been applied to it. + m_selectionMessage = tr("%n thread(s) selected", "", selected); + m_statusLabel->setText(m_selectionMessage); + + // State, not an event: it must persist while the selection does. Cancel any + // transient message still counting down, or that timer fires and replaces a + // count that is still true. + m_statusTimer->stop(); + m_transientMessage.clear(); + + // Ctrl+click and selectAll() reach a multi-row selection without moving + // current, so onThreadSelected never runs and its guard never fires. The + // pane and the pending timer have to be dealt with here as well. + m_markReadTimer->stop(); + m_markReadThreadId.clear(); + m_currentThreadId.clear(); + m_messageView->clear(); } void MainWindow::onThreadSelected(const QModelIndex ¤t, @@ -979,6 +1178,31 @@ void MainWindow::onThreadSelected(const QModelIndex ¤t, if (!current.isValid()) return; + // A selection spanning more than one row is aimed at a bulk action, not at + // reading. current follows the keyboard cursor as the selection extends, so + // without this every row swept through would be rendered and, worse, + // queued to be marked read: a selection gesture must not mutate mail. + // + // The count read here is deliberately not trusted on its own. This signal + // is emitted BEFORE the selection model is updated (verified against + // Qt 6.11), so a Ctrl+click that takes the selection from one row to two + // arrives here still reporting one. onSelectionChanged() always follows and + // sees the true count, and it is what finally blanks the pane and cancels + // the timer; this branch only catches the case where the count is already + // stale in the other direction. + // + // The stop() is not redundant with the guard. Clicking one row arms a timer + // legitimately and only then does the selection grow, so the timer already + // running for that first row has to be cancelled here or it fires behind a + // pane that no longer shows the thread. + if (m_threadView->selectionModel()->selectedRows().size() > 1) { + m_markReadTimer->stop(); + m_markReadThreadId.clear(); + m_currentThreadId.clear(); + m_messageView->clear(); + return; + } + const ThreadSummary thread = m_model->threadAt(current.row()); m_currentThreadId = thread.threadId; m_messageView->setTags(thread.tags); @@ -995,6 +1219,14 @@ void MainWindow::onThreadLoaded(const QVector<MessageRef> &messages, if (generation != m_generation || messages.isEmpty()) return; + // A load started while the selection was still a single row can land after + // it has grown: loadThread crosses to the worker on a queued connection, so + // the reply arrives after onSelectionChanged() has already blanked the + // pane. Without this it would paint a thread back over the blank, and the + // pane would only look right once a third row made the count stale-proof. + if (m_threadView->selectionModel()->selectedRows().size() > 1) + return; + MimeParser parser; QList<ThreadRenderItem> items; items.reserve(messages.size()); @@ -1067,10 +1299,11 @@ void MainWindow::onSyncFinished(bool success, int exitCode) // Only a SUCCESSFUL sync clears the count. Clearing on failure would // assert the edits had reached the mail store when the sync is exactly // what failed to put them there. - m_pendingEdits = 0; + m_pendingTagEdits.clear(); + m_unnettablePendingEdits = 0; updatePendingIndicator(); - m_statusLabel->setText(tr("Sync complete")); + showTransientStatus(tr("Sync complete")); if (m_syncingForExit) { // The work is safely across, so finish the quit the user asked for. @@ -1084,11 +1317,17 @@ void MainWindow::onSyncFinished(bool success, int exitCode) // A sync is the usual way new tags enter the database. requestAllTags(); } else if (exitCode == kSyncSkippedExitCode) { + // Skipped means the lock was never ours: some other run holds it. If + // both started inside the same poll interval the monitor will have + // latched this lock period as local, which would swallow the report + // when that other run finishes. Hand it back. + m_localSyncHoldsLock = false; + // Not a failure: another run holds the lock and is doing the work. // The user's cron fires every ten minutes, so a click landing inside // one is routine and must not raise an error or the log pane. - m_statusLabel->setText(tr("A sync is already running (started " - "elsewhere); this one was skipped")); + showTransientStatus(tr("A sync is already running (started " + "elsewhere); this one was skipped")); if (m_syncingForExit) { // The other run is syncing, but this application cannot see when @@ -1125,10 +1364,33 @@ void MainWindow::onTagsApplied(const TagChange &change) m_pendingChange = {}; m_pendingThreadIds.clear(); - // Counted here, where a write is CONFIRMED, rather than where one is sent: + // Recorded here, where a write is CONFIRMED, rather than where one is sent: // an optimistic update the worker later rejects must not leave the // indicator claiming an edit that never landed. - ++m_pendingEdits; + // + // NET state, not a count of writes. An edit and its inverse leave the mail + // store where it started, so they must leave the indicator at zero: the + // automatic mark-read followed by Ctrl+U used to read as 2 unsynced + // changes when nothing was outstanding. What the user needs to know is + // whether quitting now would strand work. + // + // Keyed per (message, tag): removing `unread` and adding `flagged` on one + // message are two independent changes and must not cancel each other. + for (const QString &messageId : change.messageIds) { + for (const QString &tag : change.added) + recordPendingEdit(messageId, tag, true); + for (const QString &tag : change.removed) + recordPendingEdit(messageId, tag, false); + } + + // A change carrying no message ids cannot be netted against anything, and + // must still register: losing an edit understates the indicator, which is + // the direction that costs the user work. + if (change.messageIds.isEmpty() + && !(change.added.isEmpty() && change.removed.isEmpty())) { + ++m_unnettablePendingEdits; + } + updatePendingIndicator(); // A tag the user has just created is the one they are most likely to type @@ -1142,28 +1404,129 @@ void MainWindow::onTagsApplied(const TagChange &change) } } +void MainWindow::onExternalSyncStateChanged(SyncMonitor::State state) +{ + if (state == SyncMonitor::State::Running) { + // A sync this window started is already reported by setSyncBusy(). + // Remember that this particular lock period is ours, because the + // release at the end of it must be ignored too: the process exits, and + // therefore isRunning() goes false, BEFORE the monitor's next poll sees + // the lock gone. Testing isRunning() again on that poll would report a + // local sync as an external one, stamping "background sync completed" + // over the local run's own result up to two seconds later. + m_localSyncHoldsLock = (m_sync && m_sync->isRunning()); + if (m_localSyncHoldsLock) + return; + + m_externalSyncBusy = true; + updateSyncControls(); + m_statusLabel->setText(tr("Background sync running...")); + return; + } + + // The release of a lock this window took. onSyncFinished() has already + // said what happened, including for a failure, so there is nothing to add. + if (m_localSyncHoldsLock) { + m_localSyncHoldsLock = false; + m_externalSyncBusy = false; + updateSyncControls(); + return; + } + + // Cleared for Idle AND for Unknown. Unknown means /proc/locks could not be + // read, so nothing is observed; leaving the button disabled there would + // strand it permanently on a platform that cannot see the lock at all. + m_externalSyncBusy = false; + updateSyncControls(); + + // Deliberately reports rather than refreshes. runCurrentQuery() clears the + // undo stack, the selection and the message pane, which is right for a + // query the user typed and hostile for one fired by a cron timer: with a + // sync every ten minutes it would discard undo history and close the thread + // being read, up to six times an hour, with no action from the user. + // + // Unknown is not worth reporting either. It means the lock table could not + // be read, so nothing was observed, and "sync finished" would be a claim + // this cannot support. + if (state == SyncMonitor::State::Idle) { + showTransientStatus( + tr("Background sync completed. Press Enter in the query bar to " + "refresh.")); + } +} + +void MainWindow::showTransientStatus(const QString &text) +{ + m_transientMessage = text; + m_statusLabel->setText(text); + m_statusTimer->start(); +} + void MainWindow::setSyncBusy(bool busy) { + m_localSyncBusy = busy; + updateSyncControls(); + + if (busy) + m_statusLabel->setText(tr("Syncing...")); +} + +void MainWindow::updateSyncControls() +{ + // ONE function of both states, deliberately. Two independent assignments, + // one per sync path, means whichever fires second wins: a background sync + // ending would re-enable the button in the middle of a local run, and a + // local run ending would re-enable it while cron still holds the lock. + const bool busy = m_localSyncBusy || m_externalSyncBusy; + m_syncProgress->setVisible(busy); + // Disabled rather than left clickable: MailSync::start() already refuses a - // second run, but a button that looks live and does nothing is worse than - // one that shows it is unavailable. + // second run and the script exits 75 when another holds the lock, but a + // button that looks live and does nothing is worse than one that shows it + // is unavailable. + // + // Note this reads Running specifically, not "not Idle". Unknown means + // /proc/locks could not be read and nothing was observed, so the button + // stays usable: permanently disabling it where the lock cannot be seen is + // worse than occasionally offering a run that gets skipped. m_syncButton->setEnabled(!busy && m_sync && m_sync->isAvailable()); +} - if (busy) - m_statusLabel->setText(tr("Syncing...")); +void MainWindow::recordPendingEdit(const QString &messageId, const QString &tag, + bool added) +{ + const QString key = messageId + QLatin1Char('\n') + tag; + + // A tag put back the way it was is not an outstanding change. Erase rather + // than store the new direction, or the ledger grows without bound over a + // long session of tagging and untagging. + const auto existing = m_pendingTagEdits.constFind(key); + if (existing != m_pendingTagEdits.constEnd()) { + if (*existing != added) + m_pendingTagEdits.erase(m_pendingTagEdits.find(key)); + return; + } + + m_pendingTagEdits.insert(key, added); +} + +int MainWindow::pendingEditCount() const +{ + return m_pendingTagEdits.size() + m_unnettablePendingEdits; } void MainWindow::updatePendingIndicator() { - if (m_pendingEdits <= 0) { + const int pending = pendingEditCount(); + if (pending <= 0) { m_pendingLabel->hide(); return; } // "Changes" and not "mutations": the unit the user thinks in is the tagging // they did, not the writes it became. - m_pendingLabel->setText(tr("%n unsynced change(s)", "", m_pendingEdits)); + m_pendingLabel->setText(tr("%n unsynced change(s)", "", pending)); m_pendingLabel->setToolTip( tr("Tag changes made here that a sync has not yet carried to the mail " "store. An external notmuch run can clear them without this count " @@ -1244,7 +1607,7 @@ void MainWindow::editTagsOnSelection() const QModelIndexList rows = m_threadView->selectionModel()->selectedRows(); if (rows.isEmpty()) { - m_statusLabel->setText(tr("Select a thread first")); + showTransientStatus(tr("Select a thread first")); return; } @@ -1294,7 +1657,7 @@ void MainWindow::tagSelected(const QStringList &add, const QStringList &remove, m_undoStack.push(new ThreadTagCommand(this, threadIds, add, remove, description)); - m_statusLabel->setText( + showTransientStatus( tr("%1: %n thread(s)", "", threadIds.size()).arg(description)); } |
