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 <cristian.adam@qt.io>
This commit is contained in:
Alessandro Portale
2024-05-29 16:00:22 +02:00
parent fb2a1ecd37
commit 1a1d938170
14 changed files with 1234 additions and 512 deletions

View File

@@ -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 <coreplugin/coreconstants.h>
#include <coreplugin/icontext.h>
#include <coreplugin/icore.h>
#include <coreplugin/plugininstallwizard.h>
#include <coreplugin/welcomepagehelper.h>
#include <extensionsystem/iplugin.h>
@@ -15,9 +22,14 @@
#include <extensionsystem/pluginview.h>
#include <extensionsystem/pluginmanager.h>
#include <solutions/tasking/networkquery.h>
#include <solutions/tasking/tasktree.h>
#include <solutions/tasking/tasktreerunner.h>
#include <utils/fancylineedit.h>
#include <utils/icon.h>
#include <utils/layoutbuilder.h>
#include <utils/networkaccessmanager.h>
#include <utils/stylehelper.h>
#include <QItemDelegate>
@@ -26,7 +38,6 @@
#include <QMessageBox>
#include <QPainter>
#include <QPainterPath>
#include <QStandardItemModel>
#include <QStyle>
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<ItemType>(),
index.data(RoleTags).toStringList(),
index.data(RolePluginSpecs).value<PluginSpecList>(),
};
}
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<QStandardItem*> items;
QStringList expectedExtensions;
QStringList unexpectedExtensions;
QHash<const QString, const PluginSpec*> 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<PluginSpecList>(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<PluginSpecList>(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<PluginSpecList>();
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<PluginsData>();
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