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
-

- - - - -
%4%5
%6%7
%8%9
-

- )").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
+

+ + + + )").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"( + + )").arg(Tr::tr("Location")) + .arg(locationFmt)); + } + description.append(QString(R"( +
%4%5
%6%7
%1%2
+

+ )")); + } 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" + } + ] +}