Files
QodeAssist/settings/ProvidersSettingsPage.cpp
2026-05-26 12:31:00 +02:00

618 lines
22 KiB
C++

// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ProvidersSettingsPage.hpp"
#include <algorithm>
#include <vector>
#include <coreplugin/dialogs/ioptionspage.h>
#include <coreplugin/editormanager/editormanager.h>
#include <coreplugin/icore.h>
#include <utils/filepath.h>
#include <QDialog>
#include <QFile>
#include <QFileInfo>
#include <QFrame>
#include <QHBoxLayout>
#include <QInputDialog>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPointer>
#include <QPushButton>
#include <QScrollArea>
#include <QSplitter>
#include <QTimer>
#include <QVBoxLayout>
#include "ProviderDetailPane.hpp"
#include "ProviderInstance.hpp"
#include "ProviderInstanceFactory.hpp"
#include "ProviderInstanceWriter.hpp"
#include "ProviderLauncher.hpp"
#include "ProviderListItem.hpp"
#include "ProviderSecretsStore.hpp"
#include "SettingsConstants.hpp"
#include "SettingsTheme.hpp"
namespace QodeAssist::Settings {
ProvidersPageNavigator::ProvidersPageNavigator(QObject *parent)
: QObject(parent)
{}
void ProvidersPageNavigator::requestSelectInstance(const QString &name)
{
m_pending = name;
emit selectInstanceRequested(name);
}
QString ProvidersPageNavigator::takePendingSelection()
{
QString p = m_pending;
m_pending.clear();
return p;
}
namespace {
class ProvidersPageWidget : public Core::IOptionsPageWidget
{
Q_OBJECT
public:
ProvidersPageWidget(
Providers::ProviderInstanceFactory *factory,
Providers::ProviderSecretsStore *secrets,
Providers::ProviderLauncher *launcher,
ProvidersPageNavigator *navigator)
: m_factory(factory)
, m_secrets(secrets)
, m_launcher(launcher)
, m_navigator(navigator)
{
m_titleLabel = new QLabel(tr("Providers"), this);
QFont tf = m_titleLabel->font();
tf.setBold(true);
tf.setPixelSize(13);
m_titleLabel->setFont(tf);
auto *headerRow = new QHBoxLayout;
headerRow->setContentsMargins(0, 0, 0, 0);
headerRow->setSpacing(8);
headerRow->addWidget(m_titleLabel, 1);
auto *headerSep = new QFrame(this);
headerSep->setFrameShape(QFrame::HLine);
headerSep->setFrameShadow(QFrame::Sunken);
m_filterEdit = new QLineEdit(this);
m_filterEdit->setPlaceholderText(tr("Filter providers…"));
m_listScroll = new QScrollArea(this);
m_listScroll->setWidgetResizable(true);
m_listScroll->setFrameShape(QFrame::NoFrame);
m_listContent = new QWidget(this);
m_listLayout = new QVBoxLayout(m_listContent);
m_listLayout->setContentsMargins(0, 0, 0, 0);
m_listLayout->setSpacing(0);
m_listLayout->addStretch(1);
m_listScroll->setWidget(m_listContent);
auto *leftBox = new QFrame(this);
leftBox->setFrameShape(QFrame::StyledPanel);
auto *leftLay = new QVBoxLayout(leftBox);
leftLay->setContentsMargins(0, 0, 0, 0);
leftLay->setSpacing(0);
auto *filterRow = new QHBoxLayout;
filterRow->setContentsMargins(6, 6, 6, 6);
filterRow->addWidget(m_filterEdit, 1);
leftLay->addLayout(filterRow);
leftLay->addWidget(m_listScroll, 1);
m_detailPane = new ProviderDetailPane(this);
connect(m_detailPane, &ProviderDetailPane::saveRequested,
this, &ProvidersPageWidget::onSaveEdited);
connect(m_detailPane, &ProviderDetailPane::duplicateRequested,
this, &ProvidersPageWidget::onDuplicateClicked);
connect(m_detailPane, &ProviderDetailPane::deleteRequested,
this, &ProvidersPageWidget::onRemoveClicked);
connect(m_detailPane, &ProviderDetailPane::apiKeySaveRequested,
this, &ProvidersPageWidget::onApiKeySave);
connect(m_detailPane, &ProviderDetailPane::apiKeyClearRequested,
this, &ProvidersPageWidget::onApiKeyClear);
connect(m_detailPane, &ProviderDetailPane::launchStartRequested,
this, &ProvidersPageWidget::onLaunchStart);
connect(m_detailPane, &ProviderDetailPane::launchStopRequested,
this, &ProvidersPageWidget::onLaunchStop);
connect(m_detailPane, &ProviderDetailPane::launchRestartRequested,
this, &ProvidersPageWidget::onLaunchRestart);
connect(m_detailPane, &ProviderDetailPane::openInEditorRequested,
this, [this](const QString &path) {
if (path.isEmpty() || path.startsWith(QLatin1String(":/"))) {
QMessageBox::information(
this, tr("Open in editor"),
tr("Bundled providers are read-only. "
"Use Duplicate to create an editable user copy first."));
return;
}
Core::EditorManager::openEditor(Utils::FilePath::fromString(path));
});
if (m_launcher) {
connect(m_launcher.data(), &Providers::ProviderLauncher::stateChanged,
this, [this](const QString &name,
Providers::ProviderLauncher::State newState) {
if (name == m_currentName)
refreshDetailLaunch();
const ProviderListItem::Status status = rowStatusFromState(newState);
for (auto *row : m_rows) {
if (row->providerName() == name)
row->setStatus(status);
}
});
connect(m_launcher.data(), &Providers::ProviderLauncher::bytesReceived,
this, [this](const QString &name, const QByteArray &chunk) {
if (name == m_currentName)
m_detailPane->appendLaunchBytes(chunk);
});
}
m_detailScroll = new QScrollArea(this);
m_detailScroll->setWidgetResizable(true);
m_detailScroll->setFrameShape(QFrame::StyledPanel);
m_detailScroll->setWidget(m_detailPane);
auto *splitter = new QSplitter(Qt::Horizontal, this);
splitter->addWidget(leftBox);
splitter->addWidget(m_detailScroll);
splitter->setStretchFactor(0, 0);
splitter->setStretchFactor(1, 1);
splitter->setSizes({320, 700});
auto *root = new QVBoxLayout(this);
root->setContentsMargins(8, 8, 8, 8);
root->setSpacing(6);
root->addLayout(headerRow);
root->addWidget(headerSep);
root->addWidget(splitter, 1);
m_filterDebounce = new QTimer(this);
m_filterDebounce->setSingleShot(true);
m_filterDebounce->setInterval(100);
connect(m_filterDebounce, &QTimer::timeout, this,
&ProvidersPageWidget::rebuildList);
connect(m_filterEdit, &QLineEdit::textChanged, this,
[this](const QString &) { m_filterDebounce->start(); });
if (m_factory) {
connect(m_factory.data(),
&Providers::ProviderInstanceFactory::instancesReloaded,
this, &ProvidersPageWidget::rebuildList);
}
if (m_navigator) {
connect(m_navigator.data(),
&ProvidersPageNavigator::selectInstanceRequested,
this, &ProvidersPageWidget::selectInstance);
}
rebuildList();
const QString pending
= m_navigator ? m_navigator->takePendingSelection() : QString{};
if (!pending.isEmpty())
selectInstance(pending);
else if (m_factory && !m_factory->instances().empty())
selectInstance(m_factory->instances().front().name);
}
void apply() final {}
private slots:
void rebuildList()
{
if (!m_factory)
return;
while (m_listLayout->count() > 0) {
QLayoutItem *item = m_listLayout->takeAt(0);
if (auto *w = item->widget())
w->deleteLater();
delete item;
}
m_rows.clear();
m_listLayout->addStretch(1); // re-add trailing stretch
const QString filter = m_filterEdit->text().trimmed().toLower();
auto matches = [&](const Providers::ProviderInstance &inst) {
if (filter.isEmpty())
return true;
return inst.name.toLower().contains(filter)
|| inst.clientApi.toLower().contains(filter)
|| inst.url.toLower().contains(filter);
};
auto addSection = [&](const QString &title, bool userSection) {
auto *header = new QLabel(title.toUpper(), m_listContent);
QFont hf = header->font();
hf.setPixelSize(10);
hf.setLetterSpacing(QFont::AbsoluteSpacing, 0.5);
header->setFont(hf);
QPalette hp = header->palette();
hp.setColor(QPalette::WindowText, hp.color(QPalette::Mid));
header->setPalette(hp);
header->setContentsMargins(8, 4, 8, 4);
header->setAutoFillBackground(true);
header->setStyleSheet(
QStringLiteral("QLabel { background:%1; }")
.arg(themeFor(palette()).listHeaderBg));
m_listLayout->insertWidget(m_listLayout->count() - 1, header);
std::vector<const Providers::ProviderInstance *> sorted;
for (const auto &inst : m_factory->instances()) {
if (inst.isUserSource() != userSection)
continue;
if (!matches(inst))
continue;
sorted.push_back(&inst);
}
std::sort(sorted.begin(), sorted.end(),
[](const Providers::ProviderInstance *a,
const Providers::ProviderInstance *b) {
return a->name.compare(b->name, Qt::CaseInsensitive) < 0;
});
int shown = 0;
for (const auto *inst : sorted) {
auto *row = new ProviderListItem(*inst, m_listContent);
connect(row, &ProviderListItem::clicked,
this, &ProvidersPageWidget::selectInstance);
if (m_launcher)
row->setStatus(rowStatusFromLauncher(inst->name));
m_rows.append(row);
m_listLayout->insertWidget(m_listLayout->count() - 1, row);
++shown;
}
if (shown == 0) {
auto *empty = new QLabel(
userSection ? tr("No user instances yet.")
: tr("No bundled instances loaded."),
m_listContent);
empty->setContentsMargins(10, 6, 10, 6);
QPalette ep = empty->palette();
ep.setColor(QPalette::WindowText, ep.color(QPalette::Mid));
empty->setPalette(ep);
m_listLayout->insertWidget(m_listLayout->count() - 1, empty);
}
};
addSection(tr("User"), true);
addSection(tr("Bundled"), false);
for (auto *row : m_rows)
row->setSelected(row->providerName() == m_currentName);
if (!m_currentName.isEmpty())
populateDetail(m_currentName);
else
m_detailPane->clear();
}
void selectInstance(const QString &name)
{
if (name.isEmpty())
return;
const auto *inst = m_factory ? m_factory->instanceByName(name) : nullptr;
if (!inst)
return;
m_currentName = inst->name;
for (auto *row : m_rows)
row->setSelected(row->providerName() == inst->name);
populateDetail(inst->name);
}
void onDuplicateClicked()
{
if (!m_factory || m_currentName.isEmpty())
return;
const Providers::ProviderInstance *srcPtr
= m_factory->instanceByName(m_currentName);
if (!srcPtr)
return;
const Providers::ProviderInstance srcCopy = *srcPtr;
bool ok = false;
const QString name = QInputDialog::getText(
this, tr("Duplicate provider"),
tr("Name for the new provider:"), QLineEdit::Normal,
QStringLiteral("%1 (copy)").arg(srcCopy.name), &ok);
if (!ok || name.trimmed().isEmpty())
return;
if (m_factory->instanceByName(name.trimmed())) {
QMessageBox::warning(this, tr("Duplicate provider"),
tr("An instance named '%1' already exists.").arg(name.trimmed()));
return;
}
Providers::ProviderInstance copy = srcCopy;
copy.name = name.trimmed();
copy.apiKeyRef = QStringLiteral("qodeassist/providers/%1").arg(copy.name);
copy.sourcePath.clear();
copy.overridesBundled = false;
QString writeErr;
if (Providers::ProviderInstanceWriter::writeToUserDir(
copy, /*previousPath=*/QString{}, &writeErr).isEmpty()) {
QMessageBox::warning(this, tr("Duplicate provider"), writeErr);
return;
}
m_factory->reload();
selectInstance(copy.name);
}
void onRemoveClicked()
{
if (!m_factory || m_currentName.isEmpty())
return;
const Providers::ProviderInstance *instPtr
= m_factory->instanceByName(m_currentName);
if (!instPtr || !instPtr->isUserSource())
return;
const QString instName = instPtr->name;
const QString sourcePath = instPtr->sourcePath;
if (QMessageBox::question(
this, tr("Delete provider"),
tr("Delete user provider '%1'?\n\nFile: %2").arg(instName, sourcePath))
!= QMessageBox::Yes)
return;
if (!QFile::remove(sourcePath)) {
QMessageBox::warning(this, tr("Delete provider"),
tr("Failed to delete file:\n%1").arg(sourcePath));
return;
}
m_currentName.clear();
m_factory->reload();
m_detailPane->clear();
}
void onSaveEdited(const Providers::ProviderInstance &edited)
{
if (!m_factory)
return;
Providers::ProviderInstance e = edited;
if (e.name.isEmpty()) {
QMessageBox::warning(this, tr("Save"), tr("Name cannot be empty."));
return;
}
const auto *prior = m_factory->instanceByName(m_currentName);
const QString priorRef = prior ? prior->apiKeyRef : QString{};
const QString priorName = prior ? prior->name : QString{};
const bool nameChanged = !priorName.isEmpty() && priorName != e.name;
if (e.apiKeyRef.isEmpty() || (nameChanged && e.apiKeyRef == priorRef))
e.apiKeyRef = QStringLiteral("qodeassist/providers/%1").arg(e.name);
const QString validation = Providers::ProviderInstance::validate(
e, m_factory->knownClientApis());
if (!validation.isEmpty()) {
QMessageBox::warning(this, tr("Save"), validation);
return;
}
if (nameChanged) {
const auto *clash = m_factory->instanceByName(e.name);
if (clash) {
QMessageBox::warning(this, tr("Save"),
tr("An instance named '%1' already exists.").arg(e.name));
return;
}
}
const QString softWarning = Providers::ProviderInstance::warnings(e);
if (!softWarning.isEmpty()) {
if (QMessageBox::warning(this, tr("Save"),
softWarning + QStringLiteral("\n\n")
+ tr("Save anyway?"),
QMessageBox::Yes | QMessageBox::No,
QMessageBox::No)
!= QMessageBox::Yes)
return;
}
const QString previousPath
= (prior && prior->isUserSource()) ? prior->sourcePath : QString{};
QString writeErr;
const QString writtenPath = Providers::ProviderInstanceWriter::writeToUserDir(
e, previousPath, &writeErr);
if (writtenPath.isEmpty()) {
QMessageBox::warning(this, tr("Save"), writeErr);
return;
}
if (!previousPath.isEmpty()
&& QFileInfo(writtenPath).absoluteFilePath()
!= QFileInfo(previousPath).absoluteFilePath()) {
if (!QFile::remove(previousPath)) {
QMessageBox::warning(
this, tr("Save"),
tr("Saved to:\n%1\n\nbut could not remove the old file:\n%2\n\n"
"Two provider files now describe this instance — delete the "
"old file manually to avoid a duplicate-name error.")
.arg(writtenPath, previousPath));
}
}
if (m_secrets && !priorRef.isEmpty() && priorRef != e.apiKeyRef) {
const QString carried = m_secrets->readKeySync(priorRef);
if (!carried.isEmpty())
m_secrets->writeKey(e.apiKeyRef, carried);
m_secrets->eraseKey(priorRef);
}
m_factory->reload();
selectInstance(e.name);
}
void onApiKeySave(const QString &newKey)
{
if (!m_factory || !m_secrets || m_currentName.isEmpty() || newKey.isEmpty())
return;
const auto *inst = m_factory->instanceByName(m_currentName);
if (!inst || inst->apiKeyRef.isEmpty())
return;
m_secrets->writeKey(inst->apiKeyRef, newKey);
m_detailPane->refreshKeyStatus(true);
}
void onApiKeyClear()
{
if (!m_factory || !m_secrets || m_currentName.isEmpty())
return;
const Providers::ProviderInstance *instPtr
= m_factory->instanceByName(m_currentName);
if (!instPtr || instPtr->apiKeyRef.isEmpty())
return;
const QString instName = instPtr->name;
const QString apiKeyRef = instPtr->apiKeyRef;
if (QMessageBox::question(
this, tr("Clear API key"),
tr("Erase the stored API key for '%1'?").arg(instName))
!= QMessageBox::Yes)
return;
m_secrets->eraseKey(apiKeyRef);
m_detailPane->refreshKeyStatus(false);
}
void onLaunchStart(const QString &name)
{
if (!m_factory || !m_launcher)
return;
const auto *inst = m_factory->instanceByName(name);
if (!inst || inst->launch.isEmpty())
return;
m_launcher->start(name, inst->launch);
}
void onLaunchStop(const QString &name)
{
if (!m_launcher)
return;
m_launcher->stop(name);
}
void onLaunchRestart(const QString &name)
{
if (!m_factory || !m_launcher)
return;
const auto *inst = m_factory->instanceByName(name);
if (!inst || inst->launch.isEmpty())
return;
m_launcher->restart(name, inst->launch);
}
private:
void populateDetail(const QString &name)
{
if (!m_factory)
return;
const auto *inst = m_factory->instanceByName(name);
if (!inst) {
m_detailPane->clear();
return;
}
const bool hasStoredKey
= m_secrets && !inst->apiKeyRef.isEmpty() && m_secrets->hasKey(inst->apiKeyRef);
m_detailPane->populate(*inst, hasStoredKey);
if (m_launcher) {
m_detailPane->setLaunchState(
m_launcher->state(inst->name),
m_launcher->lastError(inst->name));
m_detailPane->resetLaunchTerminal(m_launcher->scrollback(inst->name));
} else {
m_detailPane->setLaunchState(Providers::ProviderLauncher::Idle, {});
m_detailPane->resetLaunchTerminal({});
}
}
QPointer<Providers::ProviderInstanceFactory> m_factory;
QPointer<Providers::ProviderSecretsStore> m_secrets;
QPointer<ProvidersPageNavigator> m_navigator;
QLabel *m_titleLabel = nullptr;
QLineEdit *m_filterEdit = nullptr;
QScrollArea *m_listScroll = nullptr;
QWidget *m_listContent = nullptr;
QVBoxLayout *m_listLayout = nullptr;
QList<ProviderListItem *> m_rows;
QScrollArea *m_detailScroll = nullptr;
ProviderDetailPane *m_detailPane = nullptr;
QString m_currentName;
QPointer<Providers::ProviderLauncher> m_launcher;
QTimer *m_filterDebounce = nullptr;
void refreshDetailLaunch()
{
if (!m_launcher || m_currentName.isEmpty())
return;
m_detailPane->setLaunchState(
m_launcher->state(m_currentName),
m_launcher->lastError(m_currentName));
}
static ProviderListItem::Status rowStatusFromState(
Providers::ProviderLauncher::State state)
{
switch (state) {
case Providers::ProviderLauncher::Ready:
return ProviderListItem::Status::Ok;
case Providers::ProviderLauncher::Failed:
return ProviderListItem::Status::Fail;
case Providers::ProviderLauncher::Idle:
case Providers::ProviderLauncher::Starting:
case Providers::ProviderLauncher::Probing:
case Providers::ProviderLauncher::Stopping:
return ProviderListItem::Status::Unknown;
}
return ProviderListItem::Status::Unknown;
}
ProviderListItem::Status rowStatusFromLauncher(const QString &name) const
{
if (!m_launcher)
return ProviderListItem::Status::Unknown;
return rowStatusFromState(m_launcher->state(name));
}
};
class ProvidersOptionsPage : public Core::IOptionsPage
{
public:
ProvidersOptionsPage(
Providers::ProviderInstanceFactory *factory,
Providers::ProviderSecretsStore *secrets,
Providers::ProviderLauncher *launcher,
ProvidersPageNavigator *navigator)
{
setId(Constants::QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID);
setDisplayName(QObject::tr("Providers"));
setCategory(Constants::QODE_ASSIST_GENERAL_OPTIONS_CATEGORY);
setWidgetCreator([factory, secrets, launcher, navigator] {
return new ProvidersPageWidget(factory, secrets, launcher, navigator);
});
}
};
} // namespace
std::unique_ptr<Core::IOptionsPage> createProvidersSettingsPage(
Providers::ProviderInstanceFactory *instanceFactory,
Providers::ProviderSecretsStore *secrets,
Providers::ProviderLauncher *launcher,
ProvidersPageNavigator *navigator)
{
return std::make_unique<ProvidersOptionsPage>(
instanceFactory, secrets, launcher, navigator);
}
} // namespace QodeAssist::Settings
#include "ProvidersSettingsPage.moc"