[Client] Keep Archidekt navigation tied to its active request (#7723)

* [Client] Keep Archidekt navigation tied to its active request [skip ci]

* [Client] Keep Archidekt listing pagination after a failed navigation

Only update list mode and the next-page link when a listing or deck
response is accepted, so a failed deck navigation leaves the existing
listing scrollable. Retry still re-issues the failed request.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
Bryce! 2026-11-03 23:59:17 -05:00 • committed by GitHub
parent 81a0b97240
commit 75d7764643
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 136 additions and 42 deletions

View file

@ -25,6 +25,7 @@
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QJsonValueRef>
#include <QLabel>
#include <QLayoutItem>
@ -60,8 +61,6 @@ TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor)
{
// Initialize network
networkManager = NetworkManagerFactory::create(this);
networkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy);
connect(networkManager, &QNetworkAccessManager::finished, this, &TabArchidekt::processApiJson);
// Initialize debounce timer
searchDebounceTimer = new QTimer(this);
@ -77,6 +76,48 @@ TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor)
getTopDecks();
}
TabArchidekt::~TabArchidekt()
{
cancelRequest();
}
void TabArchidekt::cancelRequest()
{
const QPointer<QNetworkReply> previousReply = activeReply;
activeReply = nullptr;
isLoadingMore = false;
if (previousReply) {
disconnect(previousReply, nullptr, this, nullptr);
previousReply->abort();
previousReply->deleteLater();
}
}
void TabArchidekt::startRequest(QUrl url, RequestKind kind, int page)
{
cancelRequest();
requestUrl = url;
requestKind = kind;
requestedPage = page;
isLoadingMore = true;
retryButton->hide();
requestStatusLabel->setText(kind == RequestKind::Deck ? tr("Loading deck…") : tr("Loading decks…"));
requestStatusLabel->show();
QNetworkRequest request(url);
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
auto *reply = networkManager->get(request);
activeReply = reply;
connect(reply, &QNetworkReply::finished, this, [this, reply]() { processApiJson(reply); });
}
void TabArchidekt::showRequestError(const QString &error)
{
requestStatusLabel->setText(tr("Unable to load decks: %1").arg(error));
requestStatusLabel->show();
retryButton->show();
}
void TabArchidekt::initializeUi()
{
// Main container
@ -194,6 +235,18 @@ void TabArchidekt::initializeUi()
mainLayout->addWidget(primaryToolbar);
mainLayout->addWidget(secondaryToolbar);
auto *requestStatusRow = new QHBoxLayout;
requestStatusLabel = new QLabel(container);
requestStatusLabel->setTextFormat(Qt::PlainText);
requestStatusLabel->setWordWrap(true);
retryButton = new QPushButton(tr("Retry"), container);
retryButton->hide();
connect(retryButton, &QPushButton::clicked, this,
[this]() { startRequest(requestUrl, requestKind, requestedPage); });
requestStatusRow->addWidget(requestStatusLabel, 1);
requestStatusRow->addWidget(retryButton);
mainLayout->addLayout(requestStatusRow);
mainLayout->addWidget(scrollArea);
setCentralWidget(container);
@ -404,6 +457,7 @@ void TabArchidekt::retranslateUi()
minDeckSizeLogicCombo->setItemText(0, tr("Exact"));
searchButton->setText(tr("Search"));
retryButton->setText(tr("Retry"));
settingsButton->setToolTip(tr("Display Settings"));
}
@ -517,82 +571,95 @@ QString TabArchidekt::buildSearchUrl()
void TabArchidekt::doSearch()
{
// Reset to first page on new search
currentPage = 1;
// We're searching, so we'll be in list mode
isListMode = true;
// Don't debounce - only called by explicit user actions now
doSearchImmediate();
}
void TabArchidekt::doSearchImmediate()
{
QString url = buildSearchUrl();
QNetworkRequest req{QUrl(url)};
req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
networkManager->get(req);
searchDebounceTimer->stop();
currentPage = 1;
startRequest(QUrl(buildSearchUrl()), RequestKind::Search);
}
void TabArchidekt::loadNextPage()
{
if (isLoadingMore) {
if (activeReply || !isListMode || !listingsWidget || nextPageUrl.isEmpty()) {
return;
}
isLoadingMore = true;
currentPage++;
QString url = buildSearchUrl();
QNetworkRequest req{QUrl(url)};
req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
networkManager->get(req);
// Continue the accepted search, even if the user has edited filters without submitting them.
// Advance currentPage only after this response has been accepted, so failures can retry it.
startRequest(nextPageUrl, RequestKind::NextPage, currentPage + 1);
}
void TabArchidekt::actNavigatePage(QString url)
{
QNetworkRequest request{QUrl(url)};
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
networkManager->get(request);
searchDebounceTimer->stop();
startRequest(QUrl(url), RequestKind::Deck);
}
void TabArchidekt::getTopDecks()
{
currentPage = 1;
QNetworkRequest request{QUrl(buildSearchUrl())};
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
networkManager->get(request);
doSearchImmediate();
}
void TabArchidekt::processApiJson(QNetworkReply *reply)
{
if (reply->error() != QNetworkReply::NoError) {
isLoadingMore = false;
if (reply != activeReply) {
reply->deleteLater();
return;
}
activeReply = nullptr;
isLoadingMore = false;
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
showRequestError(reply->errorString());
return;
}
QByteArray responseData = reply->readAll();
QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData);
QJsonParseError parseError;
QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData, &parseError);
if (!jsonDoc.isObject()) {
isLoadingMore = false;
reply->deleteLater();
if (parseError.error != QJsonParseError::NoError || !jsonDoc.isObject()) {
showRequestError(tr("Invalid response from Archidekt."));
return;
}
QJsonObject jsonObj = jsonDoc.object();
QString responseUrl = reply->url().toString();
if (responseUrl.startsWith("https://archidekt.com/api/decks/v3/")) {
if (requestKind != RequestKind::Deck) {
if (!jsonObj.value("results").isArray() ||
(!jsonObj.value("next").isNull() && !jsonObj.value("next").isString())) {
showRequestError(tr("Invalid deck listing from Archidekt."));
return;
}
for (const auto &value : jsonObj.value("results").toArray()) {
if (!value.isObject() || value.toObject().value("id").toInt() <= 0) {
showRequestError(tr("Invalid deck listing from Archidekt."));
return;
}
}
currentPage = requestedPage;
const QString next = jsonObj.value("next").toString();
nextPageUrl = next.isEmpty() ? QUrl() : reply->url().resolved(QUrl(next));
// An empty or self-repeating page cannot make forward progress.
if (jsonObj.value("results").toArray().isEmpty() || nextPageUrl == reply->url()) {
nextPageUrl.clear();
}
requestStatusLabel->setText(
currentPage == 1 && jsonObj.value("results").toArray().isEmpty() ? tr("No decks found.") : QString());
requestStatusLabel->setVisible(!requestStatusLabel->text().isEmpty());
processTopDecksResponse(jsonObj);
} else if (responseUrl.startsWith("https://archidekt.com/api/decks/")) {
processDeckResponse(jsonObj);
} else {
prettyPrintJson(jsonObj, 4);
if (jsonObj.value("id").toInt() <= 0 || !jsonObj.value("cards").isArray()) {
showRequestError(tr("Invalid deck from Archidekt."));
return;
}
requestStatusLabel->hide();
processDeckResponse(jsonObj);
}
isLoadingMore = false;
reply->deleteLater();
}
void TabArchidekt::processTopDecksResponse(QJsonObject reply)
@ -600,8 +667,12 @@ void TabArchidekt::processTopDecksResponse(QJsonObject reply)
ArchidektDeckListingApiResponse deckData;
deckData.fromJson(reply);
// An accepted listing restores list mode; a failed deck navigation leaves the listing usable.
isListMode = true;
// New search → clear everything
if (currentPage == 1) {
if (requestKind == RequestKind::Search) {
listingsWidget = nullptr;
QtUtils::clearLayoutRec(resultsLayout, QtUtils::DeletePolicy::Immediate);
listingsWidget = new ArchidektApiResponseDeckListingsDisplayWidget(resultsContainer, deckData, cardSizeSlider);
@ -626,8 +697,10 @@ void TabArchidekt::processDeckResponse(QJsonObject reply)
// We're in single deck mode - disable infinite scroll
isListMode = false;
nextPageUrl.clear();
// Clear existing results for single deck view
listingsWidget = nullptr;
QtUtils::clearLayoutRec(resultsLayout, QtUtils::DeletePolicy::Immediate);
auto display = new ArchidektApiResponseDeckDisplayWidget(resultsContainer, deckData, cardSizeSlider);

View file

@ -8,8 +8,10 @@
#include <QJsonObject>
#include <QJsonValue>
#include <QList>
#include <QPointer>
#include <QSet>
#include <QString>
#include <QUrl>
#include <QVector>
#include <qtmetamacros.h>
@ -55,6 +57,7 @@ public:
* connects signals and slots, and triggers an initial fetch of top decks.
*/
explicit TabArchidekt(TabSupervisor *_tabSupervisor);
~TabArchidekt() override;
/**
* @brief Update all UI text to reflect the current language or translation
@ -190,6 +193,17 @@ protected:
bool eventFilter(QObject *obj, QEvent *event) override;
private:
enum class RequestKind
{
Search,
NextPage,
Deck
};
void startRequest(QUrl url, RequestKind kind, int page = 1);
void cancelRequest();
void showRequestError(const QString &error);
/**
* @brief Initialize the main UI layout and toolbars
*
@ -236,7 +250,14 @@ private:
int currentPage; ///< Current page number for infinite scroll
bool isLoadingMore; ///< Flag to prevent multiple simultaneous page loads
bool isListMode;
ArchidektApiResponseDeckListingsDisplayWidget *listingsWidget = nullptr;
QPointer<QNetworkReply> activeReply;
RequestKind requestKind = RequestKind::Search;
QUrl requestUrl;
QUrl nextPageUrl;
int requestedPage = 1;
QPointer<ArchidektApiResponseDeckListingsDisplayWidget> listingsWidget;
QLabel *requestStatusLabel;
QPushButton *retryButton;
// ---------------------------------------------------------------------
// Layout Containers