From 1a1d9381704bcf37aa55fa23c2c9711b91c535c1 Mon Sep 17 00:00:00 2001
From: Alessandro Portale
Date: Wed, 29 May 2024 16:00:22 +0200
Subject: [PATCH] ExtensionManager: Introduce extensions service response
parser and model
This adds a parser for the JSON response of the extension rest API. The
data, combined with the PluginSpecs of local plugins, serve as data
model for the extension mode view.
A couple of "packs" are provided as test data.
Change-Id: I5ce961a9de9bf54ca745e5e5a5e584b1698e6ac6
Reviewed-by: Cristian Adam
---
src/plugins/extensionmanager/CMakeLists.txt | 10 +
.../extensionmanager/extensionmanager.qbs | 10 +
.../extensionmanager_test.cpp | 40 ++
.../extensionmanager/extensionmanager_test.h | 14 +
.../extensionmanager_test.qrc | 6 +
.../extensionmanagerplugin.cpp | 8 +-
.../extensionmanagerwidget.cpp | 481 ++++++++++++------
.../extensionmanager/extensionmanagerwidget.h | 18 +-
.../extensionmanager/extensionsbrowser.cpp | 433 +++++-----------
.../extensionmanager/extensionsbrowser.h | 48 +-
.../extensionmanager/extensionsmodel.cpp | 411 +++++++++++++++
.../extensionmanager/extensionsmodel.h | 68 +++
.../testdata/defaultpacks.json | 161 ++++++
.../testdata/thirdpartyplugins.json | 38 ++
14 files changed, 1234 insertions(+), 512 deletions(-)
create mode 100644 src/plugins/extensionmanager/extensionmanager_test.cpp
create mode 100644 src/plugins/extensionmanager/extensionmanager_test.h
create mode 100644 src/plugins/extensionmanager/extensionmanager_test.qrc
create mode 100644 src/plugins/extensionmanager/extensionsmodel.cpp
create mode 100644 src/plugins/extensionmanager/extensionsmodel.h
create mode 100644 src/plugins/extensionmanager/testdata/defaultpacks.json
create mode 100644 src/plugins/extensionmanager/testdata/thirdpartyplugins.json
diff --git a/src/plugins/extensionmanager/CMakeLists.txt b/src/plugins/extensionmanager/CMakeLists.txt
index 1afb628ff70..094c1eb7d91 100644
--- a/src/plugins/extensionmanager/CMakeLists.txt
+++ b/src/plugins/extensionmanager/CMakeLists.txt
@@ -9,4 +9,14 @@ add_qtc_plugin(ExtensionManager
extensionmanagerwidget.h
extensionsbrowser.cpp
extensionsbrowser.h
+ extensionsmodel.cpp
+ extensionsmodel.h
+)
+
+extend_qtc_plugin(ExtensionManager
+ CONDITION WITH_TESTS
+ SOURCES
+ extensionmanager_test.cpp
+ extensionmanager_test.h
+ extensionmanager_test.qrc
)
diff --git a/src/plugins/extensionmanager/extensionmanager.qbs b/src/plugins/extensionmanager/extensionmanager.qbs
index 062bdfc6526..b4d1d1a4b60 100644
--- a/src/plugins/extensionmanager/extensionmanager.qbs
+++ b/src/plugins/extensionmanager/extensionmanager.qbs
@@ -14,5 +14,15 @@ QtcPlugin {
"extensionmanagerwidget.h",
"extensionsbrowser.cpp",
"extensionsbrowser.h",
+ "extensionsmodel.cpp",
+ "extensionsmodel.h",
]
+
+ QtcTestFiles {
+ files: [
+ "extensionmanager_test.h",
+ "extensionmanager_test.cpp",
+ "extensionmanager_test.qrc",
+ ]
+ }
}
diff --git a/src/plugins/extensionmanager/extensionmanager_test.cpp b/src/plugins/extensionmanager/extensionmanager_test.cpp
new file mode 100644
index 00000000000..7c66644ad77
--- /dev/null
+++ b/src/plugins/extensionmanager/extensionmanager_test.cpp
@@ -0,0 +1,40 @@
+// Copyright (C) 2024 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
+
+#include "extensionmanager_test.h"
+
+#include "extensionsmodel.h"
+
+#include
+
+#include
+
+namespace ExtensionManager::Internal {
+
+class ExtensionsModelTest final : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void testRepositoryJsonParser();
+};
+
+void ExtensionsModelTest::testRepositoryJsonParser()
+{
+ ExtensionsModel model;
+ model.setExtensionsJson(testData("defaultpacks"));
+}
+
+QObject *createExtensionsModelTest()
+{
+ return new ExtensionsModelTest;
+}
+
+QByteArray testData(const QString &id)
+{
+ return Utils::FileReader::fetchQrc(":/extensionmanager/testdata/" + id + ".json");
+}
+
+} // ExtensionManager::Internal
+
+#include "extensionmanager_test.moc"
diff --git a/src/plugins/extensionmanager/extensionmanager_test.h b/src/plugins/extensionmanager/extensionmanager_test.h
new file mode 100644
index 00000000000..e913688d0d4
--- /dev/null
+++ b/src/plugins/extensionmanager/extensionmanager_test.h
@@ -0,0 +1,14 @@
+// Copyright (C) 2024 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
+
+#pragma once
+
+#include
+
+namespace ExtensionManager::Internal {
+
+QObject *createExtensionsModelTest();
+
+QByteArray testData(const QString &id);
+
+} // namespace ExtensionManager::Internal
diff --git a/src/plugins/extensionmanager/extensionmanager_test.qrc b/src/plugins/extensionmanager/extensionmanager_test.qrc
new file mode 100644
index 00000000000..4c4d59f002d
--- /dev/null
+++ b/src/plugins/extensionmanager/extensionmanager_test.qrc
@@ -0,0 +1,6 @@
+
+
+ testdata/defaultpacks.json
+ testdata/thirdpartyplugins.json
+
+
diff --git a/src/plugins/extensionmanager/extensionmanagerplugin.cpp b/src/plugins/extensionmanager/extensionmanagerplugin.cpp
index 22dde816a9b..d94d5aa7ac5 100644
--- a/src/plugins/extensionmanager/extensionmanagerplugin.cpp
+++ b/src/plugins/extensionmanager/extensionmanagerplugin.cpp
@@ -5,6 +5,9 @@
#include "extensionmanagerconstants.h"
#include "extensionmanagerwidget.h"
+#ifdef WITH_TESTS
+#include "extensionmanager_test.h"
+#endif // WITH_TESTS
#include
#include
@@ -15,7 +18,6 @@
#include
#include
-#include
#include
#include
@@ -73,6 +75,10 @@ public:
void initialize() final
{
m_mode = new ExtensionManagerMode;
+
+#ifdef WITH_TESTS
+ addTestCreator(createExtensionsModelTest);
+#endif // WITH_TESTS
}
private:
diff --git a/src/plugins/extensionmanager/extensionmanagerwidget.cpp b/src/plugins/extensionmanager/extensionmanagerwidget.cpp
index 027378ede1c..78acb32e28e 100644
--- a/src/plugins/extensionmanager/extensionmanagerwidget.cpp
+++ b/src/plugins/extensionmanager/extensionmanagerwidget.cpp
@@ -3,26 +3,40 @@
#include "extensionmanagerwidget.h"
-#include "extensionmanagerconstants.h"
#include "extensionmanagertr.h"
#include "extensionsbrowser.h"
+#include "extensionsmodel.h"
#include
#include
#include
#include
+#include
#include
+#include
#include
+#include
+#include
+#include
+
#include
+#include
+#include
#include
+#include
#include
+#include
#include
+#include
#include
#include
+#include
+#include
#include
+#include
using namespace Core;
using namespace Utils;
@@ -54,80 +68,172 @@ private:
int m_width = 100;
};
-ExtensionManagerWidget::ExtensionManagerWidget()
+class PluginStatusWidget : public QWidget
{
- m_leftColumn = new ExtensionsBrowser;
+public:
+ explicit PluginStatusWidget(QWidget *parent = nullptr)
+ : QWidget(parent)
+ {
+ m_label = new InfoLabel;
+ m_checkBox = new QCheckBox(Tr::tr("Load on Start"));
+
+ using namespace Layouting;
+ Column {
+ m_label,
+ m_checkBox,
+ }.attachTo(this);
+
+ connect(m_checkBox, &QCheckBox::clicked, this, [this](bool checked) {
+ ExtensionSystem::PluginSpec *spec = ExtensionsModel::pluginSpecForName(m_pluginName);
+ if (spec == nullptr)
+ return;
+ spec->setEnabledBySettings(checked);
+ ExtensionSystem::PluginManager::writeSettings();
+ });
+
+ update();
+ }
+
+ void setPluginName(const QString &name)
+ {
+ m_pluginName = name;
+ update();
+ }
+
+private:
+ void update()
+ {
+ const ExtensionSystem::PluginSpec *spec = ExtensionsModel::pluginSpecForName(m_pluginName);
+ setVisible(spec != nullptr);
+ if (spec == nullptr)
+ return;
+
+ if (spec->hasError()) {
+ m_label->setType(InfoLabel::Error);
+ m_label->setText(Tr::tr("Error"));
+ } else if (spec->state() == ExtensionSystem::PluginSpec::Running) {
+ m_label->setType(InfoLabel::Ok);
+ m_label->setText(Tr::tr("Loaded"));
+ } else {
+ m_label->setType(InfoLabel::NotOk);
+ m_label->setText(Tr::tr("Not loaded"));
+ }
+
+ m_checkBox->setChecked(spec->isRequired() || spec->isEnabledBySettings());
+ m_checkBox->setEnabled(!spec->isRequired());
+ }
+
+ InfoLabel *m_label;
+ QCheckBox *m_checkBox;
+ QString m_pluginName;
+};
+
+class ExtensionManagerWidgetPrivate
+{
+public:
+ QString currentItemName;
+ ExtensionsBrowser *leftColumn;
+ CollapsingWidget *secondaryDescriptionWidget;
+ QTextBrowser *primaryDescription;
+ QTextBrowser *secondaryDescription;
+ PluginStatusWidget *pluginStatus;
+ QAbstractButton *installButton;
+ PluginsData currentItemPlugins;
+ Tasking::TaskTreeRunner taskTreeRunner;
+};
+
+ExtensionManagerWidget::ExtensionManagerWidget(QWidget *parent)
+ : ResizeSignallingWidget(parent)
+ , d(new ExtensionManagerWidgetPrivate)
+{
+ d->leftColumn = new ExtensionsBrowser;
auto descriptionColumns = new QWidget;
- m_secondarDescriptionWidget = new CollapsingWidget;
+ d->secondaryDescriptionWidget = new CollapsingWidget;
- m_primaryDescription = new QTextBrowser;
- m_primaryDescription->setOpenExternalLinks(true);
- m_primaryDescription->setFrameStyle(QFrame::NoFrame);
+ d->primaryDescription = new QTextBrowser;
+ d->primaryDescription->setOpenExternalLinks(true);
+ d->primaryDescription->setFrameStyle(QFrame::NoFrame);
- m_secondaryDescription = new QTextBrowser;
- m_secondaryDescription->setFrameStyle(QFrame::NoFrame);
+ d->secondaryDescription = new QTextBrowser;
+ d->secondaryDescription->setFrameStyle(QFrame::NoFrame);
+
+ d->pluginStatus = new PluginStatusWidget;
+
+ d->installButton = new Button(Tr::tr("Install..."), Button::MediumPrimary);
+ d->installButton->hide();
using namespace Layouting;
Row {
WelcomePageHelpers::createRule(Qt::Vertical),
- m_secondaryDescription,
+ Column {
+ d->secondaryDescription,
+ d->pluginStatus,
+ d->installButton,
+ },
noMargin, spacing(0),
- }.attachTo(m_secondarDescriptionWidget);
+ }.attachTo(d->secondaryDescriptionWidget);
Row {
WelcomePageHelpers::createRule(Qt::Vertical),
Row {
- m_primaryDescription,
+ d->primaryDescription,
noMargin,
},
- m_secondarDescriptionWidget,
+ d->secondaryDescriptionWidget,
noMargin, spacing(0),
}.attachTo(descriptionColumns);
Row {
Space(StyleHelper::SpacingTokens::ExVPaddingGapXl),
- m_leftColumn,
+ d->leftColumn,
descriptionColumns,
noMargin, spacing(0),
}.attachTo(this);
WelcomePageHelpers::setBackgroundColor(this, Theme::Token_Background_Default);
- connect(m_leftColumn, &ExtensionsBrowser::itemSelected,
+ connect(d->leftColumn, &ExtensionsBrowser::itemSelected,
this, &ExtensionManagerWidget::updateView);
connect(this, &ResizeSignallingWidget::resized, this, [this](const QSize &size) {
const int intendedLeftColumnWidth = size.width() - 580;
- m_leftColumn->adjustToWidth(intendedLeftColumnWidth);
+ d->leftColumn->adjustToWidth(intendedLeftColumnWidth);
const bool secondaryDescriptionVisible = size.width() > 970;
const int secondaryDescriptionWidth = secondaryDescriptionVisible ? 264 : 0;
- m_secondarDescriptionWidget->setWidth(secondaryDescriptionWidth);
+ d->secondaryDescriptionWidget->setWidth(secondaryDescriptionWidth);
});
- updateView({}, {});
+ connect(d->installButton, &QAbstractButton::pressed, this, [this]() {
+ fetchAndInstallPlugin(QUrl::fromUserInput(d->currentItemPlugins.constFirst().second));
+ });
+ updateView({});
}
-void ExtensionManagerWidget::updateView(const QModelIndex ¤t,
- [[maybe_unused]] const QModelIndex &previous)
+ExtensionManagerWidget::~ExtensionManagerWidget()
+{
+ delete d;
+}
+
+void ExtensionManagerWidget::updateView(const QModelIndex ¤t)
{
const QString h5Css =
StyleHelper::fontToCssProperties(StyleHelper::uiFont(StyleHelper::UiElementH5))
- + "; margin-top: 28px;";
+ + "; margin-top: 0px;";
const QString h6Css =
StyleHelper::fontToCssProperties(StyleHelper::uiFont(StyleHelper::UiElementH6))
+ "; margin-top: 28px;";
const QString h6CapitalCss =
StyleHelper::fontToCssProperties(StyleHelper::uiFont(StyleHelper::UiElementH6Capital))
- + QString::fromLatin1("; color: %1;")
+ + QString::fromLatin1("; margin-top: 0px; color: %1;")
.arg(creatorColor(Theme::Token_Text_Muted).name());
- const QString bodyStyle = QString::fromLatin1("color: %1; background-color: %2;"
+ const QString bodyStyle = QString::fromLatin1("color: %1; background-color: %2; "
"margin-left: %3px; margin-right: %3px;")
.arg(creatorColor(Theme::Token_Text_Default).name())
.arg(creatorColor(Theme::Token_Background_Muted).name())
.arg(StyleHelper::SpacingTokens::ExVPaddingGapXl);
const QString htmlStart = QString(R"(
-
+
)").arg(bodyStyle);
const QString htmlEnd = QString(R"(
@@ -135,161 +241,236 @@ void ExtensionManagerWidget::updateView(const QModelIndex ¤t,
if (!current.isValid()) {
const QString emptyHtml = htmlStart + htmlEnd;
- m_primaryDescription->setText(emptyHtml);
- m_secondaryDescription->setText(emptyHtml);
+ d->primaryDescription->setText(emptyHtml);
+ d->secondaryDescription->setText(emptyHtml);
return;
}
- const ItemData data = itemData(current);
- const bool isPack = data.type == ItemTypePack;
- const ExtensionSystem::PluginSpec *extension = data.plugins.first();
+ d->currentItemName = current.data().toString();
+ const bool isPack = current.data(RoleItemType) == ItemTypePack;
+ d->pluginStatus->setPluginName(isPack ? QString() : d->currentItemName);
+ const bool isRemotePlugin = !(isPack || ExtensionsModel::pluginSpecForName(d->currentItemName));
+ d->currentItemPlugins = current.data(RolePlugins).value();
+ d->installButton->setVisible(isRemotePlugin && !d->currentItemPlugins.empty());
+ if (!d->currentItemPlugins.empty())
+ d->installButton->setToolTip(d->currentItemPlugins.constFirst().second);
{
- const QString shortDescription =
- isPack ? QLatin1String("Short description for pack ") + data.name
- : extension->description();
- QString longDescription =
- isPack ? QLatin1String("Some longer text that describes the purpose and functionality "
- "of the extensions that are part of pack ") + data.name
- : extension->longDescription();
- longDescription.replace("\n", "
");
- const FilePath location = isPack ? extension->location() : extension->filePath();
-
QString description = htmlStart;
- description.append(QString(R"(
-
%2
- %3
- )").arg(h5Css)
- .arg(shortDescription)
- .arg(longDescription));
+ QString descriptionHtml;
+ {
+ const TextData textData = current.data(RoleDescriptionText).value();
+ for (const TextData::Type &text : textData) {
+ if (text.second.isEmpty())
+ continue;
+ const QString paragraph =
+ QString::fromLatin1("%2
%3
")
+ .arg(descriptionHtml.isEmpty() ? h5Css : h6Css)
+ .arg(text.first)
+ .arg(text.second.join("
"));
+ descriptionHtml.append(paragraph);
+ }
+ }
+ description.append(descriptionHtml);
- description.append(QString(R"(
- %2
- %3
- )").arg(h6Css)
- .arg(Tr::tr("Get started"))
- .arg(Tr::tr("Install the extension from above. Installation starts automatically. "
- "You can always uninstall the extension afterwards.")));
+ description.append(QString::fromLatin1("%2
")
+ .arg(h6Css)
+ .arg(Tr::tr("More information")));
+ const LinksData linksData = current.data(RoleDescriptionLinks).value();
+ if (!linksData.isEmpty()) {
+ QString linksHtml;
+ const QStringList links = Utils::transform(linksData, [](const LinksData::Type &link) {
+ const QString anchor = link.first.isEmpty() ? link.second : link.first;
+ return QString::fromLatin1("%2 >")
+ .arg(link.second).arg(anchor);
+ });
+ linksHtml = links.join("
");
+ description.append(QString::fromLatin1("%1
").arg(linksHtml));
+ }
- description.append(QString(R"(
- %2
-
- %4 >
-
- %6 >
-
- )").arg(h6Css)
- .arg(Tr::tr("More information"))
- .arg(Tr::tr("Online Documentation"))
- .arg("https://doc.qt.io/qtcreator/")
- .arg(Tr::tr("Tutorials"))
- .arg("https://doc.qt.io/qtcreator/creator-tutorials.html"));
+ const ImagesData imagesData = current.data(RoleDescriptionImages).value();
+ if (!imagesData.isEmpty()) {
+ const QString examplesBoxCss =
+ QString::fromLatin1("height: 168px; background-color: %1; ")
+ .arg(creatorTheme()->color(Theme::Token_Background_Default).name());
+ description.append(QString(R"(
+
+ %2
+
+
+ TODO: Load imagea asynchronously, and show them in a QLabel.
+ Also Use QMovie for animated images.
+
+
+ )").arg(h6CapitalCss)
+ .arg(Tr::tr("Examples"))
+ .arg(examplesBoxCss));
+ }
- const QString examplesBoxCss =
- QString::fromLatin1("height: 168px; background-color: %1; ")
- .arg(creatorColor(Theme::Token_Background_Default).name());
- description.append(QString(R"(
- %2
-
-
-
- )").arg(h6CapitalCss)
- .arg(Tr::tr("Examples"))
- .arg(examplesBoxCss));
-
- const QString captionStrongCss = StyleHelper::fontToCssProperties(
- StyleHelper::uiFont(StyleHelper::UiElementCaptionStrong));
- description.append(QString(R"(
- %2
-
-
-
- )").arg(h6Css)
- .arg(Tr::tr("Extension library details"))
- .arg(captionStrongCss)
- .arg(Tr::tr("Size"))
- .arg("547 MB")
- .arg(Tr::tr("Version"))
- .arg(extension->version())
- .arg(Tr::tr("Location"))
- .arg(location.toUserOutput()));
+ // Library details vanished from the Figma designs. The data is available, though.
+ const bool showDetails = false;
+ if (showDetails) {
+ const QString captionStrongCss = StyleHelper::fontToCssProperties(
+ StyleHelper::uiFont(StyleHelper::UiElementCaptionStrong));
+ const QLocale locale;
+ const uint size = current.data(RoleSize).toUInt();
+ const QString sizeFmt = locale.formattedDataSize(size);
+ const FilePath location = FilePath::fromVariant(current.data(RoleLocation));
+ const QString version = current.data(RoleVersion).toString();
+ description.append(QString(R"(
+ %2
+
+
+ %4 | %5 |
+ %6 | %7 |
+ )").arg(h6Css)
+ .arg(Tr::tr("Extension library details"))
+ .arg(captionStrongCss)
+ .arg(Tr::tr("Size"))
+ .arg(sizeFmt)
+ .arg(Tr::tr("Version"))
+ .arg(version));
+ if (!location.isEmpty()) {
+ const QString locationFmt =
+ HostOsInfo::isWindowsHost() ? location.toUserOutput()
+ : location.withTildeHomePath();
+ description.append(QString(R"(
+ %1 | %2 |
+ )").arg(Tr::tr("Location"))
+ .arg(locationFmt));
+ }
+ description.append(QString(R"(
+
+
+ )"));
+ }
description.append(htmlEnd);
- m_primaryDescription->setText(description);
+ d->primaryDescription->setText(description);
}
{
QString description = htmlStart;
description.append(QString(R"(
-
%2
+ %2
)").arg(h6CapitalCss)
.arg(Tr::tr("Extension details")));
- description.append(QString(R"(
- %2
- %3
- )").arg(h6Css)
- .arg(Tr::tr("Released"))
- .arg("23.5.2023"));
-
- const QString tagTemplate = QString(R"(
- %2 |
- )").arg(creatorColor(Theme::Token_Stroke_Subtle).name());
- const QStringList tags = Utils::transform(data.tags,
- [&tagTemplate] (const QString &tag) {
- return tagTemplate.arg(tag);
- });
- description.append(QString(R"(
- %2
- %3
- )").arg(h6Css)
- .arg(Tr::tr("Related tags"))
- .arg(tags.join(" ")));
-
- description.append(QString(R"(
- %2
-
- macOS
- Windows
- Linux
-
- )").arg(h6Css)
- .arg(Tr::tr("Platforms")));
-
- QStringList dependencies;
- for (const ExtensionSystem::PluginSpec *spec : data.plugins) {
- dependencies.append(Utils::transform(spec->dependencies(),
- &ExtensionSystem::PluginDependency::toString));
- }
- dependencies.removeDuplicates();
- dependencies.sort();
- description.append(QString(R"(
- %2
- %3
- )").arg(h6Css)
- .arg(Tr::tr("Dependencies"))
- .arg(dependencies.isEmpty() ? "-" : dependencies.join("
")));
-
- if (isPack) {
- const QStringList extensions = Utils::transform(data.plugins,
- &ExtensionSystem::PluginSpec::name);
+ const QStringList tags = current.data(RoleTags).toStringList();
+ if (!tags.isEmpty()) {
+ const QString tagTemplate = QString(R"(
+ %2 |
+ )").arg(creatorTheme()->color(Theme::Token_Stroke_Subtle).name());
+ const QStringList tagsFmt = Utils::transform(tags, [&tagTemplate](const QString &tag) {
+ return tagTemplate.arg(tag);
+ });
description.append(QString(R"(
%2
%3
)").arg(h6Css)
- .arg(Tr::tr("Extensions in pack"))
- .arg(extensions.join("
")));
+ .arg(Tr::tr("Related tags"))
+ .arg(tagsFmt.join(" ")));
+ }
+
+ const QStringList platforms = current.data(RolePlatforms).toStringList();
+ if (!platforms.isEmpty()) {
+ description.append(QString(R"(
+ %2
+ %3
+ )").arg(h6Css)
+ .arg(Tr::tr("Platforms"))
+ .arg(platforms.join("
")));
+ }
+
+ const QStringList dependencies = current.data(RoleDependencies).toStringList();
+ if (!dependencies.isEmpty()) {
+ const QString dependenciesFmt = dependencies.join("
");
+ description.append(QString(R"(
+ %2
+ %3
+ )").arg(h6Css)
+ .arg(Tr::tr("Dependencies"))
+ .arg(dependenciesFmt));
+ }
+
+ if (isPack) {
+ const PluginsData plugins = current.data(RolePlugins).value();
+ const QStringList extensions = Utils::transform(plugins,
+ &QPair::first);
+ const QString extensionsFmt = extensions.join("
");
+ description.append(QString(R"(
+ %2
+ %3
+ )").arg(h6Css)
+ .arg(Tr::tr("Extensions in pack"))
+ .arg(extensionsFmt));
}
description.append(htmlEnd);
- m_secondaryDescription->setText(description);
+ d->secondaryDescription->setText(description);
}
}
+void ExtensionManagerWidget::fetchAndInstallPlugin(const QUrl &url)
+{
+ using namespace Tasking;
+
+ struct StorageStruct
+ {
+ StorageStruct() {
+ progressDialog.reset(new QProgressDialog(Tr::tr("Downloading Plugin..."),
+ Tr::tr("Cancel"), 0, 0,
+ Core::ICore::dialogParent()));
+ progressDialog->setWindowModality(Qt::ApplicationModal);
+ progressDialog->setFixedSize(progressDialog->sizeHint());
+ progressDialog->setAutoClose(false);
+ progressDialog->show(); // TODO: Should not be needed. Investigate possible QT_BUG
+ }
+ std::unique_ptr progressDialog;
+ QByteArray packageData;
+ QUrl url;
+ };
+ Storage storage;
+
+ const auto onQuerySetup = [url, storage](NetworkQuery &query) {
+ storage->url = url;
+ query.setRequest(QNetworkRequest(url));
+ query.setNetworkAccessManager(NetworkAccessManager::instance());
+ };
+ const auto onQueryDone = [storage](const NetworkQuery &query, DoneWith result) {
+ storage->progressDialog->close();
+ if (result == DoneWith::Success) {
+ storage->packageData = query.reply()->readAll();
+ } else {
+ QMessageBox::warning(
+ ICore::dialogParent(),
+ Tr::tr("Download Error"),
+ Tr::tr("Could not download Plugin") + "\n\n" + storage->url.toString() + "\n\n"
+ + Tr::tr("Code: %1.").arg(query.reply()->error()));
+ }
+ };
+
+ const auto onPluginInstallation = [storage]() {
+ if (storage->packageData.isEmpty())
+ return;
+ const FilePath source = FilePath::fromUrl(storage->url);
+ TempFileSaver saver(TemporaryDirectory::masterDirectoryPath()
+ + "/XXXXXX" + source.fileName());
+
+ saver.write(storage->packageData);
+ if (saver.finalize(ICore::dialogParent()))
+ executePluginInstallWizard(saver.filePath());;
+ };
+
+ Group group{
+ storage,
+ NetworkQueryTask{onQuerySetup, onQueryDone},
+ onGroupDone(onPluginInstallation),
+ };
+
+ d->taskTreeRunner.start(group);
+}
+
} // ExtensionManager::Internal
diff --git a/src/plugins/extensionmanager/extensionmanagerwidget.h b/src/plugins/extensionmanager/extensionmanagerwidget.h
index efa2925e25f..aeaad3db07c 100644
--- a/src/plugins/extensionmanager/extensionmanagerwidget.h
+++ b/src/plugins/extensionmanager/extensionmanagerwidget.h
@@ -3,27 +3,19 @@
#include
-QT_BEGIN_NAMESPACE
-class QTextBrowser;
-QT_END_NAMESPACE
-
namespace ExtensionManager::Internal {
-class CollapsingWidget;
-class ExtensionsBrowser;
-
class ExtensionManagerWidget final : public Core::ResizeSignallingWidget
{
public:
- explicit ExtensionManagerWidget();
+ explicit ExtensionManagerWidget(QWidget *parent = nullptr);
+ ~ExtensionManagerWidget();
private:
- void updateView(const QModelIndex ¤t, [[maybe_unused]] const QModelIndex &previous);
+ void updateView(const QModelIndex ¤t);
+ void fetchAndInstallPlugin(const QUrl &url);
- ExtensionsBrowser *m_leftColumn;
- CollapsingWidget *m_secondarDescriptionWidget;
- QTextBrowser *m_primaryDescription;
- QTextBrowser *m_secondaryDescription;
+ class ExtensionManagerWidgetPrivate *d = nullptr;
};
} // ExtensionManager::Internal
diff --git a/src/plugins/extensionmanager/extensionsbrowser.cpp b/src/plugins/extensionmanager/extensionsbrowser.cpp
index 1a41b684949..81264c59bd7 100644
--- a/src/plugins/extensionmanager/extensionsbrowser.cpp
+++ b/src/plugins/extensionmanager/extensionsbrowser.cpp
@@ -4,10 +4,17 @@
#include "extensionsbrowser.h"
#include "extensionmanagertr.h"
+#include "extensionsmodel.h"
+#include "utils/hostosinfo.h"
+
+#ifdef WITH_TESTS
+#include "extensionmanager_test.h"
+#endif // WITH_TESTS
#include
#include
#include
+#include
#include
#include
@@ -15,9 +22,14 @@
#include
#include
+#include
+#include
+#include
+
#include
#include
#include
+#include
#include
#include
@@ -26,7 +38,6 @@
#include
#include
#include
-#include
#include
using namespace ExtensionSystem;
@@ -37,277 +48,16 @@ namespace ExtensionManager::Internal {
Q_LOGGING_CATEGORY(browserLog, "qtc.extensionmanager.browser", QtWarningMsg)
-using Tags = QStringList;
-
constexpr QSize itemSize = {330, 86};
constexpr int gapSize = StyleHelper::SpacingTokens::ExVPaddingGapXl;
constexpr QSize cellSize = {itemSize.width() + gapSize, itemSize.height() + gapSize};
-enum Role {
- RoleName = Qt::UserRole,
- RoleItemType,
- RoleTags,
- RolePluginSpecs,
- RoleSearchText,
-};
-
-ItemData itemData(const QModelIndex &index)
-{
- return {
- index.data(RoleName).toString(),
- index.data(RoleItemType).value(),
- index.data(RoleTags).toStringList(),
- index.data(RolePluginSpecs).value(),
- };
-}
-
static QColor colorForExtensionName(const QString &name)
{
const size_t hash = qHash(name);
return QColor::fromHsv(hash % 360, 180, 110);
}
-static QStandardItemModel *extensionsModel()
-{
- // The new extensions concept renames plugins to extensions and adds "packs" which are
- // groups of extensions.
- //
- // TODO: The "meta data" here which is injected into the model is only a place holder that
- // helps exploring the upcoming extensions concept.
- //
- // Before this loses the WIP prefix, we should at least have a concrete idea of how the data
- // is structured and where it lives. Ideally, it continues to reside exclusively in the
- // extension meta data.
- //
- // The grouping of extensions into packs could be done via extension tag. Extensions and will
- // receive tags and if possible screen shots.
- // Packs will also have a complete set of meta data. That could be an accumulation of the data
- // of the contained extensions. Or simply the data from the "first" extension in a pack.
-
- static const char tagBuildTools[] = "Build Tools";
- static const char tagCodeAnalyzing[] = "Code Analyzing";
- static const char tagConnectivity[] = "Connectivity";
- static const char tagCore[] = "Core";
- static const char tagCpp[] = "C++";
- static const char tagEditorConvenience[] = "Editor Convenience";
- static const char tagEditor[] = "Editor";
- static const char tagEssentials[] = "Essentials";
- static const char tagGlsl[] = "GLSL";
- static const char tagPackageManager[] = "Package Manager";
- static const char tagPlatformSupport[] = "Platform Support";
- static const char tagProgrammingLanguage[] = "Programming Language";
- static const char tagPython[] = "Python";
- static const char tagQml[] = "QML";
- static const char tagQuick[] = "Quick";
- static const char tagService[] = "Service";
- static const char tagTestAutomation[] = "Test Automation";
- static const char tagUiEditor[] = "Visual UI Editor" ;
- static const char tagVersionControl[] = "Version Control";
- static const char tagVisualEditor[] = "Visual editor";
- static const char tagWidgets[] = "Widgets";
-
- static const char tagTagUndefined[] = "Tag undefined";
-
- static const struct {
- const QString name;
- const QStringList extensions;
- const Tags tags;
- } packs[] = {
- {"Core",
- {"Core", "Help", "ProjectExplorer", "TextEditor", "Welcome", "GenericProjectManager",
- "QtSupport"},
- {tagCore}
- },
- {"Core (from Installer)",
- {"LicenseChecker", "Marketplace", "UpdateInfo"},
- {tagCore}
- },
- {"Essentials",
- {"Bookmarks", "BinEditor", "Debugger", "DiffEditor", "ImageViewer", "Macros",
- "LanguageClient", "ResourceEditor"},
- {tagEssentials}
- },
- {"C++ Language support",
- {"ClangCodeModel", "ClangFormat", "ClassView", "CppEditor"},
- {tagProgrammingLanguage, tagCpp}
- },
- {"QML Language Support (Qt Quick libraries)",
- {"QmlJSEditor", "QmlJSTools", "QmlPreview", "QmlProfiler", "QmlProjectManager"},
- {tagProgrammingLanguage, tagQml}
- },
- {"Visual QML UI Editor",
- {"QmlDesigner", "QmlDesignerBase"},
- {tagUiEditor, tagQml, tagQuick}
- },
- {"Visual C++ Widgets UI Editor",
- {"Designer"},
- {tagUiEditor, tagCpp, tagWidgets}
- },
- };
-
- static const struct {
- const QString name;
- const Tags tags;
- } extensions[] = {
- {"GLSLEditor", {tagProgrammingLanguage, tagGlsl}},
- {"Nim", {tagProgrammingLanguage}},
- {"Python", {tagProgrammingLanguage, tagPython}},
- {"Haskell", {tagProgrammingLanguage}},
-
- {"ModelEditor", {tagVisualEditor}},
- {"ScxmlEditor", {tagVisualEditor}},
-
- {"Bazaar", {tagVersionControl}},
- {"CVS", {tagVersionControl}},
- {"ClearCase", {tagVersionControl}},
- {"Fossil", {tagVersionControl}},
- {"Git", {tagVersionControl}},
- {"Mercurial", {tagVersionControl}},
- {"Perforce", {tagVersionControl}},
- {"Subversion", {tagVersionControl}},
- {"VcsBase", {tagVersionControl}},
- {"GitLab", {tagVersionControl, tagService}},
-
- {"AutoTest", {tagTestAutomation}},
- {"Squish", {tagTestAutomation}},
- {"Coco", {tagTestAutomation}},
-
- {"Vcpkg", {tagPackageManager}},
- {"Conan", {tagPackageManager}},
-
- {"Copilot", {tagEditorConvenience}},
- {"EmacsKeys", {tagEditorConvenience}},
- {"FakeVim", {tagEditorConvenience}},
- {"Terminal", {tagEditorConvenience}},
- {"Todo", {tagEditorConvenience}},
- {"CodePaster", {tagEditorConvenience}},
- {"Beautifier", {tagEditorConvenience}},
-
- {"SerialTerminal", {tagConnectivity}},
-
- {"SilverSearcher", {tagEditor}},
-
- {"AutotoolsProjectManager", {tagBuildTools}},
- {"CMakeProjectManager", {tagBuildTools}},
- {"CompilationDatabaseProjectManager", {tagBuildTools}},
- {"IncrediBuild", {tagBuildTools}},
- {"MesonProjectManager", {tagBuildTools}},
- {"QbsProjectManager", {tagBuildTools}},
- {"QmakeProjectManager", {tagBuildTools}},
-
- {"Axivion", {tagCodeAnalyzing}},
- {"ClangTools", {tagCodeAnalyzing}},
- {"Cppcheck", {tagCodeAnalyzing}},
- {"CtfVisualizer", {tagCodeAnalyzing}},
- {"PerfProfiler", {tagCodeAnalyzing}},
- {"Valgrind", {tagCodeAnalyzing}},
-
- {"Android", {tagPlatformSupport}},
- {"BareMetal", {tagPlatformSupport}},
- {"Boot2Qt", {tagPlatformSupport}},
- {"Ios", {tagPlatformSupport}},
- {"McuSupport", {tagPlatformSupport}},
- {"Qnx", {tagPlatformSupport}},
- {"RemoteLinux", {tagPlatformSupport}},
- {"SafeRenderer", {tagPlatformSupport}},
- {"VxWorks", {tagPlatformSupport}},
- {"WebAssembly", {tagPlatformSupport}},
- {"Docker", {tagPlatformSupport}},
-
- // Missing in Kimmo's excel sheet:
- {"CompilerExplorer", {tagTagUndefined}},
- {"ExtensionManager", {tagTagUndefined}},
- {"ScreenRecorder", {tagTagUndefined}},
- };
-
- QList items;
- QStringList expectedExtensions;
- QStringList unexpectedExtensions;
- QHash installedPlugins;
- for (const PluginSpec *ps : PluginManager::plugins()) {
- installedPlugins.insert(ps->name(), ps);
- unexpectedExtensions.append(ps->name());
- }
-
- const auto handleExtension = [&] (const ItemData &extension, bool addToBrowser) {
- if (!installedPlugins.contains(extension.name)) {
- expectedExtensions.append(extension.name);
- return false;
- }
- unexpectedExtensions.removeOne(extension.name);
-
- if (addToBrowser) {
- QStandardItem *item = new QStandardItem;
- const PluginSpecList pluginSpecs = {installedPlugins.value(extension.name)};
- item->setData(ItemTypeExtension, RoleItemType);
- item->setData(QVariant::fromValue(extension.tags), RoleTags);
- item->setData(QVariant::fromValue(pluginSpecs), RolePluginSpecs);
- item->setData(extension.name, RoleName);
- items.append(item);
- }
-
- return true;
- };
-
- const bool addPackedExtensionsToBrowser = true; // TODO: Determine how we want this. As setting?
- for (const auto &pack : packs) {
- PluginSpecList pluginSpecs;
- for (const QString &extension : pack.extensions) {
- const ItemData extensionData = {extension, {}, pack.tags, {}};
- if (!handleExtension(extensionData, addPackedExtensionsToBrowser))
- continue;
- pluginSpecs.append(installedPlugins.value(extension));
- }
- if (pluginSpecs.isEmpty())
- continue;
-
- QStandardItem *item = new QStandardItem;
- item->setData(ItemTypePack, RoleItemType);
- item->setData(QVariant::fromValue(pack.tags), RoleTags);
- item->setData(QVariant::fromValue(pluginSpecs), RolePluginSpecs);
- item->setData(pack.name, RoleName);
- items.append(item);
- }
-
- for (const auto &extension : extensions) {
- const ItemData extensionData = {extension.name, {}, extension.tags, {}};
- handleExtension(extensionData, true);
- }
-
- QStandardItemModel *result = new QStandardItemModel;
- for (auto item : items) {
- QStringList searchTexts;
- searchTexts.append(item->data(RoleName).toString());
- searchTexts.append(item->data(RoleTags).toStringList());
- const PluginSpecList pluginSpecs = item->data(RolePluginSpecs).value();
- for (auto pluginSpec : pluginSpecs) {
- searchTexts.append(pluginSpec->name());
- searchTexts.append(pluginSpec->description());
- searchTexts.append(pluginSpec->longDescription());
- searchTexts.append(pluginSpec->category());
- searchTexts.append(pluginSpec->copyright());
- }
- searchTexts.removeDuplicates();
- item->setData(searchTexts.join(" "), RoleSearchText);
-
- item->setDragEnabled(false);
- item->setEditable(false);
-
- result->appendRow(item);
- }
-
- if (browserLog().isDebugEnabled()) {
- if (!expectedExtensions.isEmpty())
- qCDebug(browserLog) << "Expected extensions/plugins are not installed:"
- << expectedExtensions.join(", ");
- if (!unexpectedExtensions.isEmpty())
- qCDebug(browserLog) << "Unexpected extensions/plugins are installed:"
- << unexpectedExtensions.join(", ");
- }
-
- return result;
-}
-
class ExtensionItemDelegate : public QItemDelegate
{
public:
@@ -322,8 +72,8 @@ public:
painter->save();
painter->setRenderHint(QPainter::Antialiasing);
- const ItemData data = itemData(index);
- const bool isPack = data.type == ItemTypePack;
+ const QString itemName = index.data().toString();
+ const bool isPack = index.data(RoleItemType) == ItemTypePack;
const QRectF itemRect(option.rect.topLeft(), itemSize);
{
const bool selected = option.state & QStyle::State_Selected;
@@ -345,7 +95,7 @@ public:
bigCirclePath.addEllipse(bigCircleLocal);
QLinearGradient gradient(bigCircleLocal.topLeft(), bigCircleLocal.bottomRight());
const QColor startColor = isPack ? qRgb(0x1e, 0x99, 0x6e)
- : colorForExtensionName(data.name);
+ : colorForExtensionName(itemName);
const QColor endColor = isPack ? qRgb(0x07, 0x6b, 0x6d) : startColor.lighter(150);
gradient.setColorAt(gradientMargin, startColor);
gradient.setColorAt(1 - gradientMargin, endColor);
@@ -377,8 +127,11 @@ public:
painter->setFont(StyleHelper::uiFont(StyleHelper::UiElementCaptionStrong));
const QColor textColor = creatorColor(Theme::Token_Text_Default);
painter->setPen(textColor);
- painter->drawText(smallCircleLocal, QString::number(data.plugins.count()),
- QTextOption(Qt::AlignCenter));
+ const PluginsData plugins = index.data(RolePlugins).value();
+ painter->drawText(
+ smallCircleLocal,
+ QString::number(plugins.count()),
+ QTextOption(Qt::AlignCenter));
}
{
constexpr int textX = 80;
@@ -390,21 +143,22 @@ public:
const QPointF titleOrigin(itemRect.topLeft() + QPointF(textX, titleY));
painter->setPen(creatorColor(Theme::Token_Text_Default));
painter->setFont(StyleHelper::uiFont(StyleHelper::UiElementH6));
- const QString titleElided = painter->fontMetrics().elidedText(
- data.name, elideMode, maxTextWidth);
+ const QString titleElided
+ = painter->fontMetrics().elidedText(itemName, elideMode, maxTextWidth);
painter->drawText(titleOrigin, titleElided);
constexpr int copyrightY = 52;
const QPointF copyrightOrigin(itemRect.topLeft() + QPointF(textX, copyrightY));
painter->setPen(creatorColor(Theme::Token_Text_Muted));
painter->setFont(StyleHelper::uiFont(StyleHelper::UiElementCaptionStrong));
- const QString copyrightElided = painter->fontMetrics().elidedText(
- data.plugins.first()->copyright(), elideMode, maxTextWidth);
+ const QString copyright = index.data(RoleCopyright).toString();
+ const QString copyrightElided
+ = painter->fontMetrics().elidedText(copyright, elideMode, maxTextWidth);
painter->drawText(copyrightOrigin, copyrightElided);
constexpr int tagsY = 70;
const QPointF tagsOrigin(itemRect.topLeft() + QPointF(textX, tagsY));
- const QString tags = data.tags.join(", ");
+ const QString tags = index.data(RoleTags).toStringList().join(", ");
painter->setPen(creatorColor(Theme::Token_Text_Default));
painter->setFont(StyleHelper::uiFont(StyleHelper::UiElementCaption));
const QString tagsElided = painter->fontMetrics().elidedText(
@@ -422,87 +176,154 @@ public:
}
};
-ExtensionsBrowser::ExtensionsBrowser()
+class ExtensionsBrowserPrivate
+{
+public:
+ ExtensionsModel *model;
+ QLineEdit *searchBox;
+ QAbstractButton *updateButton;
+ QListView *extensionsView;
+ QItemSelectionModel *selectionModel = nullptr;
+ QSortFilterProxyModel *filterProxyModel;
+ int columnsCount = 2;
+ Tasking::TaskTreeRunner taskTreeRunner;
+};
+
+ExtensionsBrowser::ExtensionsBrowser(QWidget *parent)
+ : QWidget(parent)
+ , d(new ExtensionsBrowserPrivate)
{
setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Preferred);
auto manageLabel = new QLabel(Tr::tr("Manage Extensions"));
manageLabel->setFont(StyleHelper::uiFont(StyleHelper::UiElementH1));
- m_searchBox = new Core::SearchBox;
- m_searchBox->setFixedWidth(itemSize.width());
+ d->searchBox = new Core::SearchBox;
+ d->searchBox->setFixedWidth(itemSize.width());
- m_updateButton = new Button(Tr::tr("Install..."), Button::MediumPrimary);
+ d->updateButton = new Button(Tr::tr("Install..."), Button::MediumPrimary);
- m_filterProxyModel = new QSortFilterProxyModel(this);
- m_filterProxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
- m_filterProxyModel->setFilterRole(RoleSearchText);
- m_filterProxyModel->setSortRole(RoleItemType);
+ d->model = new ExtensionsModel(this);
- m_extensionsView = new QListView;
- m_extensionsView->setFrameStyle(QFrame::NoFrame);
- m_extensionsView->setItemDelegate(new ExtensionItemDelegate(this));
- m_extensionsView->setResizeMode(QListView::Adjust);
- m_extensionsView->setSelectionMode(QListView::SingleSelection);
- m_extensionsView->setUniformItemSizes(true);
- m_extensionsView->setViewMode(QListView::IconMode);
- m_extensionsView->setModel(m_filterProxyModel);
- m_extensionsView->setMouseTracking(true);
+ d->filterProxyModel = new QSortFilterProxyModel(this);
+ d->filterProxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
+ d->filterProxyModel->setFilterRole(RoleSearchText);
+ d->filterProxyModel->setSortRole(RoleItemType);
+ d->filterProxyModel->setSourceModel(d->model);
+
+ d->extensionsView = new QListView;
+ d->extensionsView->setFrameStyle(QFrame::NoFrame);
+ d->extensionsView->setItemDelegate(new ExtensionItemDelegate(this));
+ d->extensionsView->setResizeMode(QListView::Adjust);
+ d->extensionsView->setSelectionMode(QListView::SingleSelection);
+ d->extensionsView->setUniformItemSizes(true);
+ d->extensionsView->setViewMode(QListView::IconMode);
+ d->extensionsView->setModel(d->filterProxyModel);
+ d->extensionsView->setMouseTracking(true);
using namespace Layouting;
Column {
Space(15),
manageLabel,
Space(15),
- Row { m_searchBox, st, m_updateButton, Space(extraListViewWidth() + gapSize) },
+ Row { d->searchBox, st, d->updateButton, Space(extraListViewWidth() + gapSize) },
Space(gapSize),
- m_extensionsView,
+ d->extensionsView,
noMargin, spacing(0),
}.attachTo(this);
WelcomePageHelpers::setBackgroundColor(this, Theme::Token_Background_Default);
- WelcomePageHelpers::setBackgroundColor(m_extensionsView, Theme::Token_Background_Default);
- WelcomePageHelpers::setBackgroundColor(m_extensionsView->viewport(),
+ WelcomePageHelpers::setBackgroundColor(d->extensionsView, Theme::Token_Background_Default);
+ WelcomePageHelpers::setBackgroundColor(d->extensionsView->viewport(),
Theme::Token_Background_Default);
auto updateModel = [this] {
- m_model.reset(extensionsModel());
- m_filterProxyModel->setSourceModel(m_model.data());
- m_filterProxyModel->sort(0);
+ d->filterProxyModel->sort(0);
- if (m_selectionModel == nullptr) {
- m_selectionModel = new QItemSelectionModel(m_filterProxyModel, m_extensionsView);
- m_extensionsView->setSelectionModel(m_selectionModel);
- connect(m_extensionsView->selectionModel(), &QItemSelectionModel::currentChanged,
+ if (d->selectionModel == nullptr) {
+ d->selectionModel = new QItemSelectionModel(d->filterProxyModel,
+ d->extensionsView);
+ d->extensionsView->setSelectionModel(d->selectionModel);
+ connect(d->extensionsView->selectionModel(), &QItemSelectionModel::currentChanged,
this, &ExtensionsBrowser::itemSelected);
}
};
+ connect(d->updateButton, &QAbstractButton::pressed, this, []() {
+ executePluginInstallWizard();
+ });
connect(ExtensionSystem::PluginManager::instance(),
&ExtensionSystem::PluginManager::pluginsChanged, this, updateModel);
- connect(m_searchBox, &QLineEdit::textChanged,
- m_filterProxyModel, &QSortFilterProxyModel::setFilterWildcard);
+ connect(ExtensionSystem::PluginManager::instance(),
+ &ExtensionSystem::PluginManager::initializationDone,
+ this, &ExtensionsBrowser::fetchExtensions);
+ connect(d->searchBox, &QLineEdit::textChanged,
+ d->filterProxyModel, &QSortFilterProxyModel::setFilterWildcard);
+}
+
+ExtensionsBrowser::~ExtensionsBrowser()
+{
+ delete d;
}
void ExtensionsBrowser::adjustToWidth(const int width)
{
const int widthForItems = width - extraListViewWidth();
- m_columnsCount = qMax(1, qFloor(widthForItems / cellSize.width()));
- m_updateButton->setVisible(m_columnsCount > 1);
+ d->columnsCount = qMax(1, qFloor(widthForItems / cellSize.width()));
+ d->updateButton->setVisible(d->columnsCount > 1);
updateGeometry();
}
QSize ExtensionsBrowser::sizeHint() const
{
- const int columsWidth = m_columnsCount * cellSize.width();
+ const int columsWidth = d->columnsCount * cellSize.width();
return { columsWidth + extraListViewWidth(), 0};
}
int ExtensionsBrowser::extraListViewWidth() const
{
// TODO: Investigate "transient" scrollbar, just for this list view.
- return m_extensionsView->style()->pixelMetric(QStyle::PM_ScrollBarExtent)
+ return d->extensionsView->style()->pixelMetric(QStyle::PM_ScrollBarExtent)
+ 1; // Needed
}
+void ExtensionsBrowser::fetchExtensions()
+{
+ // d->model->setExtensionsJson(testData("thirdpartyplugins")); return;
+
+ using namespace Tasking;
+
+ const auto onQuerySetup = [](NetworkQuery &query) {
+ const QString host = "https://qc-extensions.qt.io";
+ const QString url = "%1/api/v1/search?request=";
+ const QString requestTemplate
+ = R"({"version":"%1","host_os":"%2","host_os_version":"%3","host_architecture":"%4","page_size":200})";
+ const QString request = url.arg(host)
+ + requestTemplate
+ .arg("2.2") // .arg(QCoreApplication::applicationVersion())
+ .arg("macOS") // .arg(QSysInfo::productType())
+ .arg("12") // .arg(QSysInfo::productVersion())
+ .arg("arm64"); // .arg(QSysInfo::currentCpuArchitecture());
+
+ query.setRequest(QNetworkRequest(QUrl::fromUserInput(request)));
+ query.setNetworkAccessManager(NetworkAccessManager::instance());
+ };
+ const auto onQueryDone = [this](const NetworkQuery &query, DoneWith result) {
+ if (result != DoneWith::Success) {
+#ifdef WITH_TESTS
+ d->model->setExtensionsJson(testData("defaultpacks"));
+#endif // WITH_TESTS
+ return;
+ }
+ const QByteArray response = query.reply()->readAll();
+ d->model->setExtensionsJson(response);
+ };
+
+ Group group {
+ NetworkQueryTask{onQuerySetup, onQueryDone},
+ };
+
+ d->taskTreeRunner.start(group);
+}
+
} // ExtensionManager::Internal
diff --git a/src/plugins/extensionmanager/extensionsbrowser.h b/src/plugins/extensionmanager/extensionsbrowser.h
index 2daa2362ba0..d0467aa2162 100644
--- a/src/plugins/extensionmanager/extensionsbrowser.h
+++ b/src/plugins/extensionmanager/extensionsbrowser.h
@@ -3,66 +3,30 @@
#pragma once
-#include
-
-#include
#include
-QT_BEGIN_NAMESPACE
-class QAbstractButton;
-class QItemSelectionModel;
-class QLineEdit;
-class QListView;
-class QSortFilterProxyModel;
-QT_END_NAMESPACE
-
-namespace ExtensionSystem
-{
-class PluginSpec;
-}
-
namespace ExtensionManager::Internal {
-using PluginSpecList = QList;
-using Tags = QStringList;
-
-enum ItemType {
- ItemTypePack,
- ItemTypeExtension,
-};
-
-struct ItemData {
- const QString name;
- const ItemType type = ItemTypeExtension;
- const Tags tags;
- const PluginSpecList plugins;
-};
-
-ItemData itemData(const QModelIndex &index);
-
class ExtensionsBrowser final : public QWidget
{
Q_OBJECT
public:
- ExtensionsBrowser();
+ ExtensionsBrowser(QWidget *parent = nullptr);
+ ~ExtensionsBrowser();
void adjustToWidth(const int width);
QSize sizeHint() const override;
+ int extraListViewWidth() const; // Space for scrollbar, etc.
+
signals:
void itemSelected(const QModelIndex ¤t, const QModelIndex &previous);
private:
- int extraListViewWidth() const; // Space for scrollbar, etc.
+ void fetchExtensions();
- QScopedPointer m_model;
- QLineEdit *m_searchBox;
- QAbstractButton *m_updateButton;
- QListView *m_extensionsView;
- QItemSelectionModel *m_selectionModel = nullptr;
- QSortFilterProxyModel *m_filterProxyModel;
- int m_columnsCount = 2;
+ class ExtensionsBrowserPrivate *d = nullptr;
};
} // ExtensionManager::Internal
diff --git a/src/plugins/extensionmanager/extensionsmodel.cpp b/src/plugins/extensionmanager/extensionsmodel.cpp
new file mode 100644
index 00000000000..2315164e11f
--- /dev/null
+++ b/src/plugins/extensionmanager/extensionsmodel.cpp
@@ -0,0 +1,411 @@
+// Copyright (C) 2023 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
+
+#include "extensionsmodel.h"
+
+#include "extensionsbrowser.h"
+
+#include "extensionmanagertr.h"
+#include "utils/algorithm.h"
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+using namespace ExtensionSystem;
+using namespace Core;
+using namespace Utils;
+
+namespace ExtensionManager::Internal {
+
+Q_LOGGING_CATEGORY(modelLog, "qtc.extensionmanager.model", QtWarningMsg)
+
+struct PluginDependency
+{
+ QString name;
+ QString version;
+};
+using PluginDependencies = QList;
+
+struct Plugin
+{
+ PluginDependencies dependencies;
+ QString copyright;
+ QString name;
+ QString packageUrl;
+ QString vendor;
+ QString version;
+};
+using Plugins = QList;
+
+struct Description {
+ ImagesData images;
+ LinksData links;
+ TextData text;
+};
+
+struct Extension {
+ QString copyright;
+ Description description;
+ QString license;
+ QString name;
+ QStringList platforms;
+ Plugins plugins;
+ qint64 size = 0;
+ QStringList tags;
+ ItemType type = ItemTypePack;
+ QString vendor;
+ QString version;
+};
+using Extensions = QList;
+
+static Plugin pluginFromJson(const QJsonObject &obj)
+{
+ const QJsonObject metaDataObj = obj.value("meta_data").toObject();
+
+ const QJsonArray dependenciesArray = metaDataObj.value("Dependencies").toArray();
+ PluginDependencies dependencies;
+ for (const QJsonValueConstRef &dependencyVal : dependenciesArray) {
+ const QJsonObject dependencyObj = dependencyVal.toObject();
+ dependencies.append(PluginDependency{
+ .name = dependencyObj.value("Name").toString(),
+ .version = dependencyObj.value("Version").toString(),
+ });
+ }
+
+ return {
+ .dependencies = dependencies,
+ .copyright = metaDataObj.value("Copyright").toString(),
+ .name = metaDataObj.value("Name").toString(),
+ .packageUrl = obj.value("url").toString(),
+ .vendor = metaDataObj.value("Vendor").toString(),
+ .version = metaDataObj.value("Version").toString(),
+ };
+}
+
+static Description descriptionFromJson(const QJsonObject &obj)
+{
+ TextData descriptionText;
+ const QJsonArray paragraphsArray = obj.value("paragraphs").toArray();
+ for (const QJsonValueConstRef ¶graphVal : paragraphsArray) {
+ const QJsonObject ¶graphObj = paragraphVal.toObject();
+ const QJsonArray &textArray = paragraphObj.value("text").toArray();
+ QStringList textLines;
+ for (const QJsonValueConstRef &textVal : textArray)
+ textLines.append(textVal.toString());
+ descriptionText.append({
+ paragraphObj.value("header").toString(),
+ textLines,
+ });
+ }
+
+ LinksData links;
+ const QJsonArray linksArray = obj.value("links").toArray();
+ for (const QJsonValueConstRef &linkVal : linksArray) {
+ const QJsonObject &linkObj = linkVal.toObject();
+ links.append({
+ linkObj.value("link_text").toString(),
+ linkObj.value("url").toString(),
+ });
+ }
+
+ ImagesData images;
+ const QJsonArray imagesArray = obj.value("images").toArray();
+ for (const QJsonValueConstRef &imageVal : imagesArray) {
+ const QJsonObject &imageObj = imageVal.toObject();
+ images.append({
+ imageObj.value("image_label").toString(),
+ imageObj.value("url").toString(),
+ });
+ }
+
+ const Description description = {
+ .images = images,
+ .links = links,
+ .text = descriptionText,
+ };
+
+ return description;
+}
+
+static Extension extensionFromJson(const QJsonObject &obj)
+{
+ Plugins plugins;
+ const QJsonArray pluginsArray = obj.value("plugins").toArray();
+ for (const QJsonValueConstRef &pluginVal : pluginsArray)
+ plugins.append(pluginFromJson(pluginVal.toObject()));
+
+ QStringList tags;
+ const QJsonArray tagsArray = obj.value("tags").toArray();
+ for (const QJsonValueConstRef &tagVal : tagsArray)
+ tags.append(tagVal.toString());
+
+ QStringList platforms;
+ const QJsonArray platformsArray = obj.value("platforms").toArray();
+ for (const QJsonValueConstRef &platformsVal : platformsArray)
+ platforms.append(platformsVal.toString());
+
+ const QJsonObject descriptionObj = obj.value("description").toObject();
+ const Description description = descriptionFromJson(descriptionObj);
+
+ const Extension extension = {
+ .copyright = obj.value("copyright").toString(),
+ .description = description,
+ .license = obj.value("license").toString(),
+ .name = obj.value("name").toString(),
+ .platforms = platforms,
+ .plugins = plugins,
+ .size = obj.value("total_size").toInteger(),
+ .tags = tags,
+ .type = obj.value("is_pack").toBool(true) ? ItemTypePack : ItemTypeExtension,
+ .vendor = obj.value("vendor").toString(),
+ .version = obj.value("version").toString(),
+ };
+
+ return extension;
+}
+
+static Extensions parseExtensionsRepoReply(const QByteArray &jsonData)
+{
+ // https://qc-extensions.qt.io/api-docs
+ Extensions parsedExtensions;
+ const QJsonObject jsonObj = QJsonDocument::fromJson(jsonData).object();
+ const QJsonArray items = jsonObj.value("items").toArray();
+ for (const QJsonValueConstRef &itemVal : items) {
+ const QJsonObject itemObj = itemVal.toObject();
+ const Extension extension = extensionFromJson(itemObj);
+ parsedExtensions.append(extension);
+ }
+ return parsedExtensions;
+}
+
+class ExtensionsModelPrivate : public QObject
+{
+public:
+ ExtensionsModelPrivate(ExtensionsModel *parent)
+ : q(parent)
+ {
+ }
+
+ void setExtensions(const Extensions &extensions);
+ void removeLocalExtensions();
+
+ ExtensionsModel *q;
+
+ Extensions allExtensions; // Original, complete extensions entries
+ Extensions absentExtensions; // All packs + plugin extensions that are not (yet) installed
+};
+
+void ExtensionsModelPrivate::setExtensions(const Extensions &extensions)
+{
+ allExtensions = extensions;
+ removeLocalExtensions();
+}
+
+void ExtensionsModelPrivate::removeLocalExtensions()
+{
+ const QStringList installedPlugins = transform(PluginManager::plugins(), &PluginSpec::name);
+ absentExtensions.clear();
+ for (const Extension &extension : allExtensions) {
+ if (extension.type == ItemTypePack || !installedPlugins.contains(extension.name))
+ absentExtensions.append(extension);
+ }
+}
+
+ExtensionsModel::ExtensionsModel(QObject *parent)
+ : QAbstractListModel(parent)
+ , d(new ExtensionsModelPrivate(this))
+{
+}
+
+ExtensionsModel::~ExtensionsModel()
+{
+ delete d;
+}
+
+int ExtensionsModel::rowCount([[maybe_unused]] const QModelIndex &parent) const
+{
+ const int remoteExtnsionsCount = d->absentExtensions.count();
+ const int installedPluginsCount = PluginManager::plugins().count();
+ return remoteExtnsionsCount + installedPluginsCount;
+}
+
+static QVariant dataFromPluginSpec(const PluginSpec *pluginSpec, int role)
+{
+ switch (role) {
+ case Qt::DisplayRole:
+ case RoleName:
+ return pluginSpec->name();
+ case RoleCopyright:
+ return pluginSpec->copyright();
+ case RoleDependencies: {
+ QStringList dependencies = transform(pluginSpec->dependencies(),
+ &ExtensionSystem::PluginDependency::toString);
+ dependencies.sort();
+ return dependencies;
+ }
+ case RoleDescriptionImages:
+ break;
+ case RoleDescriptionLinks: {
+ const QString url = pluginSpec->url();
+ if (!url.isEmpty()) {
+ const LinksData links = {{{}, url}};
+ return QVariant::fromValue(links);
+ }
+ break;
+ }
+ case RoleDescriptionText: {
+ QStringList lines = pluginSpec->description().split('\n', Qt::SkipEmptyParts);
+ lines.append(pluginSpec->longDescription().split('\n', Qt::SkipEmptyParts));
+ const TextData text = {{ pluginSpec->name(), lines }};
+ return QVariant::fromValue(text);
+ }
+ case RoleItemType:
+ return ItemTypeExtension;
+ case RoleLicense:
+ return pluginSpec->license();
+ case RoleLocation:
+ return pluginSpec->filePath().toVariant();
+ case RolePlatforms: {
+ const QString pattern = pluginSpec->platformSpecification().pattern();
+ const QStringList platforms = pattern.isEmpty()
+ ? QStringList({"macOS", "Windows", "Linux"})
+ : QStringList(pattern);
+ return platforms;
+ }
+ case RoleSize:
+ return pluginSpec->filePath().fileSize();
+ case RoleTags:
+ break;
+ case RoleVendor:
+ return pluginSpec->vendor();
+ case RoleVersion:
+ return pluginSpec->version();
+ default:
+ break;
+ }
+ return {};
+}
+
+static QStringList dependenciesFromExtension(const Extension &extension)
+{
+ QStringList dependencies;
+ for (const Plugin &plugin : extension.plugins) {
+ for (const PluginDependency &dependency : plugin.dependencies) {
+ const QString withVersion = QString::fromLatin1("%1 (%2)").arg(dependency.name)
+ .arg(dependency.version);
+ dependencies.append(withVersion);
+ }
+ }
+ dependencies.sort();
+ dependencies.removeDuplicates();
+ return dependencies;
+}
+
+static QVariant dataFromExtension(const Extension &extension, int role)
+{
+ switch (role) {
+ case Qt::DisplayRole:
+ case RoleName:
+ return extension.name;
+ case RoleCopyright:
+ return !extension.copyright.isEmpty() ? extension.copyright : QVariant();
+ case RoleDependencies:
+ return dependenciesFromExtension(extension);
+ case RoleDescriptionImages:
+ return QVariant::fromValue(extension.description.images);
+ case RoleDescriptionLinks:
+ return QVariant::fromValue(extension.description.links);
+ case RoleDescriptionText:
+ return QVariant::fromValue(extension.description.text);
+ case RoleItemType:
+ return extension.type;
+ case RoleLicense:
+ return extension.license;
+ case RoleLocation:
+ break;
+ case RolePlatforms:
+ return extension.platforms;
+ case RolePlugins: {
+ PluginsData plugins;
+ for (const Plugin &plugin : extension.plugins)
+ plugins.append(qMakePair(plugin.name, plugin.packageUrl));
+ return QVariant::fromValue(plugins);
+ }
+ case RoleSize:
+ return extension.size;
+ case RoleTags:
+ return extension.tags;
+ case RoleVendor:
+ return !extension.vendor.isEmpty() ? extension.vendor : QVariant();
+ case RoleVersion:
+ return !extension.version.isEmpty() ? extension.version : QVariant();
+ default:
+ break;
+ }
+ return {};
+}
+
+static QString searchText(const QModelIndex &index)
+{
+ QStringList searchTexts;
+ searchTexts.append(index.data(RoleName).toString());
+ searchTexts.append(index.data(RoleTags).toStringList());
+ searchTexts.append(index.data(RoleDescriptionText).toStringList());
+ searchTexts.append(index.data(RoleVendor).toString());
+ return searchTexts.join(" ");
+}
+
+QVariant ExtensionsModel::data(const QModelIndex &index, int role) const
+{
+ if (role == RoleSearchText)
+ return searchText(index);
+
+ const bool itemIsLocalPlugin = index.row() >= d->absentExtensions.count();
+ if (itemIsLocalPlugin) {
+ const PluginSpecs &pluginSpecs = PluginManager::plugins();
+ const int pluginIndex = index.row() - d->absentExtensions.count();
+ QTC_ASSERT(pluginIndex >= 0 && pluginIndex <= pluginSpecs.size(), return {});
+ const PluginSpec *plugin = pluginSpecs.at(pluginIndex);
+ return dataFromPluginSpec(plugin, role);
+ } else {
+ const Extension &extension = d->absentExtensions.at(index.row());
+ const QVariant extensionData = dataFromExtension(extension, role);
+
+ // If data is unavailable, retrieve it from the first contained plugin
+ if (extensionData.isNull() && !extension.plugins.isEmpty()) {
+ const PluginSpec *pluginSpec = ExtensionsModel::pluginSpecForName(
+ extension.plugins.constFirst().name);
+ if (pluginSpec)
+ return dataFromPluginSpec(pluginSpec, role);
+ }
+ return extensionData;
+ }
+ return {};
+}
+
+void ExtensionsModel::setExtensionsJson(const QByteArray &json)
+{
+ const Extensions extensions = parseExtensionsRepoReply(json);
+ beginResetModel();
+ d->setExtensions(extensions);
+ endResetModel();
+}
+
+PluginSpec *ExtensionsModel::pluginSpecForName(const QString &pluginName)
+{
+ return Utils::findOrDefault(PluginManager::plugins(),
+ Utils::equal(&PluginSpec::name, pluginName));
+}
+
+} // ExtensionManager::Internal
diff --git a/src/plugins/extensionmanager/extensionsmodel.h b/src/plugins/extensionmanager/extensionsmodel.h
new file mode 100644
index 00000000000..d426f443533
--- /dev/null
+++ b/src/plugins/extensionmanager/extensionsmodel.h
@@ -0,0 +1,68 @@
+// Copyright (C) 2023 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
+
+#pragma once
+
+#include
+
+namespace ExtensionSystem {
+class PluginSpec;
+}
+
+namespace ExtensionManager::Internal {
+
+using QPairList = QList >;
+
+using ImagesData = QPairList; // { , ... }
+using LinksData = QPairList; // { , ... }
+using PluginsData = QPairList; // { , ... }
+using TextData = QList >; // { , ... }
+
+enum ItemType {
+ ItemTypePack,
+ ItemTypeExtension,
+};
+
+enum Role {
+ RoleName = Qt::UserRole,
+ RoleCopyright,
+ RoleDependencies,
+ RoleDescriptionImages,
+ RoleDescriptionLinks,
+ RoleDescriptionText,
+ RoleItemType,
+ RoleLicense,
+ RoleLocation,
+ RolePlatforms,
+ RolePlugins,
+ RoleSearchText,
+ RoleSize,
+ RoleTags,
+ RoleVendor,
+ RoleVersion,
+};
+
+class ExtensionsModel : public QAbstractListModel
+{
+public:
+ ExtensionsModel(QObject *parent = nullptr);
+ ~ExtensionsModel();
+
+ int rowCount(const QModelIndex &parent = {}) const;
+ QVariant data(const QModelIndex &index, int role) const;
+
+ void setExtensionsJson(const QByteArray &json);
+ static ExtensionSystem::PluginSpec *pluginSpecForName(const QString &pluginName);
+
+private:
+ class ExtensionsModelPrivate *d = nullptr;
+};
+
+#ifdef WITH_TESTS
+QObject *createExtensionsModelTest();
+#endif
+
+} // ExtensionManager::Internal
+
+Q_DECLARE_METATYPE(ExtensionManager::Internal::QPairList)
+Q_DECLARE_METATYPE(ExtensionManager::Internal::TextData)
diff --git a/src/plugins/extensionmanager/testdata/defaultpacks.json b/src/plugins/extensionmanager/testdata/defaultpacks.json
new file mode 100644
index 00000000000..0323b08d27e
--- /dev/null
+++ b/src/plugins/extensionmanager/testdata/defaultpacks.json
@@ -0,0 +1,161 @@
+{
+ "items": [
+ {
+ "name": "Essentials",
+ "tags": [ "Essentials" ],
+ "platforms": [ "macOS", "Windows", "Linux" ],
+ "license": "os",
+ "is_pack": true,
+ "description": {
+ "paragraphs": [
+ {
+ "text": [
+ "Basic services, such as editing and debugging code, viewing images, and adding resources to applications."
+ ],
+ "header": "Get started"
+ }
+ ],
+ "links": [
+ {
+ "url": "https://doc.qt.io/qtcreator/creator-coding-navigating.html",
+ "link_text": "Online documentation"
+ }
+ ]
+ },
+ "plugins": [
+ { "meta_data": { "Name": "BinEditor" } },
+ { "meta_data": { "Name": "Debugger" } },
+ { "meta_data": { "Name": "DiffEditor" } },
+ { "meta_data": { "Name": "ImageViewer" } },
+ { "meta_data": { "Name": "Macros" } },
+ { "meta_data": { "Name": "LanguageClient" } },
+ { "meta_data": { "Name": "ResourceEditor" } }
+ ]
+ },
+
+ {
+ "name": "C++ Support",
+ "tags": [ "Programming Language", "C++" ],
+ "platforms": [ "macOS", "Windows", "Linux" ],
+ "license": "os",
+ "is_pack": true,
+ "description": {
+ "paragraphs": [
+ {
+ "text": [
+ "Tools for developing Qt C++ applications."
+ ],
+ "header": "Get started"
+ }
+ ]
+ },
+ "plugins": [
+ { "meta_data": { "Name": "ClangCodeModel" } },
+ { "meta_data": { "Name": "ClangFormat" } },
+ { "meta_data": { "Name": "ClassView" } },
+ { "meta_data": { "Name": "CppEditor" } }
+ ]
+ },
+
+ {
+ "name": "QML Support",
+ "tags": [ "Programming Language", "QML" ],
+ "platforms": [ "macOS", "Windows", "Linux" ],
+ "license": "os",
+ "is_pack": true,
+ "description": {
+ "paragraphs": [
+ {
+ "text": [
+ "Tools for developing Qt Quick applications."
+ ],
+ "header": "Get started"
+ }
+ ]
+ },
+ "plugins": [
+ { "meta_data": { "Name": "QmlJSEditor" } },
+ { "meta_data": { "Name": "QmlJSTools" } },
+ { "meta_data": { "Name": "QmlPreview" } },
+ { "meta_data": { "Name": "QmlProfiler" } }
+ ]
+ },
+
+ {
+ "name": "Visual QML Editor",
+ "tags": [ "Visual UI editor", "qml", "Quick" ],
+ "platforms": [ "macOS", "Windows", "Linux" ],
+ "license": "os",
+ "is_pack": true,
+ "description": {
+ "paragraphs": [
+ {
+ "text": [
+ "Tools for creating Qt Quick UIs."
+ ],
+ "header": "Get started"
+ }
+ ]
+ },
+ "plugins": [
+ { "meta_data": { "Name": "QmlDesigner" } }
+ ]
+ },
+
+ {
+ "name": "Visual Widget Editor",
+ "tags": [ "Visual UI editor", "C++", "Widgets" ],
+ "platforms": [ "macOS", "Windows", "Linux" ],
+ "license": "os",
+ "is_pack": true,
+ "description": {
+ "paragraphs": [
+ {
+ "text": [
+ "Visual tool for creating Qt widget-based UIs."
+ ],
+ "header": "Get started"
+ }
+ ]
+ },
+ "plugins": [
+ { "meta_data": { "Name": "Designer" } }
+ ]
+ },
+
+ {
+ "name": "SpellChecker",
+ "tags": [ "Editor" ],
+ "platforms": [ "macOS", "Windows", "Linux" ],
+ "license": "os",
+ "is_pack": false,
+ "description": {
+ "paragraphs": [
+ {
+ "text": [
+ "Spellcheck comments in source files."
+ ],
+ "header": "Get started"
+ }
+ ],
+ "links": [
+ {
+ "url": "https://github.com/CJCombrink/SpellChecker-Plugin",
+ "link_text": "GitHub page"
+ }
+ ]
+ },
+ "plugins": [
+ {
+ "meta_data": {
+ "Name": "SpellChecker",
+ "Copyright": "(C) 2015 - 2024 Carel Combrink"
+ },
+ "url": "https://github.com/CJCombrink/SpellChecker-Plugin/releases/download/v3.6.0/SpellChecker-Plugin_QtC13.0.0_macos_x64.tar.gz"
+ }
+ ],
+ "vendor": "Carel Combrink",
+ "copyright": "(C) 2015 - 2024 Carel Combrink"
+ }
+ ]
+}
diff --git a/src/plugins/extensionmanager/testdata/thirdpartyplugins.json b/src/plugins/extensionmanager/testdata/thirdpartyplugins.json
new file mode 100644
index 00000000000..910854a68f8
--- /dev/null
+++ b/src/plugins/extensionmanager/testdata/thirdpartyplugins.json
@@ -0,0 +1,38 @@
+{
+ "items": [
+ {
+ "name": "SpellChecker",
+ "tags": [ "Editor" ],
+ "platforms": [ "macOS", "Windows", "Linux" ],
+ "license": "os",
+ "is_pack": false,
+ "description": {
+ "paragraphs": [
+ {
+ "text": [
+ "Spellcheck comments in source files."
+ ],
+ "header": "Get started"
+ }
+ ],
+ "links": [
+ {
+ "url": "https://github.com/CJCombrink/SpellChecker-Plugin",
+ "link_text": "GitHub page"
+ }
+ ]
+ },
+ "plugins": [
+ {
+ "meta_data": {
+ "Name": "SpellChecker",
+ "Copyright": "(C) 2015 - 2024 Carel Combrink"
+ },
+ "url": "https://github.com/CJCombrink/SpellChecker-Plugin/releases/download/v3.6.0/SpellChecker-Plugin_QtC13.0.0_macos_x64.tar.gz"
+ }
+ ],
+ "vendor": "Carel Combrink",
+ "copyright": "(C) 2015 - 2024 Carel Combrink"
+ }
+ ]
+}