aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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"