feat: Add settings page for providers (#353)

This commit is contained in:
Petr Mironychev
2026-05-21 19:30:32 +02:00
committed by GitHub
parent ca3baa7597
commit e193d1e1fa
45 changed files with 3835 additions and 2 deletions

View File

@@ -16,6 +16,12 @@ add_library(QodeAssistSettings STATIC
ProjectSettingsPanel.hpp ProjectSettingsPanel.cpp
ProviderSettings.hpp ProviderSettings.cpp
ProviderNameMigration.hpp
ProvidersSettingsPage.hpp ProvidersSettingsPage.cpp
ProvidersSettingsHelpers.hpp
SectionBox.hpp SectionBox.cpp
ProviderListItem.hpp ProviderListItem.cpp
ProviderDetailPane.hpp ProviderDetailPane.cpp
NewProviderDialog.hpp NewProviderDialog.cpp
PluginUpdater.hpp PluginUpdater.cpp
UpdateDialog.hpp UpdateDialog.cpp
AgentRole.hpp AgentRole.cpp
@@ -31,6 +37,7 @@ target_link_libraries(QodeAssistSettings
QtCreator::Core
QtCreator::Utils
QodeAssistLogger
ProvidersConfig
Skills
)
target_include_directories(QodeAssistSettings PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})

View File

@@ -0,0 +1,81 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "NewProviderDialog.hpp"
#include <QComboBox>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QVBoxLayout>
namespace QodeAssist::Settings {
NewProviderDialog::NewProviderDialog(const QStringList &types, QWidget *parent)
: QDialog(parent)
{
setWindowTitle(tr("New provider"));
setMinimumWidth(520);
auto *intro = new QLabel(
tr("A provider binds a client API to a URL and API key. "
"Agents reference providers by name."),
this);
intro->setWordWrap(true);
QPalette ip = intro->palette();
ip.setColor(QPalette::WindowText, ip.color(QPalette::Mid));
intro->setPalette(ip);
m_typeCombo = new QComboBox(this);
m_typeCombo->addItems(types);
m_typeCombo->setEditable(false);
m_nameEdit = new QLineEdit(this);
m_nameEdit->setPlaceholderText(tr("Shown in the providers list and referenced by agents."));
m_urlEdit = new QLineEdit(this);
m_urlEdit->setPlaceholderText(QStringLiteral("https://api.example.com"));
m_descriptionEdit = new QLineEdit(this);
m_descriptionEdit->setPlaceholderText(tr("Optional — what this provider is for."));
m_apiKeyEdit = new QLineEdit(this);
m_apiKeyEdit->setEchoMode(QLineEdit::Password);
m_apiKeyEdit->setPlaceholderText(tr("(stored — leave blank to set later)"));
auto *form = new QFormLayout;
form->addRow(tr("Client API:"), m_typeCombo);
form->addRow(tr("Name:"), m_nameEdit);
form->addRow(tr("URL:"), m_urlEdit);
form->addRow(tr("Description:"), m_descriptionEdit);
form->addRow(tr("API key:"), m_apiKeyEdit);
auto *buttons = new QDialogButtonBox(
QDialogButtonBox::Cancel | QDialogButtonBox::Ok, this);
buttons->button(QDialogButtonBox::Ok)->setText(tr("Create"));
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
auto *root = new QVBoxLayout(this);
root->addWidget(intro);
root->addLayout(form);
root->addWidget(buttons);
connect(m_typeCombo, &QComboBox::currentTextChanged, this, [this](const QString &type) {
if (m_nameEdit->text().isEmpty())
m_nameEdit->setText(type);
});
if (!types.isEmpty() && m_nameEdit->text().isEmpty())
m_nameEdit->setText(types.front());
}
QString NewProviderDialog::providerType() const { return m_typeCombo->currentText(); }
QString NewProviderDialog::providerName() const { return m_nameEdit->text().trimmed(); }
QString NewProviderDialog::url() const { return m_urlEdit->text().trimmed(); }
QString NewProviderDialog::description() const { return m_descriptionEdit->text().trimmed(); }
QString NewProviderDialog::apiKey() const { return m_apiKeyEdit->text(); }
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,34 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QDialog>
#include <QStringList>
class QComboBox;
class QLineEdit;
namespace QodeAssist::Settings {
class NewProviderDialog : public QDialog
{
Q_OBJECT
public:
explicit NewProviderDialog(const QStringList &types, QWidget *parent = nullptr);
QString providerType() const;
QString providerName() const;
QString url() const;
QString description() const;
QString apiKey() const;
private:
QComboBox *m_typeCombo = nullptr;
QLineEdit *m_nameEdit = nullptr;
QLineEdit *m_urlEdit = nullptr;
QLineEdit *m_descriptionEdit = nullptr;
QLineEdit *m_apiKeyEdit = nullptr;
};
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,527 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ProviderDetailPane.hpp"
#include <array>
#include <QFrame>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QToolButton>
#include <QVBoxLayout>
#include <solutions/terminal/terminalview.h>
#include "ProviderInstanceWriter.hpp"
#include "ProvidersSettingsHelpers.hpp"
#include "SectionBox.hpp"
namespace QodeAssist::Settings {
ProviderDetailPane::ProviderDetailPane(QWidget *parent)
: QWidget(parent)
{
m_nameLabel = new QLabel(this);
QFont nf = m_nameLabel->font();
nf.setBold(true);
nf.setPixelSize(15);
m_nameLabel->setFont(nf);
m_sourcePathLabel = new QLabel(this);
m_sourcePathLabel->setFont(monospaceFont(11));
QPalette spp = m_sourcePathLabel->palette();
spp.setColor(QPalette::WindowText, spp.color(QPalette::Mid));
m_sourcePathLabel->setPalette(spp);
m_editBtn = new QPushButton(tr("Edit…"), this);
m_editBtn->setDefault(true);
m_openInEditorBtn = new QPushButton(tr("Open in editor"), this);
m_openInEditorBtn->setToolTip(
tr("Open this provider's TOML file in Qt Creator. "
"Bundled providers are read-only — duplicate first."));
m_dupBtn = new QPushButton(tr("Duplicate…"), this);
m_deleteBtn = new QPushButton(tr("Delete"), this);
m_cancelBtn = new QPushButton(tr("Cancel"), this);
m_saveBtn = new QPushButton(tr("Save"), this);
m_saveBtn->setDefault(true);
m_cancelBtn->hide();
m_saveBtn->hide();
connect(m_editBtn, &QPushButton::clicked, this, [this] { setEditing(true); });
connect(m_cancelBtn, &QPushButton::clicked, this, [this] {
setEditing(false);
populate(m_current, m_currentHasStoredKey);
});
connect(m_saveBtn, &QPushButton::clicked, this, [this] {
emit saveRequested(collectEdits());
});
connect(m_openInEditorBtn, &QPushButton::clicked, this,
[this] { emit openInEditorRequested(m_current.sourcePath); });
connect(m_dupBtn, &QPushButton::clicked, this, [this] { emit duplicateRequested(); });
connect(m_deleteBtn, &QPushButton::clicked, this, [this] { emit deleteRequested(); });
auto *btnBar = new QHBoxLayout;
btnBar->setContentsMargins(0, 0, 0, 0);
btnBar->setSpacing(4);
btnBar->addWidget(m_editBtn);
btnBar->addWidget(m_openInEditorBtn);
btnBar->addWidget(m_dupBtn);
btnBar->addWidget(m_deleteBtn);
btnBar->addWidget(m_cancelBtn);
btnBar->addWidget(m_saveBtn);
auto *titleRow = new QHBoxLayout;
titleRow->setContentsMargins(0, 0, 0, 0);
titleRow->setSpacing(8);
titleRow->addWidget(m_nameLabel);
titleRow->addStretch(1);
auto *headerLeft = new QVBoxLayout;
headerLeft->setContentsMargins(0, 0, 0, 0);
headerLeft->setSpacing(2);
headerLeft->addLayout(titleRow);
headerLeft->addWidget(m_sourcePathLabel);
auto *headerRow = new QHBoxLayout;
headerRow->setContentsMargins(0, 0, 0, 0);
headerRow->setSpacing(8);
headerRow->addLayout(headerLeft, 1);
headerRow->addLayout(btnBar);
auto *headerSep = new QFrame(this);
headerSep->setFrameShape(QFrame::HLine);
headerSep->setFrameShadow(QFrame::Sunken);
m_descriptionLabel = new QLabel(this);
m_descriptionLabel->setWordWrap(true);
m_descriptionLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
auto *identitySection = new SectionBox(tr("Identity"), this);
m_nameEdit = new QLineEdit(this);
m_typeEdit = new QLineEdit(this);
m_typeEdit->setReadOnly(true);
m_descriptionEdit = new QPlainTextEdit(this);
m_descriptionEdit->setMaximumHeight(60);
m_descriptionEdit->setReadOnly(true);
auto *identityGrid = new QGridLayout;
identityGrid->setContentsMargins(0, 0, 0, 0);
identityGrid->setHorizontalSpacing(8);
identityGrid->setVerticalSpacing(4);
int identityRow = 0;
identityRow = addFormRow(identityGrid, identityRow, tr("Name:"),
singleField(m_nameEdit));
identityRow = addFormRow(identityGrid, identityRow, tr("Client API:"),
singleField(m_typeEdit),
tr("The client API this provider speaks. "
"Cannot be changed after creation."));
identityRow = addFormRow(identityGrid, identityRow, tr("Description:"),
singleField(m_descriptionEdit));
identitySection->bodyLayout()->addLayout(identityGrid);
auto *endpointSection = new SectionBox(tr("Endpoint"), this);
m_urlEdit = new QLineEdit(this);
m_urlEdit->setFont(monospaceFont(11));
auto *endpointGrid = new QGridLayout;
endpointGrid->setContentsMargins(0, 0, 0, 0);
endpointGrid->setHorizontalSpacing(8);
endpointGrid->setVerticalSpacing(4);
int endpointRow = 0;
endpointRow = addFormRow(endpointGrid, endpointRow, tr("URL:"),
singleField(m_urlEdit),
tr("Base URL. Agents append their endpoint path "
"(e.g. /chat/completions) to this."));
endpointSection->bodyLayout()->addLayout(endpointGrid);
m_samplePreview = new QLabel(this);
m_samplePreview->setFont(monospaceFont(11));
m_samplePreview->setTextInteractionFlags(Qt::TextSelectableByMouse);
m_samplePreview->setWordWrap(true);
m_samplePreview->setContentsMargins(6, 4, 6, 4);
m_samplePreview->setAutoFillBackground(true);
endpointSection->bodyLayout()->addWidget(m_samplePreview);
auto *credSection = new SectionBox(tr("Credentials"), this);
m_apiKeyEdit = new QLineEdit(this);
m_apiKeyEdit->setEchoMode(QLineEdit::Password);
m_apiKeyEdit->setPlaceholderText(tr("Enter API key…"));
m_revealKeyBtn = new QToolButton(this);
m_revealKeyBtn->setText(QStringLiteral("👁"));
m_revealKeyBtn->setCheckable(true);
m_revealKeyBtn->setToolTip(tr("Show / hide API key"));
connect(m_revealKeyBtn, &QToolButton::toggled, this, [this](bool on) {
m_apiKeyEdit->setEchoMode(on ? QLineEdit::Normal : QLineEdit::Password);
});
m_apiKeySaveBtn = new QPushButton(tr("Save key"), this);
m_apiKeySaveBtn->setEnabled(false);
m_apiKeyClearBtn = new QPushButton(tr("Clear"), this);
m_apiKeyClearBtn->setToolTip(tr("Erase the stored API key for this provider"));
connect(m_apiKeyEdit, &QLineEdit::textChanged, this, [this](const QString &t) {
m_apiKeySaveBtn->setEnabled(!t.isEmpty());
});
connect(m_apiKeyEdit, &QLineEdit::returnPressed, this, [this] {
if (!m_apiKeyEdit->text().isEmpty())
m_apiKeySaveBtn->click();
});
connect(m_apiKeySaveBtn, &QPushButton::clicked, this, [this] {
const QString key = m_apiKeyEdit->text();
if (key.isEmpty())
return;
emit apiKeySaveRequested(key);
m_apiKeyEdit->clear();
});
connect(m_apiKeyClearBtn, &QPushButton::clicked, this,
[this] { emit apiKeyClearRequested(); });
m_keyHint = new QLabel(this);
QFont khf = m_keyHint->font();
khf.setPixelSize(11);
m_keyHint->setFont(khf);
m_keyHint->setWordWrap(true);
QPalette khp = m_keyHint->palette();
khp.setColor(QPalette::WindowText, khp.color(QPalette::Mid));
m_keyHint->setPalette(khp);
auto *keyRow = new QHBoxLayout;
keyRow->setContentsMargins(0, 0, 0, 0);
keyRow->setSpacing(4);
keyRow->addWidget(m_apiKeyEdit, 1);
keyRow->addWidget(m_revealKeyBtn);
keyRow->addWidget(m_apiKeySaveBtn);
keyRow->addWidget(m_apiKeyClearBtn);
auto *credGrid = new QGridLayout;
credGrid->setContentsMargins(0, 0, 0, 0);
credGrid->setHorizontalSpacing(8);
credGrid->setVerticalSpacing(4);
int credRow = 0;
credRow = addFormRow(credGrid, credRow, tr("API key:"), keyRow);
credGrid->addWidget(m_keyHint, credRow, 1);
credSection->bodyLayout()->addLayout(credGrid);
m_launchSection = new SectionBox(tr("Launch"), this);
m_launchEmptyHint = new QLabel(this);
m_launchEmptyHint->setWordWrap(true);
QPalette lehp = m_launchEmptyHint->palette();
lehp.setColor(QPalette::WindowText, lehp.color(QPalette::Mid));
m_launchEmptyHint->setPalette(lehp);
m_launchCmdLabel = new QLabel(this);
m_launchCmdLabel->setFont(monospaceFont(11));
m_launchCmdLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
m_launchCmdLabel->setWordWrap(true);
m_launchStatusPill = new QLabel(tr("idle"), this);
m_startBtn = new QPushButton(tr("Start"), this);
m_stopBtn = new QPushButton(tr("Stop"), this);
m_restartBtn = new QPushButton(tr("Restart"), this);
connect(m_startBtn, &QPushButton::clicked, this,
[this] { emit launchStartRequested(m_current.name); });
connect(m_stopBtn, &QPushButton::clicked, this,
[this] { emit launchStopRequested(m_current.name); });
connect(m_restartBtn, &QPushButton::clicked, this,
[this] { emit launchRestartRequested(m_current.name); });
auto *launchBtnRow = new QHBoxLayout;
launchBtnRow->setContentsMargins(0, 0, 0, 0);
launchBtnRow->setSpacing(6);
launchBtnRow->addWidget(m_launchStatusPill);
launchBtnRow->addStretch(1);
launchBtnRow->addWidget(m_startBtn);
launchBtnRow->addWidget(m_stopBtn);
launchBtnRow->addWidget(m_restartBtn);
m_launchTerminalToggle = new QToolButton(this);
m_launchTerminalToggle->setText(tr("▸ Show launch terminal"));
m_launchTerminalToggle->setCursor(Qt::PointingHandCursor);
m_launchTerminalToggle->setAutoRaise(true);
m_launchTerminalToggle->setCheckable(true);
m_launchTerminal = new TerminalSolution::TerminalView(this);
{
QFont termFont(TerminalSolution::defaultFontFamily());
const int sz = TerminalSolution::defaultFontSize();
if (sz > 0)
termFont.setPixelSize(sz);
termFont.setStyleHint(QFont::Monospace);
m_launchTerminal->setFont(termFont);
applyTerminalPalette();
}
m_launchTerminal->setMinimumHeight(180);
m_launchTerminal->setVisible(false);
connect(m_launchTerminalToggle, &QToolButton::toggled, this, [this](bool on) {
m_launchTerminal->setVisible(on);
m_launchTerminalToggle->setText(
on ? tr("▾ Hide launch terminal") : tr("▸ Show launch terminal"));
});
m_launchSection->bodyLayout()->addWidget(m_launchEmptyHint);
m_launchSection->bodyLayout()->addWidget(m_launchCmdLabel);
m_launchSection->bodyLayout()->addLayout(launchBtnRow);
m_launchSection->bodyLayout()->addWidget(m_launchTerminalToggle, 0, Qt::AlignLeft);
m_launchSection->bodyLayout()->addWidget(m_launchTerminal);
m_rawToggle = new QToolButton(this);
m_rawToggle->setText(tr("▸ Show raw TOML"));
m_rawToggle->setCursor(Qt::PointingHandCursor);
m_rawToggle->setAutoRaise(true);
m_rawToggle->setCheckable(true);
m_rawToml = new QPlainTextEdit(this);
m_rawToml->setReadOnly(true);
m_rawToml->setFont(monospaceFont(11));
m_rawToml->setMinimumHeight(120);
m_rawToml->setVisible(false);
connect(m_rawToggle, &QToolButton::toggled, this, [this](bool on) {
m_rawToml->setVisible(on);
m_rawToggle->setText(on ? tr("▾ Hide raw TOML") : tr("▸ Show raw TOML"));
});
auto *root = new QVBoxLayout(this);
root->setContentsMargins(12, 12, 12, 12);
root->setSpacing(10);
root->addLayout(headerRow);
root->addWidget(headerSep);
root->addWidget(m_descriptionLabel);
root->addWidget(identitySection);
root->addWidget(endpointSection);
root->addWidget(credSection);
root->addWidget(m_launchSection);
root->addWidget(m_rawToggle, 0, Qt::AlignLeft);
root->addWidget(m_rawToml);
root->addStretch(1);
clear();
}
void ProviderDetailPane::populate(const Providers::ProviderInstance &inst, bool hasStoredKey)
{
m_current = inst;
m_currentHasStoredKey = hasStoredKey;
const bool isUser = inst.isUserSource();
const bool needsKey = !inst.apiKeyRef.isEmpty();
m_nameLabel->setText(inst.name);
m_sourcePathLabel->setText(inst.sourcePath);
m_descriptionLabel->setText(
inst.description.isEmpty() ? tr("No description provided.") : inst.description);
m_nameEdit->setText(inst.name);
m_typeEdit->setText(inst.clientApi);
m_descriptionEdit->setPlainText(inst.description);
m_urlEdit->setText(inst.url);
m_apiKeyEdit->clear();
m_apiKeyEdit->setEnabled(needsKey);
m_apiKeySaveBtn->setEnabled(false);
m_apiKeyClearBtn->setEnabled(needsKey && hasStoredKey);
m_revealKeyBtn->setEnabled(needsKey);
m_revealKeyBtn->setChecked(false);
if (!needsKey) {
m_apiKeyEdit->setPlaceholderText(tr("— not required (local provider)"));
m_keyHint->setText(tr("This provider type does not use a key."));
} else if (hasStoredKey) {
m_apiKeyEdit->setPlaceholderText(tr("Stored — enter a new key to replace it."));
m_keyHint->setText(tr("A key is stored. Type a new key and press Save key to "
"replace it, or Clear to erase it."));
} else {
m_apiKeyEdit->setPlaceholderText(tr("Enter API key…"));
m_keyHint->setText(tr("No key stored yet. Type a key and press Save key."));
}
m_samplePreview->setText(
QStringLiteral("# sample request line\nPOST %1/<agent endpoint>").arg(inst.url));
applyPreviewPalette();
m_deleteBtn->setEnabled(isUser);
m_dupBtn->setEnabled(true);
m_editBtn->setVisible(isUser);
m_openInEditorBtn->setEnabled(isUser);
setEditing(false);
QString toml = QStringLiteral("# %1\n").arg(inst.sourcePath);
toml += Providers::ProviderInstanceWriter::toToml(inst);
m_rawToml->setPlainText(toml);
}
void ProviderDetailPane::clear()
{
m_current = {};
m_nameLabel->setText(tr("Select a provider"));
m_sourcePathLabel->clear();
m_descriptionLabel->clear();
m_nameEdit->clear();
m_typeEdit->clear();
m_descriptionEdit->clear();
m_urlEdit->clear();
m_apiKeyEdit->clear();
m_apiKeyEdit->setEnabled(false);
m_apiKeySaveBtn->setEnabled(false);
m_apiKeyClearBtn->setEnabled(false);
m_revealKeyBtn->setEnabled(false);
m_samplePreview->clear();
m_rawToml->clear();
m_editBtn->setVisible(false);
m_dupBtn->setEnabled(false);
m_deleteBtn->setEnabled(false);
m_openInEditorBtn->setEnabled(false);
}
void ProviderDetailPane::refreshKeyStatus(bool hasStoredKey)
{
m_currentHasStoredKey = hasStoredKey;
const bool needsKey = !m_current.apiKeyRef.isEmpty();
m_apiKeyClearBtn->setEnabled(needsKey && hasStoredKey);
if (!needsKey)
return;
if (hasStoredKey) {
m_apiKeyEdit->setPlaceholderText(tr("Stored — enter a new key to replace it."));
m_keyHint->setText(tr("A key is stored. Type a new key and press Save key to "
"replace it, or Clear to erase it."));
} else {
m_apiKeyEdit->setPlaceholderText(tr("Enter API key…"));
m_keyHint->setText(tr("No key stored yet. Type a key and press Save key."));
}
}
void ProviderDetailPane::setLaunchState(
Providers::ProviderLauncher::State st, const QString &lastError)
{
const bool hasLaunch = !m_current.launch.isEmpty();
m_launchSection->setVisible(true);
m_launchEmptyHint->setVisible(!hasLaunch);
m_launchCmdLabel->setVisible(hasLaunch);
m_startBtn->setVisible(hasLaunch);
m_stopBtn->setVisible(hasLaunch);
m_restartBtn->setVisible(hasLaunch);
m_launchStatusPill->setVisible(hasLaunch);
m_launchTerminalToggle->setVisible(hasLaunch);
if (!hasLaunch) {
m_launchEmptyHint->setText(tr(
"No [launch] block. This provider is treated as external — "
"the plugin will not spawn or supervise any process. "
"Add a [launch] block to the TOML to have the plugin manage "
"a local server here."));
m_launchCmdLabel->clear();
m_launchTerminal->clearContents();
return;
}
const QString detachedNote = m_current.launch.detach
? tr(" <span style='color:gray'>(detached — survives Qt Creator restart)</span>")
: QString();
m_launchCmdLabel->setText(
QStringLiteral("<b>%1</b> %2%3")
.arg(m_current.launch.command.toHtmlEscaped(),
m_current.launch.args.join(QLatin1Char(' ')).toHtmlEscaped(),
detachedNote));
QString statusText;
switch (st) {
case Providers::ProviderLauncher::Idle: statusText = tr("idle"); break;
case Providers::ProviderLauncher::Starting: statusText = tr("starting…"); break;
case Providers::ProviderLauncher::Probing: statusText = tr("probing…"); break;
case Providers::ProviderLauncher::Ready: statusText = tr("ready"); break;
case Providers::ProviderLauncher::Stopping: statusText = tr("stopping…"); break;
case Providers::ProviderLauncher::Failed:
statusText = lastError.isEmpty() ? tr("failed")
: tr("failed — %1").arg(lastError);
break;
}
m_launchStatusPill->setText(statusText);
const bool running = st == Providers::ProviderLauncher::Starting
|| st == Providers::ProviderLauncher::Probing
|| st == Providers::ProviderLauncher::Ready;
m_startBtn->setEnabled(!running && st != Providers::ProviderLauncher::Stopping);
m_stopBtn->setEnabled(running);
m_restartBtn->setEnabled(running || st == Providers::ProviderLauncher::Failed);
}
void ProviderDetailPane::resetLaunchTerminal(const QByteArray &scrollback)
{
m_launchTerminal->clearContents();
if (!scrollback.isEmpty())
m_launchTerminal->writeToTerminal(scrollback, true);
}
void ProviderDetailPane::appendLaunchBytes(const QByteArray &chunk)
{
m_launchTerminal->writeToTerminal(chunk, true);
}
void ProviderDetailPane::changeEvent(QEvent *event)
{
QWidget::changeEvent(event);
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange) {
applyPreviewPalette();
applyTerminalPalette();
}
}
void ProviderDetailPane::setEditing(bool on)
{
m_editing = on;
m_nameEdit->setReadOnly(!on);
m_descriptionEdit->setReadOnly(!on);
m_urlEdit->setReadOnly(!on);
m_editBtn->setVisible(!on && m_current.isUserSource());
m_dupBtn->setVisible(!on);
m_deleteBtn->setVisible(!on);
m_cancelBtn->setVisible(on);
m_saveBtn->setVisible(on);
}
Providers::ProviderInstance ProviderDetailPane::collectEdits() const
{
Providers::ProviderInstance out = m_current;
out.name = m_nameEdit->text().trimmed();
out.description = m_descriptionEdit->toPlainText().trimmed();
out.url = m_urlEdit->text().trimmed();
return out;
}
void ProviderDetailPane::applyPreviewPalette()
{
const bool dark = isDarkPalette(palette());
const QString bg = dark ? QStringLiteral("#1f1f1f") : QStringLiteral("#f4f4f4");
const QString bd = dark ? QStringLiteral("#3a3a3a") : QStringLiteral("#dcdcdc");
m_samplePreview->setStyleSheet(QStringLiteral(
"QLabel { background:%1; border:1px solid %2; }")
.arg(bg, bd));
}
void ProviderDetailPane::applyTerminalPalette()
{
if (!m_launchTerminal)
return;
const QPalette pal = palette();
const bool dark = isDarkPalette(pal);
const std::array<QColor, 16> ansi = dark
? std::array<QColor, 16>{
QColor("#000000"), QColor("#cd3131"), QColor("#0dbc79"),
QColor("#e5e510"), QColor("#2472c8"), QColor("#bc3fbc"),
QColor("#11a8cd"), QColor("#e5e5e5"),
QColor("#666666"), QColor("#f14c4c"), QColor("#23d18b"),
QColor("#f5f543"), QColor("#3b8eea"), QColor("#d670d6"),
QColor("#29b8db"), QColor("#ffffff"),
}
: std::array<QColor, 16>{
QColor("#000000"), QColor("#c91b00"), QColor("#00c200"),
QColor("#c7c400"), QColor("#0037da"), QColor("#c930c7"),
QColor("#00c5c7"), QColor("#c7c7c7"),
QColor("#676767"), QColor("#ff6d67"), QColor("#5ff967"),
QColor("#fefb67"), QColor("#6871ff"), QColor("#ff76ff"),
QColor("#5ffdff"), QColor("#ffffff"),
};
std::array<QColor, 20> colors{};
for (int i = 0; i < 16; ++i)
colors[i] = ansi[i];
colors[16] = pal.color(QPalette::Text);
colors[17] = pal.color(QPalette::Base);
colors[18] = pal.color(QPalette::Highlight);
colors[19] = dark ? QColor("#5a5a40") : QColor("#fff59d");
m_launchTerminal->setColors(colors);
}
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,101 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QWidget>
#include "ProviderInstance.hpp"
#include "ProviderLauncher.hpp"
class QLabel;
class QLineEdit;
class QPlainTextEdit;
class QPushButton;
class QToolButton;
namespace TerminalSolution {
class TerminalView;
}
namespace QodeAssist::Settings {
class SectionBox;
class ProviderDetailPane : public QWidget
{
Q_OBJECT
public:
explicit ProviderDetailPane(QWidget *parent = nullptr);
void populate(const Providers::ProviderInstance &inst, bool hasStoredKey);
void clear();
void refreshKeyStatus(bool hasStoredKey);
void setLaunchState(Providers::ProviderLauncher::State st, const QString &lastError);
void resetLaunchTerminal(const QByteArray &scrollback);
void appendLaunchBytes(const QByteArray &chunk);
QString currentName() const { return m_current.name; }
signals:
void saveRequested(const Providers::ProviderInstance &edited);
void duplicateRequested();
void deleteRequested();
void apiKeySaveRequested(const QString &newKey);
void apiKeyClearRequested();
void launchStartRequested(const QString &providerName);
void launchStopRequested(const QString &providerName);
void launchRestartRequested(const QString &providerName);
void openInEditorRequested(const QString &sourcePath);
protected:
void changeEvent(QEvent *event) override;
private:
void setEditing(bool on);
Providers::ProviderInstance collectEdits() const;
void applyPreviewPalette();
void applyTerminalPalette();
bool m_editing = false;
bool m_currentHasStoredKey = false;
Providers::ProviderInstance m_current;
QLabel *m_nameLabel = nullptr;
QLabel *m_sourcePathLabel = nullptr;
QPushButton *m_editBtn = nullptr;
QPushButton *m_openInEditorBtn = nullptr;
QPushButton *m_dupBtn = nullptr;
QPushButton *m_deleteBtn = nullptr;
QPushButton *m_cancelBtn = nullptr;
QPushButton *m_saveBtn = nullptr;
QLabel *m_descriptionLabel = nullptr;
QLineEdit *m_nameEdit = nullptr;
QLineEdit *m_typeEdit = nullptr;
QPlainTextEdit *m_descriptionEdit = nullptr;
QLineEdit *m_urlEdit = nullptr;
QLabel *m_samplePreview = nullptr;
QLineEdit *m_apiKeyEdit = nullptr;
QToolButton *m_revealKeyBtn = nullptr;
QLabel *m_keyHint = nullptr;
QPushButton *m_apiKeySaveBtn = nullptr;
QPushButton *m_apiKeyClearBtn = nullptr;
SectionBox *m_launchSection = nullptr;
QLabel *m_launchEmptyHint = nullptr;
QLabel *m_launchCmdLabel = nullptr;
QLabel *m_launchStatusPill = nullptr;
QPushButton *m_startBtn = nullptr;
QPushButton *m_stopBtn = nullptr;
QPushButton *m_restartBtn = nullptr;
QToolButton *m_launchTerminalToggle = nullptr;
TerminalSolution::TerminalView *m_launchTerminal = nullptr;
QToolButton *m_rawToggle = nullptr;
QPlainTextEdit *m_rawToml = nullptr;
};
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,110 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ProviderListItem.hpp"
#include <QHBoxLayout>
#include <QLabel>
#include <QMouseEvent>
#include <QScopedValueRollback>
#include <QVBoxLayout>
#include "ProviderInstance.hpp"
#include "ProvidersSettingsHelpers.hpp"
namespace QodeAssist::Settings {
ProviderListItem::ProviderListItem(
const Providers::ProviderInstance &inst, QWidget *parent)
: QFrame(parent)
, m_name(inst.name)
{
setObjectName(QStringLiteral("ProvListItem"));
setFrameShape(QFrame::NoFrame);
setAutoFillBackground(true);
setCursor(Qt::PointingHandCursor);
auto *headerRow = new QHBoxLayout;
headerRow->setContentsMargins(0, 0, 0, 0);
headerRow->setSpacing(6);
m_statusDot = new QLabel(QStringLiteral(""), this);
QFont df = m_statusDot->font();
df.setPixelSize(11);
m_statusDot->setFont(df);
m_statusDot->setStyleSheet(QStringLiteral("color: %1;").arg(statusColor(Status::Unknown)));
m_nameLabel = new QLabel(inst.name, this);
QFont nf = m_nameLabel->font();
nf.setBold(true);
nf.setPixelSize(12);
m_nameLabel->setFont(nf);
headerRow->addWidget(m_statusDot, 0, Qt::AlignVCenter);
headerRow->addWidget(m_nameLabel, 1);
m_urlLabel = new QLabel(inst.url, this);
m_urlLabel->setFont(monospaceFont(10));
QPalette up = m_urlLabel->palette();
up.setColor(QPalette::WindowText, up.color(QPalette::Mid));
m_urlLabel->setPalette(up);
m_urlLabel->setContentsMargins(17, 0, 0, 0);
auto *outer = new QVBoxLayout(this);
outer->setContentsMargins(8, 6, 8, 6);
outer->setSpacing(2);
outer->addLayout(headerRow);
outer->addWidget(m_urlLabel);
applyTheme();
}
void ProviderListItem::setStatus(Status s)
{
m_status = s;
m_statusDot->setStyleSheet(QStringLiteral("color: %1;").arg(statusColor(s)));
}
void ProviderListItem::setSelected(bool s)
{
if (m_selected == s)
return;
m_selected = s;
applyTheme();
}
void ProviderListItem::mouseReleaseEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton)
emit clicked(m_name);
QFrame::mouseReleaseEvent(event);
}
void ProviderListItem::changeEvent(QEvent *event)
{
QFrame::changeEvent(event);
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
applyTheme();
}
QString ProviderListItem::statusColor(Status s)
{
switch (s) {
case Status::Ok: return QStringLiteral("#3a8a4f");
case Status::Fail: return QStringLiteral("#c94a4a");
case Status::Unknown: return QStringLiteral("#888888");
}
return QStringLiteral("#888888");
}
void ProviderListItem::applyTheme()
{
if (m_inApplyTheme)
return;
QScopedValueRollback<bool> guard(m_inApplyTheme, true);
const bool dark = isDarkPalette(palette());
const QString sep = dark ? QStringLiteral("#3a3a3a") : QStringLiteral("#dcdcdc");
const QString sel = dark ? QStringLiteral("#2c4060") : QStringLiteral("#cfe2ff");
setStyleSheet(QStringLiteral(
"#ProvListItem { background:%1; border-top: 1px solid %2; }")
.arg(m_selected ? sel : QStringLiteral("transparent"), sep));
}
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,50 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QFrame>
class QLabel;
class QMouseEvent;
namespace QodeAssist::Providers {
struct ProviderInstance;
}
namespace QodeAssist::Settings {
class ProviderListItem : public QFrame
{
Q_OBJECT
public:
enum class Status : int { Unknown, Ok, Fail };
explicit ProviderListItem(
const Providers::ProviderInstance &inst, QWidget *parent = nullptr);
void setStatus(Status s);
void setSelected(bool s);
QString providerName() const { return m_name; }
signals:
void clicked(const QString &name);
protected:
void mouseReleaseEvent(QMouseEvent *event) override;
void changeEvent(QEvent *event) override;
private:
static QString statusColor(Status s);
void applyTheme();
QString m_name;
Status m_status = Status::Unknown;
bool m_selected = false;
bool m_inApplyTheme = false;
QLabel *m_statusDot = nullptr;
QLabel *m_nameLabel = nullptr;
QLabel *m_urlLabel = nullptr;
};
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,64 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QFont>
#include <QFontDatabase>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QPalette>
#include <QString>
#include <QWidget>
namespace QodeAssist::Settings {
inline QFont monospaceFont(int pixelSize = 11)
{
QFont f = QFontDatabase::systemFont(QFontDatabase::FixedFont);
f.setStyleHint(QFont::Monospace);
if (pixelSize > 0)
f.setPixelSize(pixelSize);
return f;
}
inline bool isDarkPalette(const QPalette &p)
{
return p.color(QPalette::Window).lightness() < 128;
}
inline int addFormRow(
QGridLayout *grid, int row, const QString &label, QLayout *value, const QString &hint = {})
{
auto *l = new QLabel(label);
l->setMinimumWidth(96);
l->setAlignment(Qt::AlignLeft | Qt::AlignTop);
grid->addWidget(l, row, 0, Qt::AlignTop);
auto *holder = new QWidget;
holder->setLayout(value);
grid->addWidget(holder, row, 1);
if (hint.isEmpty())
return row + 1;
auto *h = new QLabel(hint);
QFont hf = h->font();
hf.setPixelSize(11);
h->setFont(hf);
h->setWordWrap(true);
QPalette p = h->palette();
p.setColor(QPalette::WindowText, p.color(QPalette::Mid));
h->setPalette(p);
grid->addWidget(h, row + 1, 1);
return row + 2;
}
inline QHBoxLayout *singleField(QWidget *w)
{
auto *lay = new QHBoxLayout;
lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(4);
lay->addWidget(w, 1);
return lay;
}
} // namespace QodeAssist::Settings

View File

@@ -0,0 +1,674 @@
// 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 "NewProviderDialog.hpp"
#include "ProviderDetailPane.hpp"
#include "ProviderInstance.hpp"
#include "ProviderInstanceFactory.hpp"
#include "ProviderInstanceWriter.hpp"
#include "ProviderLauncher.hpp"
#include "ProviderListItem.hpp"
#include "ProviderSecretsStore.hpp"
#include "ProvidersSettingsHelpers.hpp"
#include "SettingsConstants.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);
m_newBtn = new QPushButton(tr("+ New provider…"), this);
auto *headerRow = new QHBoxLayout;
headerRow->setContentsMargins(0, 0, 0, 0);
headerRow->setSpacing(8);
headerRow->addWidget(m_titleLabel, 1);
headerRow->addWidget(m_newBtn);
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);
connect(m_newBtn, &QPushButton::clicked, this, &ProvidersPageWidget::onNewClicked);
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);
const bool dark = isDarkPalette(palette());
const QString bg = dark ? QStringLiteral("#262626") : QStringLiteral("#f0f0f0");
header->setStyleSheet(QStringLiteral("QLabel { background:%1; }").arg(bg));
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 onNewClicked()
{
if (!m_factory)
return;
NewProviderDialog dlg(m_factory->knownClientApis(), this);
if (dlg.exec() != QDialog::Accepted)
return;
Providers::ProviderInstance inst;
inst.name = dlg.providerName();
inst.clientApi = dlg.providerType();
inst.description = dlg.description();
inst.url = dlg.url();
inst.apiKeyRef = QStringLiteral("qodeassist/providers/%1").arg(inst.name);
if (inst.name.isEmpty()) {
QMessageBox::warning(this, tr("New provider"), tr("Name cannot be empty."));
return;
}
if (m_factory->instanceByName(inst.name)) {
QMessageBox::warning(this, tr("New provider"),
tr("An instance named '%1' already exists.").arg(inst.name));
return;
}
const QString validation = Providers::ProviderInstance::validate(
inst, m_factory->knownClientApis());
if (!validation.isEmpty()) {
QMessageBox::warning(this, tr("New provider"), validation);
return;
}
const QString softWarning = Providers::ProviderInstance::warnings(inst);
if (!softWarning.isEmpty()) {
if (QMessageBox::warning(this, tr("New provider"),
softWarning + QStringLiteral("\n\n")
+ tr("Save anyway?"),
QMessageBox::Yes | QMessageBox::No,
QMessageBox::No)
!= QMessageBox::Yes)
return;
}
QString writeErr;
if (Providers::ProviderInstanceWriter::writeToUserDir(
inst, /*previousPath=*/QString{}, &writeErr).isEmpty()) {
QMessageBox::warning(this, tr("New provider"), writeErr);
return;
}
if (m_secrets && !dlg.apiKey().isEmpty())
m_secrets->writeKey(inst.apiKeyRef, dlg.apiKey());
m_factory->reload();
selectInstance(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;
QPushButton *m_newBtn = 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"

View File

@@ -0,0 +1,43 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <memory>
#include <QObject>
#include <QString>
namespace Core { class IOptionsPage; }
namespace QodeAssist::Providers {
class ProviderInstanceFactory;
class ProviderSecretsStore;
class ProviderLauncher;
}
namespace QodeAssist::Settings {
class ProvidersPageNavigator : public QObject
{
Q_OBJECT
public:
explicit ProvidersPageNavigator(QObject *parent = nullptr);
void requestSelectInstance(const QString &name);
QString takePendingSelection();
signals:
void selectInstanceRequested(const QString &name);
private:
QString m_pending;
};
std::unique_ptr<Core::IOptionsPage> createProvidersSettingsPage(
Providers::ProviderInstanceFactory *instanceFactory,
Providers::ProviderSecretsStore *secrets,
Providers::ProviderLauncher *launcher,
ProvidersPageNavigator *navigator);
} // namespace QodeAssist::Settings

31
settings/SectionBox.cpp Normal file
View File

@@ -0,0 +1,31 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#include "SectionBox.hpp"
#include <QLabel>
#include <QVBoxLayout>
namespace QodeAssist::Settings {
SectionBox::SectionBox(const QString &title, QWidget *parent)
: QWidget(parent)
{
m_title = new QLabel(title, this);
QFont tf = m_title->font();
tf.setBold(true);
m_title->setFont(tf);
m_body = new QWidget(this);
m_bodyLayout = new QVBoxLayout(m_body);
m_bodyLayout->setContentsMargins(0, 0, 0, 0);
m_bodyLayout->setSpacing(4);
auto *outer = new QVBoxLayout(this);
outer->setContentsMargins(0, 4, 0, 4);
outer->setSpacing(4);
outer->addWidget(m_title);
outer->addWidget(m_body, 1);
}
} // namespace QodeAssist::Settings

26
settings/SectionBox.hpp Normal file
View File

@@ -0,0 +1,26 @@
// Copyright (C) 2024-2026 Petr Mironychev
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QWidget>
class QLabel;
class QVBoxLayout;
namespace QodeAssist::Settings {
class SectionBox : public QWidget
{
public:
explicit SectionBox(const QString &title, QWidget *parent = nullptr);
QVBoxLayout *bodyLayout() const { return m_bodyLayout; }
private:
QLabel *m_title = nullptr;
QWidget *m_body = nullptr;
QVBoxLayout *m_bodyLayout = nullptr;
};
} // namespace QodeAssist::Settings