aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/mainwindow.cpp168
-rw-r--r--src/mainwindow.h65
-rw-r--r--src/threadlistmodel.cpp11
-rw-r--r--src/threadlistmodel.h5
4 files changed, 221 insertions, 28 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index d9aa57a..423aa7b 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -1607,33 +1607,82 @@ void MainWindow::showThreadContextMenu(const QPoint &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();
+ const QModelIndexList rows = m_threadView->selectionModel()->selectedRows();
+ const int selected = rows.size();
+ if (selected == 1) {
+ // One row selected. With two kinds of row this is exactly where the
+ // scope became ambiguous: a thread root stands for every message in it,
+ // a message row for one, and the keypress looks identical. Naming it
+ // here is what this project does instead of a confirmation dialog,
+ // which CLAUDE.md rules out for tag mutations.
+ const ActionScope scope = m_model->scopeFor(rows);
+
+ if (scope.wholeThread) {
+ m_selectionMessage =
+ tr("1 thread selected (%n message(s))", "", scope.messageCount);
+ m_statusLabel->setText(m_selectionMessage);
+ m_statusTimer->stop();
+ m_transientMessage.clear();
+ } else {
+ // Reading one message is not a bulk action and gets no count.
+ 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.
+ // 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 that handler still sees the old count and returns
+ // without loading anything.
+ //
+ // Compared per row kind. A message row's row number indexes its
+ // siblings, so threadAt() on one answers about an unrelated thread and
+ // the comparison below would be against the wrong id.
const QModelIndex current = m_threadView->currentIndex();
- if (current.isValid()
- && m_model->threadAt(current.row()).threadId != m_currentThreadId) {
- onThreadSelected(current, QModelIndex());
+ if (current.isValid()) {
+ const bool changed =
+ m_model->isMessageRow(current)
+ ? m_model->messageAt(current).messageId != m_currentMessageId
+ : m_model->threadAt(current.row()).threadId
+ != m_currentThreadId;
+ if (changed)
+ onThreadSelected(current, QModelIndex());
}
return;
}
+ if (selected < 1) {
+ // Nothing selected. Clearing unconditionally would wipe whatever the
+ // last action reported ("Archive: 3 threads"), which is the more useful
+ // message once the selection is gone, so only a count this function
+ // wrote is taken back.
+ if (m_statusLabel->text() == m_selectionMessage)
+ m_statusLabel->clear();
+ m_selectionMessage.clear();
+ 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);
+ //
+ // Reported per row kind rather than as a bare row count, so a mixed
+ // selection says what it will really touch instead of calling three replies
+ // "3 threads".
+ const ActionScope scope = m_model->scopeFor(rows);
+ if (!scope.threadIds.isEmpty() && scope.messageIds.isEmpty()) {
+ m_selectionMessage =
+ tr("%n thread(s) selected (%1 messages)", "", scope.threadIds.size())
+ .arg(scope.messageCount);
+ } else if (scope.threadIds.isEmpty()) {
+ m_selectionMessage =
+ tr("%n message(s) selected", "", scope.messageIds.size());
+ } else {
+ m_selectionMessage =
+ tr("%n thread(s) and %1 message(s) selected", "",
+ scope.threadIds.size()).arg(scope.messageIds.size());
+ }
m_statusLabel->setText(m_selectionMessage);
// State, not an event: it must persist while the selection does. Cancel any
@@ -2457,20 +2506,83 @@ void MainWindow::tagSelected(const QStringList &add, const QStringList &remove,
if (rows.isEmpty())
return;
- QStringList threadIds;
- threadIds.reserve(rows.size());
- for (const QModelIndex &index : rows)
- threadIds.append(m_model->threadAt(index.row()).threadId);
+ // Resolved through the model rather than by mapping rows to threads here.
+ // A message row's row number indexes its siblings, so the old
+ // threadAt(index.row()) mapping silently acted on whichever thread sat at
+ // that position in the list.
+ const ActionScope scope = m_model->scopeFor(rows);
+ if (scope.isEmpty())
+ return;
- sendThreadTagChange(threadIds, add, remove, description);
+ if (!scope.threadIds.isEmpty()) {
+ sendThreadTagChange(scope.threadIds, add, remove, description);
- // Pushed for undo. The inverse re-resolves the same threads, so it works
- // whether or not those rows are still selected.
- m_undoStack.push(new ThreadTagCommand(this, threadIds, add, remove,
- description));
+ // Pushed for undo. The inverse re-resolves the same threads, so it
+ // works whether or not those rows are still selected.
+ m_undoStack.push(new ThreadTagCommand(this, scope.threadIds, add,
+ remove, description));
+ }
+ if (!scope.messageIds.isEmpty()) {
+ sendMessageTagChange(scope.messageIds, add, remove, description);
+ m_undoStack.push(new MessageTagCommand(this, scope.messageIds, add,
+ remove, description));
+ }
+
+ // The scope named after the fact, since the selection may well be gone by
+ // the time the user reads it. This is what stands in for the confirmation
+ // dialog CLAUDE.md rules out: undo is the safety net, and undo is only
+ // usable if the user can tell that something larger than they meant has
+ // just happened.
showTransientStatus(
- tr("%1: %n thread(s)", "", threadIds.size()).arg(description));
+ scope.wholeThread
+ ? tr("%1: %n message(s) (whole thread)", "", scope.messageCount)
+ .arg(description)
+ : tr("%1: %n message(s)", "", scope.messageCount).arg(description));
+}
+
+void MainWindow::sendMessageTagChange(const QStringList &messageIds,
+ const QStringList &add,
+ const QStringList &remove,
+ const QString &description)
+{
+ if (messageIds.isEmpty())
+ return;
+
+ // No optimistic model update. applyTagChange is keyed by THREAD and would
+ // repaint the whole row as though every message in it had changed, which
+ // for a one-message edit is a lie the user would see and then watch
+ // silently correct itself on the next query.
+
+ // The accounts this touches, resolved through the containing threads: the
+ // account is a property of the thread, and the sync needs the channel
+ // whether one message moved or seven.
+ for (const QString &messageId : messageIds) {
+ const QString threadId = m_model->threadIdForMessage(messageId);
+ if (threadId.isEmpty())
+ continue;
+ for (const QString &key : m_model->accountKeysForThread(threadId))
+ m_editedAccounts.insert(key);
+ }
+
+ // Held during a sync for exactly the reason the thread path is: the
+ // worker's read-write open BLOCKS on notmuch's exclusive lock rather than
+ // failing, so sending now would freeze the worker for the rest of the run.
+ if (aSyncHoldsTheWriteLock()) {
+ m_heldEdits.append(HeldEdit{
+ {}, TagChange{ messageIds, add, remove, description } });
+ m_statusLabel->setText(
+ tr("A sync is running; your change will be applied when it "
+ "finishes."));
+ updatePendingIndicator();
+ return;
+ }
+
+ m_pendingThreadIds.clear();
+ m_pendingChange = TagChange{ messageIds, add, remove, description };
+
+ QMetaObject::invokeMethod(m_worker, "applyTags", Qt::QueuedConnection,
+ Q_ARG(TagChange, m_pendingChange));
}
void MainWindow::sendThreadTagChange(const QStringList &threadIds,
diff --git a/src/mainwindow.h b/src/mainwindow.h
index a4eca20..6b9e557 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -129,6 +129,19 @@ public:
/// 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
@@ -355,6 +368,13 @@ private:
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();
@@ -388,6 +408,7 @@ private:
QVector<HeldEdit> m_heldEdits;
friend class ThreadTagCommand;
+ friend class MessageTagCommand;
Config m_config;
KeyMap m_keyMap;
@@ -629,3 +650,47 @@ private:
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;
+};
diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp
index f9efee7..9a74041 100644
--- a/src/threadlistmodel.cpp
+++ b/src/threadlistmodel.cpp
@@ -594,6 +594,17 @@ MessageNode ThreadListModel::messageAt(const QModelIndex &index) const
return children.at(index.row());
}
+QString ThreadListModel::threadIdForMessage(const QString &messageId) const
+{
+ for (const ThreadNode &node : m_threads) {
+ for (const MessageNode &child : node.children) {
+ if (child.messageId == messageId)
+ return node.summary.threadId;
+ }
+ }
+ return {};
+}
+
ActionScope ThreadListModel::scopeFor(const QModelIndexList &selection) const
{
ActionScope scope;
diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h
index c56e80a..b80488b 100644
--- a/src/threadlistmodel.h
+++ b/src/threadlistmodel.h
@@ -188,6 +188,11 @@ public:
/// is not a message row.
MessageNode messageAt(const QModelIndex &index) const;
+ /// The thread a loaded message row belongs to, or empty when no expanded
+ /// thread holds it. Only expanded threads have message rows at all, so a
+ /// message the user could select is always findable here.
+ QString threadIdForMessage(const QString &messageId) const;
+
/// Resolves a selection into what an action should touch.
///
/// Mixed selections are honoured as given: a thread root and an unrelated