diff --git a/src/plugins/gitlab/CMakeLists.txt b/src/plugins/gitlab/CMakeLists.txt index 4e45c77bb71..7bde1d67560 100644 --- a/src/plugins/gitlab/CMakeLists.txt +++ b/src/plugins/gitlab/CMakeLists.txt @@ -3,6 +3,8 @@ add_qtc_plugin(GitLab PLUGIN_DEPENDS Core ProjectExplorer Git VcsBase DEPENDS Utils SOURCES + gitlabclonedialog.cpp gitlabclonedialog.h + gitlabdialog.cpp gitlabdialog.h gitlabdialog.ui gitlaboptionspage.cpp gitlaboptionspage.h gitlabparameters.cpp gitlabparameters.h gitlabplugin.cpp gitlabplugin.h diff --git a/src/plugins/gitlab/gitlab.qbs b/src/plugins/gitlab/gitlab.qbs index 03a06ec5cc9..96076b85d1a 100644 --- a/src/plugins/gitlab/gitlab.qbs +++ b/src/plugins/gitlab/gitlab.qbs @@ -10,6 +10,11 @@ QtcPlugin { Depends { name: "Utils" } files: [ + "gitlabclonedialog.cpp", + "gitlabclonedialog.h", + "gitlabdialog.cpp", + "gitlabdialog.h", + "gitlabdialog.ui", "gitlaboptionspage.cpp", "gitlaboptionspage.h", "gitlabparameters.cpp", diff --git a/src/plugins/gitlab/gitlabclonedialog.cpp b/src/plugins/gitlab/gitlabclonedialog.cpp new file mode 100644 index 00000000000..e1dabccc28a --- /dev/null +++ b/src/plugins/gitlab/gitlabclonedialog.cpp @@ -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 +#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 + +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::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 diff --git a/src/plugins/gitlab/gitlabclonedialog.h b/src/plugins/gitlab/gitlabclonedialog.h new file mode 100644 index 00000000000..5d3b19543aa --- /dev/null +++ b/src/plugins/gitlab/gitlabclonedialog.h @@ -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 +#include + +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 diff --git a/src/plugins/gitlab/gitlabdialog.cpp b/src/plugins/gitlab/gitlabdialog.cpp new file mode 100644 index 00000000000..6af65fef2dc --- /dev/null +++ b/src/plugins/gitlab/gitlabdialog.cpp @@ -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 +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +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::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().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 *listModel = new Utils::ListModel(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(); + QTC_ASSERT(!project.sshUrl.isEmpty() && !project.httpUrl.isEmpty(), return); + GitLabCloneDialog dialog(project, this); + if (dialog.exec() == QDialog::Accepted) + reject(); +} + +} // namespace GitLab diff --git a/src/plugins/gitlab/gitlabdialog.h b/src/plugins/gitlab/gitlabdialog.h new file mode 100644 index 00000000000..907d786f474 --- /dev/null +++ b/src/plugins/gitlab/gitlabdialog.h @@ -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 +#include + +#include + +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 diff --git a/src/plugins/gitlab/gitlabdialog.ui b/src/plugins/gitlab/gitlabdialog.ui new file mode 100644 index 00000000000..2b4377b1263 --- /dev/null +++ b/src/plugins/gitlab/gitlabdialog.ui @@ -0,0 +1,268 @@ + + + GitLab::GitLabDialog + + + + 0 + 0 + 665 + 530 + + + + GitLab + + + + + + + + + + + + Login + + + + + + + Details + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Remote: + + + + + + + + 200 + 0 + + + + + + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 40 + + + + + + + + + + + + Projects + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + Search + + + + + + + Search + + + + + + + + + + + false + + + true + + + false + + + false + + + false + + + + + + + + + + + + + + + + + + + Qt::Horizontal + + + + 200 + 20 + + + + + + + + + + |< + + + + + + + ... + + + + + + + 0 + + + + + + + ... + + + + + + + >| + + + + + + + + + Qt::Horizontal + + + + 200 + 20 + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Close + + + + + + + + + buttonBox + rejected() + GitLab::GitLabDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/plugins/gitlab/gitlabplugin.cpp b/src/plugins/gitlab/gitlabplugin.cpp index 1f45fe97d1d..2a4dd7c69de 100644 --- a/src/plugins/gitlab/gitlabplugin.cpp +++ b/src/plugins/gitlab/gitlabplugin.cpp @@ -25,16 +25,27 @@ #include "gitlabplugin.h" +#include "gitlabdialog.h" #include "gitlaboptionspage.h" #include "gitlabparameters.h" #include "gitlabprojectsettings.h" +#include +#include #include +#include #include #include #include +#include +#include +#include + namespace GitLab { +namespace Constants { +const char GITLAB_OPEN_VIEW[] = "GitLab.OpenView"; +} // namespace Constants class GitLabPluginPrivate { @@ -42,6 +53,7 @@ public: GitLabParameters parameters; GitLabOptionsPage optionsPage{¶meters}; QHash projectSettings; + QPointer dialog; }; static GitLabPluginPrivate *dd = nullptr; @@ -71,9 +83,42 @@ bool GitLabPlugin::initialize(const QStringList & /*arguments*/, QString * /*err return new GitLabProjectSettingsWidget(project); }); 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; } +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 GitLabPlugin::allGitLabServers() { QTC_ASSERT(dd, return {}); diff --git a/src/plugins/gitlab/gitlabplugin.h b/src/plugins/gitlab/gitlabplugin.h index 7d578120f9f..14368ef29fc 100644 --- a/src/plugins/gitlab/gitlabplugin.h +++ b/src/plugins/gitlab/gitlabplugin.h @@ -52,6 +52,9 @@ public: static GitLabParameters *globalParameters(); static GitLabProjectSettings *projectSettings(ProjectExplorer::Project *project); static GitLabOptionsPage *optionsPage(); + +private: + void openView(); }; } // namespace GitLab diff --git a/src/plugins/gitlab/gitlabprojectsettings.cpp b/src/plugins/gitlab/gitlabprojectsettings.cpp index 7088131c409..22921e229de 100644 --- a/src/plugins/gitlab/gitlabprojectsettings.cpp +++ b/src/plugins/gitlab/gitlabprojectsettings.cpp @@ -294,6 +294,7 @@ void GitLabProjectSettingsWidget::updateEnabledStates() const bool isGitRepository = m_hostCB->count() > 0; const bool hasGitLabServers = m_linkedGitLabServer->count(); const bool linked = m_projectSettings->isLinked(); + m_linkedGitLabServer->setEnabled(isGitRepository && !linked); m_hostCB->setEnabled(isGitRepository && !linked); m_linkWithGitLab->setEnabled(isGitRepository && !linked && hasGitLabServers); diff --git a/src/plugins/gitlab/queryrunner.cpp b/src/plugins/gitlab/queryrunner.cpp index b680374dbd1..e0369d78c52 100644 --- a/src/plugins/gitlab/queryrunner.cpp +++ b/src/plugins/gitlab/queryrunner.cpp @@ -41,6 +41,8 @@ namespace GitLab { const char API_PREFIX[] = "/api/v4"; const char QUERY_PROJECT[] = "/projects/%1"; +const char QUERY_PROJECTS[] = "/projects?simple=true"; +const char QUERY_USER[] = "/user"; Query::Query(Type type, const QStringList ¶meter) : m_type(type) @@ -48,6 +50,21 @@ Query::Query(Type type, const QStringList ¶meter) { } +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 = API_PREFIX; @@ -59,6 +76,20 @@ QString Query::toString() const query += QLatin1String(QUERY_PROJECT).arg(QLatin1String( QUrl::toPercentEncoding(m_parameter.at(0)))); 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; } @@ -69,6 +100,9 @@ QueryRunner::QueryRunner(const Query &query, const Utils::Id &id, QObject *paren const GitLabParameters *p = GitLabPlugin::globalParameters(); const auto server = p->serverForId(id); QStringList args = server.curlArguments(); + m_paginated = query.hasPaginatedResults(); + if (m_paginated) + args << "-i"; if (!server.token.isEmpty()) args << "--header" << "PRIVATE-TOKEN: " + server.token; QString url = "https://" + server.host; diff --git a/src/plugins/gitlab/queryrunner.h b/src/plugins/gitlab/queryrunner.h index c0fa801b0fd..a431921ab72 100644 --- a/src/plugins/gitlab/queryrunner.h +++ b/src/plugins/gitlab/queryrunner.h @@ -38,15 +38,23 @@ class Query public: enum Type { NoQuery, - Project + User, + Project, + Projects }; explicit Query(Type type, const QStringList ¶meters = {}); + void setPageParameter(int page); + void setAdditionalParameters(const QStringList &additional); + bool hasPaginatedResults() const; + Type type() const { return m_type; } QString toString() const; private: Type m_type = NoQuery; QStringList m_parameter; + QStringList m_additionalParameters; + int m_pageParameter = -1; }; class QueryRunner : public QObject @@ -70,6 +78,7 @@ private: Utils::QtcProcess m_process; bool m_running = false; + bool m_paginated = false; }; } // namespace GitLab diff --git a/src/plugins/gitlab/resultparser.cpp b/src/plugins/gitlab/resultparser.cpp index bd857c2e865..7fb5db8d2ff 100644 --- a/src/plugins/gitlab/resultparser.cpp +++ b/src/plugins/gitlab/resultparser.cpp @@ -34,6 +34,38 @@ namespace GitLab { 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 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 preHandleSingle(const QByteArray &json) { Error result; @@ -59,6 +91,52 @@ static std::pair preHandleSingle(const QByteArray &json) return std::make_pair(result, object); } +static std::pair 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) { Project project; @@ -67,6 +145,8 @@ static Project projectFromJson(const QJsonObject &jsonObj) project.pathName = jsonObj.value("path_with_namespace").toString(); project.id = jsonObj.value("id").toInt(-1); 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")) project.forkCount = jsonObj.value("forks_count").toInt(); if (jsonObj.contains("star_count")) @@ -82,6 +162,17 @@ static Project projectFromJson(const QJsonObject &jsonObj) 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) { auto [error, projectObj] = preHandleSingle(input); @@ -93,6 +184,26 @@ Project parseProject(const QByteArray &input) 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 error; diff --git a/src/plugins/gitlab/resultparser.h b/src/plugins/gitlab/resultparser.h index 7a99bc14a3e..7c71b5c6d18 100644 --- a/src/plugins/gitlab/resultparser.h +++ b/src/plugins/gitlab/resultparser.h @@ -25,6 +25,8 @@ #pragma once +#include +#include #include namespace GitLab { @@ -35,6 +37,26 @@ struct Error 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 { public: @@ -42,18 +64,32 @@ public: QString displayName; QString pathName; QString visibility; + QString httpUrl; + QString sshUrl; Error error; int id = -1; int starCount = -1; int forkCount = -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 projects; + Error error; + PageInformation pageInfo; }; namespace ResultParser { +User parseUser(const QByteArray &input); Project parseProject(const QByteArray &input); +Projects parseProjects(const QByteArray &input); Error parseErrorMessage(const QString &message); } // namespace ResultParser } // namespace GitLab + +Q_DECLARE_METATYPE(GitLab::Project)