// Copyright (C) 2024-2026 Petr Mironychev // SPDX-License-Identifier: GPL-3.0-or-later #include "ProvidersSettingsPage.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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 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 m_factory; QPointer m_secrets; QPointer m_navigator; QLabel *m_titleLabel = nullptr; QLineEdit *m_filterEdit = nullptr; QScrollArea *m_listScroll = nullptr; QWidget *m_listContent = nullptr; QVBoxLayout *m_listLayout = nullptr; QList m_rows; QScrollArea *m_detailScroll = nullptr; ProviderDetailPane *m_detailPane = nullptr; QString m_currentName; QPointer 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 createProvidersSettingsPage( Providers::ProviderInstanceFactory *instanceFactory, Providers::ProviderSecretsStore *secrets, Providers::ProviderLauncher *launcher, ProvidersPageNavigator *navigator) { return std::make_unique( instanceFactory, secrets, launcher, navigator); } } // namespace QodeAssist::Settings #include "ProvidersSettingsPage.moc"