GitLab: Allow browsing and cloning projects

Change-Id: I1cc877ea6b5a55ae7bdb8e7a529afeb08d09e0c0
Reviewed-by: <github-actions-qt-creator@cristianadam.eu>
Reviewed-by: David Schulz <david.schulz@qt.io>
This commit is contained in:
Christian Stenger
2022-05-06 15:15:46 +02:00
parent b336ebafc3
commit dcfa15ff17
14 changed files with 1228 additions and 2 deletions

View File

@@ -3,6 +3,8 @@ add_qtc_plugin(GitLab
PLUGIN_DEPENDS Core ProjectExplorer Git VcsBase PLUGIN_DEPENDS Core ProjectExplorer Git VcsBase
DEPENDS Utils DEPENDS Utils
SOURCES SOURCES
gitlabclonedialog.cpp gitlabclonedialog.h
gitlabdialog.cpp gitlabdialog.h gitlabdialog.ui
gitlaboptionspage.cpp gitlaboptionspage.h gitlaboptionspage.cpp gitlaboptionspage.h
gitlabparameters.cpp gitlabparameters.h gitlabparameters.cpp gitlabparameters.h
gitlabplugin.cpp gitlabplugin.h gitlabplugin.cpp gitlabplugin.h

View File

@@ -10,6 +10,11 @@ QtcPlugin {
Depends { name: "Utils" } Depends { name: "Utils" }
files: [ files: [
"gitlabclonedialog.cpp",
"gitlabclonedialog.h",
"gitlabdialog.cpp",
"gitlabdialog.h",
"gitlabdialog.ui",
"gitlaboptionspage.cpp", "gitlaboptionspage.cpp",
"gitlaboptionspage.h", "gitlaboptionspage.h",
"gitlabparameters.cpp", "gitlabparameters.cpp",

View File

@@ -0,0 +1,263 @@
/****************************************************************************
**
** Copyright (C) 2022 The Qt Company Ltd.
** Contact: https://www.qt.io/licensing/
**
** This file is part of Qt Creator.
**
** Commercial License Usage
** Licensees holding valid commercial Qt licenses may use this file in
** accordance with the commercial license agreement provided with the
** Software or, alternatively, in accordance with the terms contained in
** a written agreement between you and The Qt Company. For licensing terms
** and conditions see https://www.qt.io/terms-conditions. For further
** information use the contact form at https://www.qt.io/contact-us.
**
** GNU General Public License Usage
** Alternatively, this file may be used under the terms of the GNU
** General Public License version 3 as published by the Free Software
** Foundation with exceptions as appearing in the file LICENSE.GPL3-EXCEPT
** included in the packaging of this file. Please review the following
** information to ensure the GNU General Public License requirements will
** be met: https://www.gnu.org/licenses/gpl-3.0.html.
**
****************************************************************************/
#include "gitlabclonedialog.h"
#include "gitlabprojectsettings.h"
#include "resultparser.h"
#include <coreplugin/documentmanager.h>
#include <coreplugin/shellcommand.h>
#include <coreplugin/vcsmanager.h>
#include <git/gitclient.h>
#include <projectexplorer/projectexplorer.h>
#include <projectexplorer/projectmanager.h>
#include <utils/algorithm.h>
#include <utils/commandline.h>
#include <utils/environment.h>
#include <utils/fancylineedit.h>
#include <utils/filepath.h>
#include <utils/infolabel.h>
#include <utils/mimeutils.h>
#include <utils/pathchooser.h>
#include <utils/qtcassert.h>
#include <utils/qtcprocess.h>
#include <QApplication>
#include <QCheckBox>
#include <QComboBox>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QInputDialog>
#include <QMessageBox>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QVBoxLayout>
namespace GitLab {
GitLabCloneDialog::GitLabCloneDialog(const Project &project, QWidget *parent)
: QDialog(parent)
{
setWindowTitle(tr("Clone Repository"));
QVBoxLayout *layout = new QVBoxLayout(this);
layout->addWidget(new QLabel(tr("Specify repository URL, checkout path and directory.")));
QHBoxLayout *centerLayout = new QHBoxLayout;
QFormLayout *form = new QFormLayout;
m_repositoryCB = new QComboBox(this);
m_repositoryCB->addItems({project.sshUrl, project.httpUrl});
form->addRow(tr("Repository"), m_repositoryCB);
m_pathChooser = new Utils::PathChooser(this);
m_pathChooser->setExpectedKind(Utils::PathChooser::ExistingDirectory);
form->addRow(tr("Path"), m_pathChooser);
m_directoryLE = new Utils::FancyLineEdit(this);
m_directoryLE->setValidationFunction([this](Utils::FancyLineEdit *e, QString *msg) {
const Utils::FilePath fullPath = m_pathChooser->filePath().pathAppended(e->text());
bool alreadyExists = fullPath.exists();
if (alreadyExists && msg)
*msg = tr("Path \"%1\" already exists.").arg(fullPath.toUserOutput());
return !alreadyExists;
});
form->addRow(tr("Directory"), m_directoryLE);
m_submodulesCB = new QCheckBox(this);
form->addRow(tr("Recursive"), m_submodulesCB);
centerLayout->addLayout(form);
m_cloneOutput = new QPlainTextEdit(this);
m_cloneOutput->setReadOnly(true);
centerLayout->addWidget(m_cloneOutput);
layout->addLayout(centerLayout);
m_infoLabel = new Utils::InfoLabel(this);
layout->addWidget(m_infoLabel);
layout->addStretch(1);
auto buttons = new QDialogButtonBox(QDialogButtonBox::Cancel, this);
m_cloneButton = new QPushButton(tr("Clone"), this);
buttons->addButton(m_cloneButton, QDialogButtonBox::ActionRole);
m_cancelButton = buttons->button(QDialogButtonBox::Cancel);
layout->addWidget(buttons);
setLayout(layout);
m_pathChooser->setFilePath(Core::DocumentManager::projectsDirectory());
auto [host, path, port]
= GitLabProjectSettings::remotePartsFromRemote(m_repositoryCB->currentText());
int slashIndex = path.indexOf('/');
QTC_ASSERT(slashIndex > 0, return);
m_directoryLE->setText(path.mid(slashIndex + 1));
connect(m_pathChooser, &Utils::PathChooser::pathChanged, this, [this]() {
m_directoryLE->validate();
GitLabCloneDialog::updateUi();
});
connect(m_directoryLE, &Utils::FancyLineEdit::textChanged, this, &GitLabCloneDialog::updateUi);
connect(m_cloneButton, &QPushButton::clicked, this, &GitLabCloneDialog::cloneProject);
connect(m_cancelButton, &QPushButton::clicked,
this, &GitLabCloneDialog::cancel);
connect(this, &QDialog::rejected, this, [this]() {
if (m_commandRunning) {
cancel();
QApplication::restoreOverrideCursor();
return;
}
});
updateUi();
resize(575, 265);
}
void GitLabCloneDialog::updateUi()
{
bool pathValid = m_pathChooser->isValid();
bool directoryValid = m_directoryLE->isValid();
m_cloneButton->setEnabled(pathValid && directoryValid);
if (!pathValid) {
m_infoLabel->setText(m_pathChooser->errorMessage());
m_infoLabel->setType(Utils::InfoLabel::Error);
} else if (!directoryValid) {
m_infoLabel->setText(m_directoryLE->errorMessage());
m_infoLabel->setType(Utils::InfoLabel::Error);
}
m_infoLabel->setVisible(!pathValid || !directoryValid);
}
void GitLabCloneDialog::cloneProject()
{
Core::IVersionControl *vc = Core::VcsManager::versionControl(Utils::Id::fromString("G.Git"));
QTC_ASSERT(vc, return);
const QStringList extraArgs = m_submodulesCB->isChecked() ? QStringList{ "--recursive" }
: QStringList{};
m_command = vc->createInitialCheckoutCommand(m_repositoryCB->currentText(),
m_pathChooser->absoluteFilePath(),
m_directoryLE->text(), extraArgs);
const Utils::FilePath workingDirectory = m_pathChooser->absoluteFilePath();
m_command->setProgressiveOutput(true);
connect(m_command, &Utils::ShellCommand::stdOutText, this, [this](const QString &text) {
m_cloneOutput->appendPlainText(text);
});
connect(m_command, &Utils::ShellCommand::stdErrText, this, [this](const QString &text) {
m_cloneOutput->appendPlainText(text);
});
connect(m_command, &Utils::ShellCommand::finished, this, &GitLabCloneDialog::cloneFinished);
QApplication::setOverrideCursor(Qt::WaitCursor);
m_cloneOutput->clear();
m_cloneButton->setEnabled(false);
m_pathChooser->setReadOnly(true);
m_directoryLE->setReadOnly(true);
m_commandRunning = true;
m_command->execute();
}
void GitLabCloneDialog::cancel()
{
if (m_commandRunning) {
m_cloneOutput->appendPlainText(tr("User canceled process."));
m_cancelButton->setEnabled(false);
m_command->cancel(); // FIXME does not cancel the git processes... QTCREATORBUG-27567
} else {
reject();
}
}
static Utils::FilePaths scanDirectoryForFiles(const Utils::FilePath &directory)
{
Utils::FilePaths result;
const Utils::FilePaths entries = directory.dirEntries(QDir::AllEntries | QDir::NoDotAndDotDot);
for (const Utils::FilePath &entry : entries) {
if (entry.isDir())
result.append(scanDirectoryForFiles(entry));
else
result.append(entry);
}
return result;
}
void GitLabCloneDialog::cloneFinished(bool ok, int exitCode)
{
const bool success = (ok && exitCode == 0);
m_commandRunning = false;
delete m_command;
m_command = nullptr;
const QString emptyLine("\n\n");
m_cloneOutput->appendPlainText(emptyLine);
QApplication::restoreOverrideCursor();
if (success) {
m_cloneOutput->appendPlainText(tr("Cloning succeeded.") + emptyLine);
m_cloneButton->setEnabled(false);
const Utils::FilePath base = m_pathChooser->filePath().pathAppended(m_directoryLE->text());
Utils::FilePaths filesWeMayOpen
= Utils::filtered(scanDirectoryForFiles(base), [](const Utils::FilePath &f) {
return ProjectExplorer::ProjectManager::canOpenProjectForMimeType(
Utils::mimeTypeForFile(f));
});
// limit the files to the most top-level item(s)
int minimum = std::numeric_limits<int>::max();
for (const Utils::FilePath &f : filesWeMayOpen) {
int parentCount = f.toString().count('/');
if (parentCount < minimum)
minimum = parentCount;
}
filesWeMayOpen = Utils::filtered(filesWeMayOpen, [minimum](const Utils::FilePath &f) {
return f.toString().count('/') == minimum;
});
hide(); // avoid to many dialogs.. FIXME: maybe change to some wizard approach?
if (filesWeMayOpen.isEmpty()) {
QMessageBox::warning(this, tr("Warning"),
tr("Cloned project does not have a project file that can be "
"opened. Try importing the project as a generic project."));
accept();
} else {
const QStringList pFiles = Utils::transform(filesWeMayOpen,
[base](const Utils::FilePath &f) {
return f.relativePath(base).toUserOutput();
});
bool ok = false;
const QString fileToOpen
= QInputDialog::getItem(this, tr("Open Project"),
tr("Choose the project file to be opened."),
pFiles, 0, false, &ok);
accept();
if (ok && !fileToOpen.isEmpty())
ProjectExplorer::ProjectExplorerPlugin::openProject(base.pathAppended(fileToOpen));
}
} else {
m_cloneOutput->appendPlainText(tr("Cloning failed.") + emptyLine);
const Utils::FilePath fullPath = m_pathChooser->filePath()
.pathAppended(m_directoryLE->text());
fullPath.removeRecursively();
m_cloneButton->setEnabled(true);
m_cancelButton->setEnabled(true);
m_pathChooser->setReadOnly(false);
m_directoryLE->setReadOnly(false);
m_directoryLE->validate();
}
}
} // namespace GitLab

View File

@@ -0,0 +1,74 @@
/****************************************************************************
**
** Copyright (C) 2022 The Qt Company Ltd.
** Contact: https://www.qt.io/licensing/
**
** This file is part of Qt Creator.
**
** Commercial License Usage
** Licensees holding valid commercial Qt licenses may use this file in
** accordance with the commercial license agreement provided with the
** Software or, alternatively, in accordance with the terms contained in
** a written agreement between you and The Qt Company. For licensing terms
** and conditions see https://www.qt.io/terms-conditions. For further
** information use the contact form at https://www.qt.io/contact-us.
**
** GNU General Public License Usage
** Alternatively, this file may be used under the terms of the GNU
** General Public License version 3 as published by the Free Software
** Foundation with exceptions as appearing in the file LICENSE.GPL3-EXCEPT
** included in the packaging of this file. Please review the following
** information to ensure the GNU General Public License requirements will
** be met: https://www.gnu.org/licenses/gpl-3.0.html.
**
****************************************************************************/
#pragma once
#include <QCoreApplication>
#include <QDialog>
QT_BEGIN_NAMESPACE
class QCheckBox;
class QComboBox;
class QPlainTextEdit;
class QPushButton;
QT_END_NAMESPACE
namespace Core { class ShellCommand; }
namespace Utils {
class FancyLineEdit;
class InfoLabel;
class PathChooser;
}
namespace GitLab {
class Project;
class GitLabCloneDialog : public QDialog
{
Q_DECLARE_TR_FUNCTIONS(GitLab::GitLabCloneDialog)
public:
explicit GitLabCloneDialog(const Project &project, QWidget *parent = nullptr);
private:
void updateUi();
void cloneProject();
void cancel();
void cloneFinished(bool ok, int exitCode);
QComboBox * m_repositoryCB = nullptr;
QCheckBox *m_submodulesCB = nullptr;
QPushButton *m_cloneButton = nullptr;
QPushButton *m_cancelButton = nullptr;
QPlainTextEdit *m_cloneOutput = nullptr;
Utils::PathChooser *m_pathChooser = nullptr;
Utils::FancyLineEdit *m_directoryLE = nullptr;
Utils::InfoLabel *m_infoLabel = nullptr;
Core::ShellCommand *m_command = nullptr;
bool m_commandRunning = false;
};
} // namespace GitLab

View File

@@ -0,0 +1,294 @@
/****************************************************************************
**
** Copyright (C) 2022 The Qt Company Ltd.
** Contact: https://www.qt.io/licensing/
**
** This file is part of Qt Creator.
**
** Commercial License Usage
** Licensees holding valid commercial Qt licenses may use this file in
** accordance with the commercial license agreement provided with the
** Software or, alternatively, in accordance with the terms contained in
** a written agreement between you and The Qt Company. For licensing terms
** and conditions see https://www.qt.io/terms-conditions. For further
** information use the contact form at https://www.qt.io/contact-us.
**
** GNU General Public License Usage
** Alternatively, this file may be used under the terms of the GNU
** General Public License version 3 as published by the Free Software
** Foundation with exceptions as appearing in the file LICENSE.GPL3-EXCEPT
** included in the packaging of this file. Please review the following
** information to ensure the GNU General Public License requirements will
** be met: https://www.gnu.org/licenses/gpl-3.0.html.
**
****************************************************************************/
#include "gitlabdialog.h"
#include "gitlabclonedialog.h"
#include "gitlabparameters.h"
#include "gitlabplugin.h"
#include "gitlabprojectsettings.h"
#include <projectexplorer/session.h>
#include <texteditor/fontsettings.h>
#include <texteditor/texteditorsettings.h>
#include <utils/listmodel.h>
#include <utils/qtcassert.h>
#include <utils/utilsicons.h>
#include <QKeyEvent>
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
#include <QRegularExpression>
#include <QSyntaxHighlighter>
namespace GitLab {
GitLabDialog::GitLabDialog(QWidget *parent)
: QDialog(parent)
, m_lastTreeViewQuery(Query::NoQuery)
{
m_ui.setupUi(this);
m_clonePB = new QPushButton(Utils::Icons::DOWNLOAD.icon(), tr("Clone..."), this);
m_ui.buttonBox->addButton(m_clonePB, QDialogButtonBox::ActionRole);
m_clonePB->setEnabled(false);
updateRemotes();
connect(m_ui.remoteCB, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &GitLabDialog::requestMainViewUpdate);
connect(m_ui.searchLE, &QLineEdit::returnPressed, this, &GitLabDialog::querySearch);
connect(m_ui.searchPB, &QPushButton::clicked, this, &GitLabDialog::querySearch);
connect(m_clonePB, &QPushButton::clicked, this, &GitLabDialog::cloneSelected);
connect(m_ui.firstTB, &QToolButton::clicked, this, &GitLabDialog::queryFirstPage);
connect(m_ui.previousTB, &QToolButton::clicked, this, &GitLabDialog::queryPreviousPage);
connect(m_ui.nextTB, &QToolButton::clicked, this, &GitLabDialog::queryNextPage);
connect(m_ui.lastTB, &QToolButton::clicked, this, &GitLabDialog::queryLastPage);
requestMainViewUpdate();
}
void GitLabDialog::resetTreeView(QTreeView *treeView, QAbstractItemModel *model)
{
auto oldModel = treeView->model();
treeView->setModel(model);
delete oldModel;
if (model) {
connect(treeView->selectionModel(), &QItemSelectionModel::selectionChanged,
this, [this](const QItemSelection &selected, const QItemSelection &) {
m_clonePB->setEnabled(!selected.isEmpty());
});
m_clonePB->setEnabled(!treeView->selectionModel()->selectedIndexes().isEmpty());
}
}
void GitLabDialog::updateRemotes()
{
m_ui.remoteCB->clear();
const GitLabParameters *global = GitLabPlugin::globalParameters();
for (const GitLabServer &server : qAsConst(global->gitLabServers))
m_ui.remoteCB->addItem(server.displayString(), QVariant::fromValue(server));
m_ui.remoteCB->setCurrentIndex(m_ui.remoteCB->findData(
QVariant::fromValue(global->currentDefaultServer())));
}
void GitLabDialog::keyPressEvent(QKeyEvent *event)
{
if (event->key() == Qt::Key_Enter || event->key() == Qt::Key_Return)
return;
QDialog::keyPressEvent(event);
}
void GitLabDialog::requestMainViewUpdate()
{
m_lastPageInformation = PageInformation();
m_lastTreeViewQuery = Query(Query::NoQuery);
m_ui.mainLabel->setText({});
m_ui.detailsLabel->setText({});
m_ui.treeViewTitle->setText({});
m_ui.searchLE->setText({});
resetTreeView(m_ui.treeView, nullptr);
updatePageButtons();
bool linked = false;
m_currentServerId = Utils::Id();
if (auto project = ProjectExplorer::SessionManager::startupProject()) {
GitLabProjectSettings *projSettings = GitLabPlugin::projectSettings(project);
if (projSettings->isLinked()) {
m_currentServerId = projSettings->currentServer();
linked = true;
}
}
if (!m_currentServerId.isValid())
m_currentServerId = m_ui.remoteCB->currentData().value<GitLabServer>().id;
if (m_currentServerId.isValid()) {
const GitLabParameters *global = GitLabPlugin::globalParameters();
const GitLabServer server = global->serverForId(m_currentServerId);
m_ui.remoteCB->setCurrentIndex(m_ui.remoteCB->findData(QVariant::fromValue(server)));
}
m_ui.remoteCB->setEnabled(!linked);
const Query query(Query::User);
QueryRunner *runner = new QueryRunner(query, m_currentServerId, this);
connect(runner, &QueryRunner::resultRetrieved, this, [this](const QByteArray &result) {
handleUser(ResultParser::parseUser(result));
});
connect(runner, &QueryRunner::finished, [runner]() { runner->deleteLater(); });
runner->start();
}
void GitLabDialog::updatePageButtons()
{
if (m_lastPageInformation.currentPage == -1) {
m_ui.currentPage->setVisible(false);
m_ui.firstTB->setVisible(false);
m_ui.lastTB->setVisible(false);
m_ui.previousTB->setVisible(false);
m_ui.nextTB->setVisible(false);
} else {
m_ui.currentPage->setText(QString::number(m_lastPageInformation.currentPage));
m_ui.currentPage->setVisible(true);
m_ui.firstTB->setVisible(true);
m_ui.lastTB->setVisible(true);
}
if (m_lastPageInformation.currentPage > 1) {
m_ui.firstTB->setEnabled(true);
m_ui.previousTB->setText(QString::number(m_lastPageInformation.currentPage - 1));
m_ui.previousTB->setVisible(true);
} else {
m_ui.firstTB->setEnabled(false);
m_ui.previousTB->setVisible(false);
}
if (m_lastPageInformation.currentPage < m_lastPageInformation.totalPages) {
m_ui.lastTB->setEnabled(true);
m_ui.nextTB->setText(QString::number(m_lastPageInformation.currentPage + 1));
m_ui.nextTB->setVisible(true);
} else {
m_ui.lastTB->setEnabled(false);
m_ui.nextTB->setVisible(false);
}
}
void GitLabDialog::queryFirstPage()
{
QTC_ASSERT(m_lastTreeViewQuery.type() != Query::NoQuery, return);
QTC_ASSERT(m_lastPageInformation.currentPage != -1, return);
m_lastTreeViewQuery.setPageParameter(1);
fetchProjects();
}
void GitLabDialog::queryPreviousPage()
{
QTC_ASSERT(m_lastTreeViewQuery.type() != Query::NoQuery, return);
QTC_ASSERT(m_lastPageInformation.currentPage != -1, return);
m_lastTreeViewQuery.setPageParameter(m_lastPageInformation.currentPage - 1);
fetchProjects();
}
void GitLabDialog::queryNextPage()
{
QTC_ASSERT(m_lastTreeViewQuery.type() != Query::NoQuery, return);
QTC_ASSERT(m_lastPageInformation.currentPage != -1, return);
m_lastTreeViewQuery.setPageParameter(m_lastPageInformation.currentPage + 1);
fetchProjects();
}
void GitLabDialog::queryLastPage()
{
QTC_ASSERT(m_lastTreeViewQuery.type() != Query::NoQuery, return);
QTC_ASSERT(m_lastPageInformation.currentPage != -1, return);
m_lastTreeViewQuery.setPageParameter(m_lastPageInformation.totalPages);
fetchProjects();
}
void GitLabDialog::querySearch()
{
QTC_ASSERT(m_lastTreeViewQuery.type() != Query::NoQuery, return);
m_lastTreeViewQuery.setPageParameter(-1);
m_lastTreeViewQuery.setAdditionalParameters({"search=" + m_ui.searchLE->text()});
fetchProjects();
}
void GitLabDialog::handleUser(const User &user)
{
m_lastPageInformation = {};
m_currentUserId = user.id;
if (!user.error.message.isEmpty()) {
// TODO
if (user.error.code == 1) {
m_ui.mainLabel->setText(tr("Not logged in."));
m_ui.detailsLabel->setText(tr("Insufficient access token."));
m_ui.detailsLabel->setToolTip(user.error.message + QLatin1Char('\n')
+ tr("Permission scope read_api or api needed."));
updatePageButtons();
m_ui.treeViewTitle->setText(tr("Projects (%1)").arg(0));
return;
}
}
if (user.id != -1) {
if (user.bot) {
m_ui.mainLabel->setText(tr("Using project access token."));
m_ui.detailsLabel->setText({});
} else {
m_ui.mainLabel->setText(tr("Logged in as %1").arg(user.name));
m_ui.detailsLabel->setText(tr("Id: %1 (%2)").arg(user.id).arg(user.email));
}
m_ui.detailsLabel->setToolTip({});
} else {
m_ui.mainLabel->setText(tr("Not logged in."));
m_ui.detailsLabel->setText({});
m_ui.detailsLabel->setToolTip({});
}
m_lastTreeViewQuery = Query(Query::Projects);
fetchProjects();
}
void GitLabDialog::handleProjects(const Projects &projects)
{
Utils::ListModel<Project *> *listModel = new Utils::ListModel<Project *>(this);
for (const Project &project : projects.projects)
listModel->appendItem(new Project(project));
// TODO use a real model / delegate..?
listModel->setDataAccessor([](Project *data, int /*column*/, int role) -> QVariant {
if (role == Qt::DisplayRole)
return QString(data->displayName + " (" + data->visibility + ')');
if (role == Qt::UserRole)
return QVariant::fromValue(*data);
return QVariant();
});
resetTreeView(m_ui.treeView, listModel);
int count = projects.error.message.isEmpty() ? projects.pageInfo.total : 0;
m_ui.treeViewTitle->setText(tr("Projects (%1)").arg(count));
m_lastPageInformation = projects.pageInfo;
updatePageButtons();
}
void GitLabDialog::fetchProjects()
{
QueryRunner *runner = new QueryRunner(m_lastTreeViewQuery, m_currentServerId, this);
connect(runner, &QueryRunner::resultRetrieved, this, [this](const QByteArray &result) {
handleProjects(ResultParser::parseProjects(result));
});
connect(runner, &QueryRunner::finished, [runner]() { runner->deleteLater(); });
runner->start();
}
void GitLabDialog::cloneSelected()
{
const QModelIndexList indexes = m_ui.treeView->selectionModel()->selectedIndexes();
QTC_ASSERT(indexes.size() == 1, return);
const Project project = indexes.first().data(Qt::UserRole).value<Project>();
QTC_ASSERT(!project.sshUrl.isEmpty() && !project.httpUrl.isEmpty(), return);
GitLabCloneDialog dialog(project, this);
if (dialog.exec() == QDialog::Accepted)
reject();
}
} // namespace GitLab

View File

@@ -0,0 +1,81 @@
/****************************************************************************
**
** Copyright (C) 2022 The Qt Company Ltd.
** Contact: https://www.qt.io/licensing/
**
** This file is part of Qt Creator.
**
** Commercial License Usage
** Licensees holding valid commercial Qt licenses may use this file in
** accordance with the commercial license agreement provided with the
** Software or, alternatively, in accordance with the terms contained in
** a written agreement between you and The Qt Company. For licensing terms
** and conditions see https://www.qt.io/terms-conditions. For further
** information use the contact form at https://www.qt.io/contact-us.
**
** GNU General Public License Usage
** Alternatively, this file may be used under the terms of the GNU
** General Public License version 3 as published by the Free Software
** Foundation with exceptions as appearing in the file LICENSE.GPL3-EXCEPT
** included in the packaging of this file. Please review the following
** information to ensure the GNU General Public License requirements will
** be met: https://www.gnu.org/licenses/gpl-3.0.html.
**
****************************************************************************/
#pragma once
#include "ui_gitlabdialog.h"
#include "queryrunner.h"
#include "resultparser.h"
#include <utils/filepath.h>
#include <utils/id.h>
#include <QDialog>
QT_BEGIN_NAMESPACE
class QPushButton;
QT_END_NAMESPACE
namespace GitLab {
class GitLabParameters;
class GitLabDialog : public QDialog
{
Q_OBJECT
public:
explicit GitLabDialog(QWidget *parent = nullptr);
void updateRemotes();
protected:
void keyPressEvent(QKeyEvent *event) override;
private:
void resetTreeView(QTreeView *treeView, QAbstractItemModel *model);
void requestMainViewUpdate();
void updatePageButtons();
void queryFirstPage();
void queryPreviousPage();
void queryNextPage();
void queryLastPage();
void querySearch();
void continuePageUpdate();
void handleUser(const User &user);
void handleProjects(const Projects &projects);
void fetchProjects();
void cloneSelected();
Ui::GitLabDialog m_ui;
QPushButton *m_clonePB = nullptr;
Utils::Id m_currentServerId;
Query m_lastTreeViewQuery;
PageInformation m_lastPageInformation;
int m_currentUserId = -1;
};
} // namespace GitLab

View File

@@ -0,0 +1,268 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>GitLab::GitLabDialog</class>
<widget class="QDialog" name="GitLab::GitLabDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>665</width>
<height>530</height>
</rect>
</property>
<property name="windowTitle">
<string>GitLab</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_0">
<item>
<layout class="QVBoxLayout" name="verticalLayout_1">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_0">
<item>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QLabel" name="mainLabel">
<property name="text">
<string>Login</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="detailsLabel">
<property name="text">
<string>Details</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<spacer name="horizontalSpacer_0">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Remote:</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="remoteCB">
<property name="minimumSize">
<size>
<width>200</width>
<height>0</height>
</size>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout_5">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<widget class="QLabel" name="treeViewTitle">
<property name="text">
<string>Projects</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_1">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLineEdit" name="searchLE">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="placeholderText">
<string>Search</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="searchPB">
<property name="text">
<string>Search</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout_6">
<item>
<widget class="QTreeView" name="treeView">
<property name="rootIsDecorated">
<bool>false</bool>
</property>
<property name="uniformRowHeights">
<bool>true</bool>
</property>
<property name="itemsExpandable">
<bool>false</bool>
</property>
<property name="expandsOnDoubleClick">
<bool>false</bool>
</property>
<attribute name="headerVisible">
<bool>false</bool>
</attribute>
</widget>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_4">
<item>
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>200</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_5">
<item>
<widget class="QToolButton" name="firstTB">
<property name="text">
<string notr="true">|&lt;</string>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="previousTB">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="currentPage">
<property name="text">
<string>0</string>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="nextTB">
<property name="text">
<string>...</string>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="lastTB">
<property name="text">
<string notr="true">&gt;|</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>200</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Close</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>GitLab::GitLabDialog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -25,16 +25,27 @@
#include "gitlabplugin.h" #include "gitlabplugin.h"
#include "gitlabdialog.h"
#include "gitlaboptionspage.h" #include "gitlaboptionspage.h"
#include "gitlabparameters.h" #include "gitlabparameters.h"
#include "gitlabprojectsettings.h" #include "gitlabprojectsettings.h"
#include <coreplugin/actionmanager/actioncontainer.h>
#include <coreplugin/actionmanager/actionmanager.h>
#include <coreplugin/icore.h> #include <coreplugin/icore.h>
#include <git/gitplugin.h>
#include <projectexplorer/project.h> #include <projectexplorer/project.h>
#include <projectexplorer/projectpanelfactory.h> #include <projectexplorer/projectpanelfactory.h>
#include <utils/qtcassert.h> #include <utils/qtcassert.h>
#include <QAction>
#include <QMessageBox>
#include <QPointer>
namespace GitLab { namespace GitLab {
namespace Constants {
const char GITLAB_OPEN_VIEW[] = "GitLab.OpenView";
} // namespace Constants
class GitLabPluginPrivate class GitLabPluginPrivate
{ {
@@ -42,6 +53,7 @@ public:
GitLabParameters parameters; GitLabParameters parameters;
GitLabOptionsPage optionsPage{&parameters}; GitLabOptionsPage optionsPage{&parameters};
QHash<ProjectExplorer::Project *, GitLabProjectSettings *> projectSettings; QHash<ProjectExplorer::Project *, GitLabProjectSettings *> projectSettings;
QPointer<GitLabDialog> dialog;
}; };
static GitLabPluginPrivate *dd = nullptr; static GitLabPluginPrivate *dd = nullptr;
@@ -71,9 +83,42 @@ bool GitLabPlugin::initialize(const QStringList & /*arguments*/, QString * /*err
return new GitLabProjectSettingsWidget(project); return new GitLabProjectSettingsWidget(project);
}); });
ProjectExplorer::ProjectPanelFactory::registerFactory(panelFactory); ProjectExplorer::ProjectPanelFactory::registerFactory(panelFactory);
QAction *openViewAction = new QAction(tr("GitLab..."), this);
auto gitlabCommand = Core::ActionManager::registerAction(openViewAction,
Constants::GITLAB_OPEN_VIEW);
connect(openViewAction, &QAction::triggered, this, &GitLabPlugin::openView);
Core::ActionContainer *ac = Core::ActionManager::actionContainer(Core::Constants::M_TOOLS);
ac->addAction(gitlabCommand);
connect(&dd->optionsPage, &GitLabOptionsPage::settingsChanged, this, [this] {
if (dd->dialog)
dd->dialog->updateRemotes();
});
return true; return true;
} }
void GitLabPlugin::openView()
{
if (dd->dialog.isNull()) {
while (!dd->parameters.isValid()) {
QMessageBox::warning(Core::ICore::dialogParent(), tr("Error"),
tr("Invalid GitLab configuration. For a fully functional "
"configuration, you need to set up host name or address and "
"an access token. Providing the path to curl is mandatory."));
if (!Core::ICore::showOptionsDialog("GitLab"))
return;
}
GitLabDialog *gitlabD = new GitLabDialog(Core::ICore::dialogParent());
gitlabD->setModal(true);
Core::ICore::registerWindow(gitlabD, Core::Context("Git.GitLab"));
dd->dialog = gitlabD;
}
const Qt::WindowStates state = dd->dialog->windowState();
if (state & Qt::WindowMinimized)
dd->dialog->setWindowState(state & ~Qt::WindowMinimized);
dd->dialog->show();
dd->dialog->raise();
}
QList<GitLabServer> GitLabPlugin::allGitLabServers() QList<GitLabServer> GitLabPlugin::allGitLabServers()
{ {
QTC_ASSERT(dd, return {}); QTC_ASSERT(dd, return {});

View File

@@ -52,6 +52,9 @@ public:
static GitLabParameters *globalParameters(); static GitLabParameters *globalParameters();
static GitLabProjectSettings *projectSettings(ProjectExplorer::Project *project); static GitLabProjectSettings *projectSettings(ProjectExplorer::Project *project);
static GitLabOptionsPage *optionsPage(); static GitLabOptionsPage *optionsPage();
private:
void openView();
}; };
} // namespace GitLab } // namespace GitLab

View File

@@ -294,6 +294,7 @@ void GitLabProjectSettingsWidget::updateEnabledStates()
const bool isGitRepository = m_hostCB->count() > 0; const bool isGitRepository = m_hostCB->count() > 0;
const bool hasGitLabServers = m_linkedGitLabServer->count(); const bool hasGitLabServers = m_linkedGitLabServer->count();
const bool linked = m_projectSettings->isLinked(); const bool linked = m_projectSettings->isLinked();
m_linkedGitLabServer->setEnabled(isGitRepository && !linked); m_linkedGitLabServer->setEnabled(isGitRepository && !linked);
m_hostCB->setEnabled(isGitRepository && !linked); m_hostCB->setEnabled(isGitRepository && !linked);
m_linkWithGitLab->setEnabled(isGitRepository && !linked && hasGitLabServers); m_linkWithGitLab->setEnabled(isGitRepository && !linked && hasGitLabServers);

View File

@@ -41,6 +41,8 @@ namespace GitLab {
const char API_PREFIX[] = "/api/v4"; const char API_PREFIX[] = "/api/v4";
const char QUERY_PROJECT[] = "/projects/%1"; const char QUERY_PROJECT[] = "/projects/%1";
const char QUERY_PROJECTS[] = "/projects?simple=true";
const char QUERY_USER[] = "/user";
Query::Query(Type type, const QStringList &parameter) Query::Query(Type type, const QStringList &parameter)
: m_type(type) : m_type(type)
@@ -48,6 +50,21 @@ Query::Query(Type type, const QStringList &parameter)
{ {
} }
void Query::setPageParameter(int page)
{
m_pageParameter = page;
}
void Query::setAdditionalParameters(const QStringList &additional)
{
m_additionalParameters = additional;
}
bool Query::hasPaginatedResults() const
{
return m_type == Query::Projects;
}
QString Query::toString() const QString Query::toString() const
{ {
QString query = API_PREFIX; QString query = API_PREFIX;
@@ -59,6 +76,20 @@ QString Query::toString() const
query += QLatin1String(QUERY_PROJECT).arg(QLatin1String( query += QLatin1String(QUERY_PROJECT).arg(QLatin1String(
QUrl::toPercentEncoding(m_parameter.at(0)))); QUrl::toPercentEncoding(m_parameter.at(0))));
break; break;
case Query::Projects:
query += QLatin1String(QUERY_PROJECTS);
break;
case Query::User:
query += QUERY_USER;
break;
}
if (m_pageParameter > 0) {
query.append(m_type == Query::Projects ? '&' : '?');
query.append("page=").append(QString::number(m_pageParameter));
}
if (!m_additionalParameters.isEmpty()) {
query.append((m_type == Query::Projects || m_pageParameter > 0) ? '&' : '?');
query.append(m_additionalParameters.join('&'));
} }
return query; return query;
} }
@@ -69,6 +100,9 @@ QueryRunner::QueryRunner(const Query &query, const Utils::Id &id, QObject *paren
const GitLabParameters *p = GitLabPlugin::globalParameters(); const GitLabParameters *p = GitLabPlugin::globalParameters();
const auto server = p->serverForId(id); const auto server = p->serverForId(id);
QStringList args = server.curlArguments(); QStringList args = server.curlArguments();
m_paginated = query.hasPaginatedResults();
if (m_paginated)
args << "-i";
if (!server.token.isEmpty()) if (!server.token.isEmpty())
args << "--header" << "PRIVATE-TOKEN: " + server.token; args << "--header" << "PRIVATE-TOKEN: " + server.token;
QString url = "https://" + server.host; QString url = "https://" + server.host;

View File

@@ -38,15 +38,23 @@ class Query
public: public:
enum Type { enum Type {
NoQuery, NoQuery,
Project User,
Project,
Projects
}; };
explicit Query(Type type, const QStringList &parameters = {}); explicit Query(Type type, const QStringList &parameters = {});
void setPageParameter(int page);
void setAdditionalParameters(const QStringList &additional);
bool hasPaginatedResults() const;
Type type() const { return m_type; }
QString toString() const; QString toString() const;
private: private:
Type m_type = NoQuery; Type m_type = NoQuery;
QStringList m_parameter; QStringList m_parameter;
QStringList m_additionalParameters;
int m_pageParameter = -1;
}; };
class QueryRunner : public QObject class QueryRunner : public QObject
@@ -70,6 +78,7 @@ private:
Utils::QtcProcess m_process; Utils::QtcProcess m_process;
bool m_running = false; bool m_running = false;
bool m_paginated = false;
}; };
} // namespace GitLab } // namespace GitLab

View File

@@ -34,6 +34,38 @@
namespace GitLab { namespace GitLab {
namespace ResultParser { namespace ResultParser {
static PageInformation paginationInformation(const QByteArray &header)
{
PageInformation result;
const QByteArrayList lines = header.split('\n');
for (const QByteArray &line : lines) {
const QByteArray lower = line.toLower(); // depending on OS this may be capitalized
if (lower.startsWith("x-page: "))
result.currentPage = line.mid(8).toInt();
else if (lower.startsWith("x-per-page: "))
result.perPage = line.mid(12).toInt();
else if (lower.startsWith("x-total: "))
result.total = line.mid(9).toInt();
else if (lower.startsWith("x-total-pages: "))
result.totalPages = line.mid(15).toInt();
}
return result;
}
static std::pair<QByteArray, QByteArray> splitHeaderAndBody(const QByteArray &input)
{
QByteArray header;
QByteArray json;
int emptyLine = input.indexOf("\r\n\r\n"); // we always get \r\n as line separator?
if (emptyLine != -1) {
header = input.left(emptyLine);
json = input.mid(emptyLine + 4);
} else {
json = input;
}
return std::make_pair(header, json);
}
static std::pair<Error, QJsonObject> preHandleSingle(const QByteArray &json) static std::pair<Error, QJsonObject> preHandleSingle(const QByteArray &json)
{ {
Error result; Error result;
@@ -59,6 +91,52 @@ static std::pair<Error, QJsonObject> preHandleSingle(const QByteArray &json)
return std::make_pair(result, object); return std::make_pair(result, object);
} }
static std::pair<Error, QJsonDocument> preHandleHeaderAndBody(const QByteArray &header,
const QByteArray &json)
{
Error result;
if (header.isEmpty()) {
result.message = "Missing Expected Header";
return std::make_pair(result, QJsonDocument());
}
QJsonParseError error;
const QJsonDocument doc = QJsonDocument::fromJson(json, &error);
if (error.error != QJsonParseError::NoError) {
result.message = error.errorString();
return std::make_pair(result, doc);
}
if (doc.isObject()) {
const QJsonObject obj = doc.object();
if (obj.contains("message")) {
result = parseErrorMessage(obj.value("message").toString());
return std::make_pair(result, doc);
} else if (obj.contains("error")) {
if (obj.value("error").toString() == "insufficient_scope")
result.code = 1;
result.message = obj.value("error_description").toString();
return std::make_pair(result, doc);
}
}
if (!doc.isArray())
result.message = "Not an Array";
return std::make_pair(result, doc);
}
static User userFromJson(const QJsonObject &jsonObj)
{
User user;
user.name = jsonObj.value("username").toString();
user.realname = jsonObj.value("name").toString();
user.id = jsonObj.value("id").toInt(-1);
user.email = jsonObj.value("email").toString();
user.bot = jsonObj.value("bot").toBool();
return user;
}
static Project projectFromJson(const QJsonObject &jsonObj) static Project projectFromJson(const QJsonObject &jsonObj)
{ {
Project project; Project project;
@@ -67,6 +145,8 @@ static Project projectFromJson(const QJsonObject &jsonObj)
project.pathName = jsonObj.value("path_with_namespace").toString(); project.pathName = jsonObj.value("path_with_namespace").toString();
project.id = jsonObj.value("id").toInt(-1); project.id = jsonObj.value("id").toInt(-1);
project.visibility = jsonObj.value("visibility").toString("public"); project.visibility = jsonObj.value("visibility").toString("public");
project.httpUrl = jsonObj.value("http_url_to_repo").toString();
project.sshUrl = jsonObj.value("ssh_url_to_repo").toString();
if (jsonObj.contains("forks_count")) if (jsonObj.contains("forks_count"))
project.forkCount = jsonObj.value("forks_count").toInt(); project.forkCount = jsonObj.value("forks_count").toInt();
if (jsonObj.contains("star_count")) if (jsonObj.contains("star_count"))
@@ -82,6 +162,17 @@ static Project projectFromJson(const QJsonObject &jsonObj)
return project; return project;
} }
User parseUser(const QByteArray &input)
{
auto [error, userObj] = preHandleSingle(input);
if (!error.message.isEmpty()) {
User result;
result.error = error;
return result;
}
return userFromJson(userObj);
}
Project parseProject(const QByteArray &input) Project parseProject(const QByteArray &input)
{ {
auto [error, projectObj] = preHandleSingle(input); auto [error, projectObj] = preHandleSingle(input);
@@ -93,6 +184,26 @@ Project parseProject(const QByteArray &input)
return projectFromJson(projectObj); return projectFromJson(projectObj);
} }
Projects parseProjects(const QByteArray &input)
{
auto [header, json] = splitHeaderAndBody(input);
auto [error, jsonDoc] = preHandleHeaderAndBody(header, json);
Projects result;
if (!error.message.isEmpty()) {
result.error = error;
return result;
}
result.pageInfo = paginationInformation(header);
const QJsonArray projectsArray = jsonDoc.array();
for (const QJsonValue &value : projectsArray) {
if (!value.isObject())
continue;
const QJsonObject projectObj = value.toObject();
result.projects.append(projectFromJson(projectObj));
}
return result;
}
Error parseErrorMessage(const QString &message) Error parseErrorMessage(const QString &message)
{ {
Error error; Error error;

View File

@@ -25,6 +25,8 @@
#pragma once #pragma once
#include <QList>
#include <QMetaType>
#include <QString> #include <QString>
namespace GitLab { namespace GitLab {
@@ -35,6 +37,26 @@ struct Error
QString message; QString message;
}; };
class PageInformation
{
public:
int currentPage = -1;
int totalPages = -1;
int perPage = -1;
int total = -1;
};
class User
{
public:
QString name;
QString realname;
QString email;
Error error;
int id = -1;
bool bot = false;
};
class Project class Project
{ {
public: public:
@@ -42,18 +64,32 @@ public:
QString displayName; QString displayName;
QString pathName; QString pathName;
QString visibility; QString visibility;
QString httpUrl;
QString sshUrl;
Error error; Error error;
int id = -1; int id = -1;
int starCount = -1; int starCount = -1;
int forkCount = -1; int forkCount = -1;
int issuesCount = -1; int issuesCount = -1;
int accessLevel = -1; // 40 maintainer, 30 developer, 20 reporter, 10 guest int accessLevel = -1; // 50 owner, 40 maintainer, 30 developer, 20 reporter, 10 guest
};
class Projects
{
public:
QList<Project> projects;
Error error;
PageInformation pageInfo;
}; };
namespace ResultParser { namespace ResultParser {
User parseUser(const QByteArray &input);
Project parseProject(const QByteArray &input); Project parseProject(const QByteArray &input);
Projects parseProjects(const QByteArray &input);
Error parseErrorMessage(const QString &message); Error parseErrorMessage(const QString &message);
} // namespace ResultParser } // namespace ResultParser
} // namespace GitLab } // namespace GitLab
Q_DECLARE_METATYPE(GitLab::Project)