aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-29 18:18:33 +0200
committerDanilo M. <danix@danix.xyz>2026-09-29 18:18:33 +0200
commitbed9d291370b2b198b2d146c7081066b6dd70641 (patch)
tree23f1aa21358456c7df7b373fefed23538926b85b
parent04e7db97ae421212614a6ed92807485f743966d3 (diff)
downloadqtmaildir-bed9d291370b2b198b2d146c7081066b6dd70641.tar.gz
qtmaildir-bed9d291370b2b198b2d146c7081066b6dd70641.zip
fix: find a launch's thread in any account, and name a miss
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>
-rw-r--r--CHANGELOG.md5
-rw-r--r--README.md20
-rw-r--r--src/mainwindow.cpp72
-rw-r--r--src/mainwindow.h43
-rw-r--r--tests/test_mainwindow.cpp154
5 files changed, 283 insertions, 11 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bc81425..4a9879a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,11 @@ point at which they are stable.
- `--account`, `--thread` and `--message` on the command line, so another
program can open qtmaildir at a particular account's view, conversation or
message. The three combine, and Qt's own options are accepted beside them.
+ `--account` alone opens the startup view in that account; `--thread` and
+ `--message` look in every account unless `--account` narrows them, and
+ `--message` accepts a Message-ID with or without its angle brackets. A
+ selector that matches nothing is named in the status bar and the window
+ keeps the view it had.
- A second launch now hands its selectors to the already-running window over a
local socket and asks it to raise itself, rather than opening a second
window. One process, and so one notmuch database handle.
diff --git a/README.md b/README.md
index a2288f4..8443b57 100644
--- a/README.md
+++ b/README.md
@@ -54,10 +54,15 @@ qtmaildir [options]
--message <id> Open this message, inside its thread
```
-The three selectors combine: `--account work --message '<abc@example.org>'`
-opens that message in the work account's view. `--thread` takes a notmuch
-thread id (hex digits only); anything else is reported as a miss in the status
-bar and never reaches notmuch. Qt's own options, such as `-platform` and
+`--account` on its own opens the startup view (`startup_query`) in that
+account. `--thread` and `--message` open the whole conversation with that
+message selected, and look for it in every account, switching the account
+selector to All accounts. The three selectors combine: `--account work
+--message '<abc@example.org>'` looks for that message in the work account only,
+and a message that lives elsewhere is a miss. `--message` takes a Message-ID
+with or without its angle brackets. `--thread` takes a notmuch thread id (hex
+digits only); anything else is reported as a miss in the status bar and never
+reaches notmuch. Qt's own options, such as `-platform` and
`-style`, are accepted alongside them. An unknown option or a missing value
prints `qtmaildir: <error>` on stderr and exits with status 2.
@@ -73,9 +78,10 @@ Under a Wayland compositor, raising a window is a request rather than a
command: the compositor may honour it, or apply its own focus policy. The
selectors are applied either way.
-A selector that matches nothing, a stale thread id or an account key that is
-not configured, leaves the window on its normal startup view and says what
-missed in the status bar. It is never a reason to refuse to start.
+A selector that matches nothing, a stale thread or message id, one outside
+the account given with `--account`, or an account key that is not configured,
+leaves the window on the view it was showing and says what missed in the status
+bar. It is never a reason to refuse to start.
## Building
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 3ae16e9..3c94c5c 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -3727,6 +3727,8 @@ void MainWindow::runQuery(FlatResult flat, AccountScope scope)
// own query does not clear it.
m_recoverThreadId.clear();
m_recoverMessageId.clear();
+ m_launchMiss.clear();
+ m_launchMissNotice.clear();
++m_generation;
m_model->clear();
@@ -3848,6 +3850,26 @@ void MainWindow::onQueryFinished(int total, quint64 generation)
m_queryComplete = true;
updateViewWideActions();
+ // A launch selector's thread:<id> coming back empty: the thread is not in
+ // the scope it was looked for in, or anywhere. Named, and the view the
+ // user had comes back, rather than leaving an empty list that makes a
+ // stale link look like a broken client.
+ if (!m_launchMiss.isEmpty()) {
+ const QString miss = m_launchMiss;
+ m_launchMiss.clear();
+ if (total == 0) {
+ reportLaunchMiss(miss);
+ return;
+ }
+ }
+
+ // The restored view has landed, so its row count is on the bar and the
+ // miss can go over it.
+ if (!m_launchMissNotice.isEmpty()) {
+ showTransientStatus(m_launchMissNotice);
+ m_launchMissNotice.clear();
+ }
+
// A recovery's own thread:<id> query landing. The rows exist now, so the
// thread can be expanded; the message inside it is selected once its
// replies arrive.
@@ -5265,6 +5287,12 @@ void MainWindow::applySelectors(const LaunchSelectors &requested)
selectors.messageId.mid(1, selectors.messageId.size() - 2);
}
+ // Recorded BEFORE anything moves, the dropdown included, so a miss can
+ // put back exactly what the user was looking at when the launch arrived.
+ m_launchView = { m_accountBox->currentIndex(), m_queryEdit->text(),
+ m_lastQuery, m_sentView };
+ m_launchAccountGiven = false;
+
// The account FIRST, and the order matters: a built-in filter composes
// with the dropdown, so a query run before the account moved would carry
// the old scope. This is the same ordering the startup path uses.
@@ -5272,6 +5300,7 @@ void MainWindow::applySelectors(const LaunchSelectors &requested)
const int index = m_accountBox->findData(selectors.account);
if (index >= 0) {
m_accountBox->setCurrentIndex(index);
+ m_launchAccountGiven = true;
} else {
// Named, so a caller passing a stale key can be debugged from the
// client rather than from the caller.
@@ -5323,11 +5352,19 @@ void MainWindow::applySelectors(const LaunchSelectors &requested)
static const QRegularExpression hex(
QRegularExpression::anchoredPattern(QStringLiteral("[0-9a-fA-F]+")));
if (!hex.match(selectors.threadId).hasMatch()) {
- showTransientStatus(
+ reportLaunchMiss(
tr("No thread matched '%1'.").arg(selectors.threadId));
return;
}
+ // Without --account the thread is looked for in EVERY account. The
+ // recovery runs in the dropdown's scope, so a window left on work
+ // searched work alone for a personal thread and blanked the list. With
+ // --account the caller chose the scope, and a thread outside it is a
+ // miss, reported below when the query comes back empty.
+ if (!m_launchAccountGiven)
+ m_accountBox->setCurrentIndex(m_accountBox->findData(QString()));
+
// recoverStaleThread() is reused whole. It runs thread:<id>, remembers
// the target across the queued round trips the load takes, expands
// the thread when its row arrives and selects it. Item 91's
@@ -5336,6 +5373,8 @@ void MainWindow::applySelectors(const LaunchSelectors &requested)
// The empty message id is meaningful to it: land on the ROOT row,
// which is the thread's first message.
recoverStaleThread(selectors.threadId, QString());
+ // After the query, which clears it: this is that query's own miss.
+ m_launchMiss = tr("No thread matched '%1'.").arg(selectors.threadId);
}
}
@@ -5346,15 +5385,44 @@ void MainWindow::onThreadForMessageResolved(const QString &messageId,
// The miss path, and the window stays where it is. A message id from
// another program can be stale for every ordinary reason: the mail was
// deleted, moved by another client, or never indexed here.
- showTransientStatus(tr("No message matched '%1'.").arg(messageId));
+ reportLaunchMiss(tr("No message matched '%1'.").arg(messageId));
return;
}
+ // Every account unless the launch named one, as for --thread.
+ if (!m_launchAccountGiven)
+ m_accountBox->setCurrentIndex(m_accountBox->findData(QString()));
+
// The THREAD, with that message selected. An id: query on the message
// alone would show one card out of its conversation, which item 91 settled
// is the wrong reading of "open this message". The thread id came from
// notmuch, so it is safe to hand on unquoted.
recoverStaleThread(threadId, messageId);
+ // Named by the MESSAGE, which is what the caller asked for; the thread id
+ // is ours and would mean nothing to them.
+ m_launchMiss = tr("No message matched '%1'.").arg(messageId);
+}
+
+void MainWindow::reportLaunchMiss(const QString &text)
+{
+ const LaunchView view = m_launchView;
+ m_accountBox->setCurrentIndex(view.accountIndex);
+
+ // Nothing ran since the launch arrived, so the list is still the view the
+ // user had and a re-run would only flicker it. A refused thread id and a
+ // message that did not resolve both land here.
+ if (m_lastQuery == view.lastQuery || view.lastQuery.isEmpty()) {
+ showTransientStatus(text);
+ return;
+ }
+
+ m_queryEdit->setText(view.queryText);
+ runQuery(view.flat ? FlatResult::Yes : FlatResult::No,
+ view.queryText.trimmed() == view.lastQuery
+ ? AccountScope::AlreadyScoped
+ : AccountScope::Apply);
+ // After runQuery(), which clears it.
+ m_launchMissNotice = text;
}
void MainWindow::recoverStaleThread(const QString &threadId,
diff --git a/src/mainwindow.h b/src/mainwindow.h
index d4b0975..87ed171 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -143,8 +143,8 @@ public:
/// against a running window means "raise yourself", and a raise is not a
/// navigation. The user is looking at something.
///
- /// A selector that matches nothing leaves the window on its configured
- /// view and names the miss in the status bar. Not an empty result, which
+ /// A selector that matches nothing leaves the window on the view it was
+ /// showing and names the miss in the status bar. Not an empty result, which
/// makes a stale link look like a broken client; not a refusal, which is
/// right for a script and wrong for a desktop launch.
///
@@ -758,6 +758,10 @@ private slots:
/// drive it through the meta-object.
void showTransientStatus(const QString &text);
+ /// Names a launch selector's miss and puts back the view m_launchView
+ /// recorded, re-running its query only when the list has since changed.
+ void reportLaunchMiss(const QString &text);
+
/// Announces an action that has just been sent, saying so when a sync is
/// holding it rather than claiming it landed.
///
@@ -1850,6 +1854,41 @@ private:
QString m_recoverThreadId;
QString m_recoverMessageId;
+ /// What was on screen before a launch's selectors started changing it, so
+ /// a miss can put it back (item 200: "the startup view stays and the miss
+ /// is named").
+ ///
+ /// The bar's text and the query the list was built from are BOTH kept:
+ /// the scope a re-run needs is read off whether they differ. Equal means
+ /// the text was already scoped (a filter, or All accounts); different
+ /// means runQuery() wrapped it in the dropdown's account.
+ struct LaunchView
+ {
+ int accountIndex = -1;
+ QString queryText;
+ QString lastQuery;
+ bool flat = false;
+ };
+ LaunchView m_launchView;
+
+ /// Whether the launch that set m_launchView named an account that exists.
+ /// Read when a --message resolve lands, which is after applySelectors()
+ /// has returned: without --account the conversation is looked for in
+ /// every account, with it the given scope is kept.
+ bool m_launchAccountGiven = false;
+
+ /// The miss to report if the selector's own thread:<id> query returns no
+ /// rows. Set AFTER recoverStaleThread(), for the reason the recovery target
+ /// is, and cleared by runQuery(), so only the selector's query is judged
+ /// by it and the stale-thread notice and double-click, which share the
+ /// recovery, keep their behaviour.
+ QString m_launchMiss;
+
+ /// A miss waiting for the restored view's query to land, since that
+ /// query's own row count is written to the status bar when it does and
+ /// would cover a notice shown any earlier.
+ QString m_launchMissNotice;
+
/// The thread the pane's current MESSAGE belongs to.
///
/// Selecting a message row clears m_currentThreadId (the pane shows one
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index e89ad69..8ffa806 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -330,6 +330,10 @@ private slots:
void anEmptySelectorSetChangesNothing();
void aBracketedMessageSelectorOpensThatMessage();
void anAccountSelectorAloneShowsThatAccountsStartupView();
+ void aMessageSelectorFindsItsThreadInAnyAccount();
+ void aThreadSelectorFindsItsThreadInAnyAccount();
+ void aThreadOutsideTheGivenAccountIsAMissAndRestoresTheView();
+ void aThreadThatExistsNowhereIsAMissAndRestoresTheView();
void everyBuiltinFilterButtonCarriesAnIconAndItsText();
void theDraftsButtonIsAbsentWithoutADraftsFolder();
void aQueryInTheMenuCanActuallyBeRun();
@@ -18201,4 +18205,154 @@ void TestMainWindow::anAccountSelectorAloneShowsThatAccountsStartupView()
QCOMPARE(model->threadAt(0).subject, QStringLiteral("Work subject"));
}
+void TestMainWindow::aMessageSelectorFindsItsThreadInAnyAccount()
+{
+ // The window is scoped to work and the message lives in personal. The
+ // recovery runs thread:<id> in the dropdown's scope, so without --account
+ // the conversation was searched for in work alone, came back empty, and
+ // the list went blank with nothing said. With no account asked for, the
+ // conversation is found wherever it lives.
+ WorkerBackedWindow backed;
+ QVERIFY2(buildTwoAccounts(backed), qPrintable(backed.error()));
+
+ NotmuchWorker probe(backed.config().notmuchConfig());
+ const QString threadId =
+ probe.threadIdForTesting(QStringLiteral("id:p1@example.org"));
+ QVERIFY(!threadId.isEmpty());
+
+ MainWindow window(backed.config());
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(view);
+ window.selectAccountForTesting(QStringLiteral("work"));
+ QCOMPARE(window.selectedAccountForTesting(), QStringLiteral("work"));
+
+ LaunchSelectors selectors;
+ selectors.messageId = QStringLiteral("p1@example.org");
+ window.applySelectors(selectors);
+
+ QTRY_VERIFY_WITH_TIMEOUT(view->currentIndex().isValid()
+ && model->threadFor(view->currentIndex())
+ .threadId
+ == threadId,
+ 15000);
+ // "All accounts" is the entry with no account key.
+ QCOMPARE(window.selectedAccountForTesting(), QString());
+}
+
+void TestMainWindow::aThreadSelectorFindsItsThreadInAnyAccount()
+{
+ // The --thread twin of the case above. A separate code path in
+ // applySelectors(), so it needs its own test: the message one passes with
+ // this path left scoped to work.
+ WorkerBackedWindow backed;
+ QVERIFY2(buildTwoAccounts(backed), qPrintable(backed.error()));
+
+ NotmuchWorker probe(backed.config().notmuchConfig());
+ const QString threadId =
+ probe.threadIdForTesting(QStringLiteral("id:p1@example.org"));
+ QVERIFY(!threadId.isEmpty());
+
+ MainWindow window(backed.config());
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY(view);
+ window.selectAccountForTesting(QStringLiteral("work"));
+ QCOMPARE(window.selectedAccountForTesting(), QStringLiteral("work"));
+
+ LaunchSelectors selectors;
+ selectors.threadId = threadId;
+ window.applySelectors(selectors);
+
+ QTRY_VERIFY_WITH_TIMEOUT(view->currentIndex().isValid()
+ && model->threadFor(view->currentIndex())
+ .threadId
+ == threadId,
+ 15000);
+ QCOMPARE(window.selectedAccountForTesting(), QString());
+}
+
+/// Waits for the startup view to land and returns the query it ran, so a
+/// test can assert the same view came back after a miss.
+static QString settledStartupQuery(MainWindow &window, ThreadListModel *model)
+{
+ if (!QTest::qWaitFor([&]() { return model->rowCount(QModelIndex()) == 2; },
+ 15000))
+ return QString();
+ return window.lastRunQueryForTesting();
+}
+
+void TestMainWindow::aThreadOutsideTheGivenAccountIsAMissAndRestoresTheView()
+{
+ // --account work --thread <a personal thread>. The account was asked for,
+ // so the scope is kept and the thread is not found in it. That is a miss,
+ // and a miss names itself and leaves the user on the view they had rather
+ // than on an empty list.
+ WorkerBackedWindow backed;
+ QVERIFY2(buildTwoAccounts(backed), qPrintable(backed.error()));
+
+ NotmuchWorker probe(backed.config().notmuchConfig());
+ const QString threadId =
+ probe.threadIdForTesting(QStringLiteral("id:p1@example.org"));
+ QVERIFY(!threadId.isEmpty());
+
+ MainWindow window(backed.config());
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
+ QVERIFY(status);
+
+ const QString before = settledStartupQuery(window, model);
+ QVERIFY(!before.isEmpty());
+ const quint64 generation = window.currentGenerationForTesting();
+
+ LaunchSelectors selectors;
+ selectors.account = QStringLiteral("work");
+ selectors.threadId = threadId;
+ window.applySelectors(selectors);
+
+ // The thread query DID run in the work scope, which is what makes this the
+ // out-of-scope case rather than one refused before reaching notmuch.
+ QVERIFY(window.currentGenerationForTesting() > generation);
+
+ QTRY_VERIFY_WITH_TIMEOUT(status->text().contains(threadId)
+ && window.lastRunQueryForTesting() == before
+ && model->rowCount(QModelIndex()) == 2,
+ 15000);
+ QCOMPARE(window.selectedAccountForTesting(), QString());
+}
+
+void TestMainWindow::aThreadThatExistsNowhereIsAMissAndRestoresTheView()
+{
+ // Well-formed hex, so it passes the syntax check and reaches notmuch, and
+ // matches nothing there. A stale link, which is the ordinary miss.
+ WorkerBackedWindow backed;
+ QVERIFY2(buildTwoAccounts(backed), qPrintable(backed.error()));
+
+ MainWindow window(backed.config());
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY(model);
+ auto *status = window.findChild<QLabel *>(QStringLiteral("statusMessage"));
+ QVERIFY(status);
+
+ const QString before = settledStartupQuery(window, model);
+ QVERIFY(!before.isEmpty());
+
+ const QString missing = QStringLiteral("0123456789abcdef");
+ LaunchSelectors selectors;
+ selectors.threadId = missing;
+ window.applySelectors(selectors);
+
+ QTRY_VERIFY_WITH_TIMEOUT(status->text().contains(missing)
+ && window.lastRunQueryForTesting() == before
+ && model->rowCount(QModelIndex()) == 2,
+ 15000);
+ // Still a miss a moment later: the restored query landing must not stamp
+ // its row count over the notice.
+ QTest::qWait(200);
+ QVERIFY(status->text().contains(missing));
+}
+
#include "test_mainwindow.moc"