mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-06-12 17:29:13 -04:00
feat: Add agents and agents settings
This commit is contained in:
475
settings/AgentDetailPane.cpp
Normal file
475
settings/AgentDetailPane.cpp
Normal file
@@ -0,0 +1,475 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "AgentDetailPane.hpp"
|
||||
|
||||
#include "SectionBox.hpp"
|
||||
#include "SettingsTheme.hpp"
|
||||
#include "SettingsUiBuilders.hpp"
|
||||
|
||||
#include <ProviderInstance.hpp>
|
||||
#include <ProviderInstanceFactory.hpp>
|
||||
|
||||
#include <QColor>
|
||||
#include <QComboBox>
|
||||
#include <QEvent>
|
||||
#include <QFile>
|
||||
#include <QFont>
|
||||
#include <QFrame>
|
||||
#include <QGridLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QPlainTextEdit>
|
||||
#include <QPushButton>
|
||||
#include <QScopedValueRollback>
|
||||
#include <QToolButton>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr qint64 kRawTomlMaxBytes = 256 * 1024;
|
||||
|
||||
enum class FileReadStatus { Ok, Empty, Truncated, OpenFailed };
|
||||
|
||||
struct FileReadResult
|
||||
{
|
||||
FileReadStatus status = FileReadStatus::OpenFailed;
|
||||
QString content;
|
||||
QString error;
|
||||
};
|
||||
|
||||
FileReadResult readFileTextCapped(const QString &path, qint64 maxBytes)
|
||||
{
|
||||
FileReadResult result;
|
||||
QFile f(path);
|
||||
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
result.status = FileReadStatus::OpenFailed;
|
||||
result.error = f.errorString();
|
||||
return result;
|
||||
}
|
||||
const qint64 size = f.size();
|
||||
const QByteArray bytes = f.read(maxBytes);
|
||||
result.content = QString::fromUtf8(bytes);
|
||||
if (size == 0)
|
||||
result.status = FileReadStatus::Empty;
|
||||
else if (size > maxBytes)
|
||||
result.status = FileReadStatus::Truncated;
|
||||
else
|
||||
result.status = FileReadStatus::Ok;
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AgentDetailPane::AgentDetailPane(QWidget *parent)
|
||||
: QWidget(parent)
|
||||
{
|
||||
m_name = new QLabel(this);
|
||||
QFont nf = m_name->font();
|
||||
nf.setBold(true);
|
||||
nf.setPixelSize(15);
|
||||
m_name->setFont(nf);
|
||||
m_name->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||
|
||||
m_path = new QLabel(this);
|
||||
m_path->setFont(monospaceFont(11));
|
||||
m_path->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||
QPalette pp = m_path->palette();
|
||||
pp.setColor(QPalette::WindowText, pp.color(QPalette::Mid));
|
||||
m_path->setPalette(pp);
|
||||
|
||||
m_openBtn = new QPushButton(tr("Open in editor"), this);
|
||||
m_dupBtn = new QPushButton(tr("Duplicate…"), this);
|
||||
m_deleteBtn = new QPushButton(tr("Delete"), this);
|
||||
connect(m_openBtn, &QPushButton::clicked, this,
|
||||
[this] { if (m_current) emit openInEditorRequested(*m_current); });
|
||||
connect(m_dupBtn, &QPushButton::clicked, this,
|
||||
[this] { if (m_current) emit customizeRequested(*m_current); });
|
||||
connect(m_deleteBtn, &QPushButton::clicked, this,
|
||||
[this] { if (m_current) emit deleteRequested(*m_current); });
|
||||
|
||||
auto *actions = new QHBoxLayout;
|
||||
actions->setContentsMargins(0, 0, 0, 0);
|
||||
actions->setSpacing(6);
|
||||
actions->addWidget(m_openBtn);
|
||||
actions->addWidget(m_dupBtn);
|
||||
actions->addWidget(m_deleteBtn);
|
||||
|
||||
auto *titleRow = new QHBoxLayout;
|
||||
titleRow->setContentsMargins(0, 0, 0, 0);
|
||||
titleRow->setSpacing(8);
|
||||
titleRow->addWidget(m_name);
|
||||
titleRow->addStretch(1);
|
||||
|
||||
auto *headerLeft = new QVBoxLayout;
|
||||
headerLeft->setContentsMargins(0, 0, 0, 0);
|
||||
headerLeft->setSpacing(2);
|
||||
headerLeft->addLayout(titleRow);
|
||||
headerLeft->addWidget(m_path);
|
||||
|
||||
auto *headerRow = new QHBoxLayout;
|
||||
headerRow->setContentsMargins(0, 0, 0, 0);
|
||||
headerRow->setSpacing(8);
|
||||
headerRow->addLayout(headerLeft, 1);
|
||||
headerRow->addLayout(actions);
|
||||
|
||||
auto *headerSep = new QFrame(this);
|
||||
headerSep->setFrameShape(QFrame::HLine);
|
||||
headerSep->setFrameShadow(QFrame::Sunken);
|
||||
|
||||
m_description = new QLabel(this);
|
||||
m_description->setWordWrap(true);
|
||||
m_description->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||
|
||||
auto *identity = new SectionBox(tr("Identity"), this);
|
||||
m_nameValue = makeReadOnlyLine();
|
||||
m_extendsLabel = new QLabel(tr("Extends:"), this);
|
||||
m_extendsLabel->setMinimumWidth(96);
|
||||
m_extendsLabel->setAlignment(Qt::AlignLeft | Qt::AlignTop);
|
||||
m_extendsValue = makeReadOnlyLine();
|
||||
m_descriptionEdit = new QPlainTextEdit(this);
|
||||
m_descriptionEdit->setReadOnly(true);
|
||||
m_descriptionEdit->setMaximumHeight(56);
|
||||
m_tagsValue = makeReadOnlyLine();
|
||||
|
||||
auto *idGrid = new QGridLayout;
|
||||
idGrid->setContentsMargins(0, 0, 0, 0);
|
||||
idGrid->setHorizontalSpacing(8);
|
||||
idGrid->setVerticalSpacing(4);
|
||||
FormBuilder idForm(idGrid);
|
||||
idForm.row(tr("Name:"), m_nameValue);
|
||||
{
|
||||
auto *holder = new QWidget;
|
||||
holder->setLayout(singleField(m_extendsValue));
|
||||
const int row = idForm.currentRow();
|
||||
idGrid->addWidget(m_extendsLabel, row, 0, Qt::AlignTop);
|
||||
idGrid->addWidget(holder, row, 1);
|
||||
m_extendsHolder = holder;
|
||||
idForm = FormBuilder(idGrid, row + 1);
|
||||
}
|
||||
idForm.row(tr("Description:"), m_descriptionEdit);
|
||||
idForm.row(tr("Tags:"), m_tagsValue,
|
||||
tr("Comma-separated. Free-form — used to filter and "
|
||||
"group the agent list."));
|
||||
identity->bodyLayout()->addLayout(idGrid);
|
||||
|
||||
auto *roleSection = new SectionBox(tr("System role"), this);
|
||||
auto *roleHint = makeHintLabel(
|
||||
tr("Prepended to every request as the system message."));
|
||||
m_roleText = new QPlainTextEdit(this);
|
||||
m_roleText->setReadOnly(true);
|
||||
m_roleText->setMinimumHeight(120);
|
||||
roleSection->bodyLayout()->addWidget(roleHint);
|
||||
roleSection->bodyLayout()->addWidget(m_roleText);
|
||||
|
||||
auto *contextSection = new SectionBox(tr("Context"), this);
|
||||
auto *contextHint = makeHintLabel(
|
||||
tr("Jinja2 template rendered with ContextManager bindings into the "
|
||||
"agent.context system-prompt layer. Empty = no context block."));
|
||||
m_contextText = new QPlainTextEdit(this);
|
||||
m_contextText->setReadOnly(true);
|
||||
m_contextText->setFont(monospaceFont(11));
|
||||
m_contextText->setMinimumHeight(120);
|
||||
contextSection->bodyLayout()->addWidget(contextHint);
|
||||
contextSection->bodyLayout()->addWidget(m_contextText);
|
||||
|
||||
auto *connection = new SectionBox(tr("Connection"), this);
|
||||
m_providerCombo = new QComboBox(this);
|
||||
m_providerCombo->setSizeAdjustPolicy(QComboBox::AdjustToContents);
|
||||
m_providerCombo->setEnabled(false);
|
||||
m_endpointValue = makeReadOnlyLine(true);
|
||||
m_modelValue = makeReadOnlyLine(true);
|
||||
|
||||
auto *connGrid = new QGridLayout;
|
||||
connGrid->setContentsMargins(0, 0, 0, 0);
|
||||
connGrid->setHorizontalSpacing(8);
|
||||
connGrid->setVerticalSpacing(4);
|
||||
FormBuilder(connGrid)
|
||||
.row(tr("Provider:"), m_providerCombo,
|
||||
tr("The provider instance this agent uses. URL is "
|
||||
"inherited from the instance."))
|
||||
.row(tr("Endpoint:"), m_endpointValue,
|
||||
tr("Appended to the provider's URL. Blank uses the "
|
||||
"provider default."))
|
||||
.row(tr("Model:"), m_modelValue);
|
||||
connection->bodyLayout()->addLayout(connGrid);
|
||||
|
||||
m_effectiveUrl = new QLabel(this);
|
||||
m_effectiveUrl->setFont(monospaceFont(11));
|
||||
m_effectiveUrl->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||
m_effectiveUrl->setWordWrap(true);
|
||||
m_effectiveUrl->setContentsMargins(6, 4, 6, 4);
|
||||
m_effectiveUrl->setAutoFillBackground(true);
|
||||
connection->bodyLayout()->addWidget(m_effectiveUrl);
|
||||
|
||||
auto *match = new SectionBox(tr("Match"), this);
|
||||
auto *matchHint = makeHintLabel(
|
||||
tr("When a feature slot has multiple bound agents, the first whose "
|
||||
"match rules satisfy the current context wins."));
|
||||
m_filePatternsValue = makeReadOnlyLine(true);
|
||||
auto *matchGrid = new QGridLayout;
|
||||
matchGrid->setContentsMargins(0, 0, 0, 0);
|
||||
matchGrid->setHorizontalSpacing(8);
|
||||
matchGrid->setVerticalSpacing(4);
|
||||
FormBuilder(matchGrid).row(tr("File patterns:"), m_filePatternsValue,
|
||||
tr("Globs, comma-separated. Empty matches every file."));
|
||||
match->bodyLayout()->addWidget(matchHint);
|
||||
match->bodyLayout()->addLayout(matchGrid);
|
||||
|
||||
auto *templ = new SectionBox(tr("Template"), this);
|
||||
auto *templHint = makeHintLabel(
|
||||
tr("Jinja2 template (via inja) rendered to the request body. "
|
||||
"Built-in context: ctx.prefix, ctx.suffix, ctx.history, "
|
||||
"ctx.system_prompt, agent.model."));
|
||||
m_messageFormat = new QPlainTextEdit(this);
|
||||
m_messageFormat->setReadOnly(true);
|
||||
m_messageFormat->setFont(monospaceFont(11));
|
||||
m_messageFormat->setMinimumHeight(140);
|
||||
|
||||
templ->bodyLayout()->addWidget(templHint);
|
||||
auto *mfLabel = new QLabel(tr("message_format:"), this);
|
||||
templ->bodyLayout()->addWidget(mfLabel);
|
||||
templ->bodyLayout()->addWidget(m_messageFormat);
|
||||
|
||||
m_diagnostics = new SectionBox(tr("Load errors"), this);
|
||||
m_diagnosticsView = new QPlainTextEdit(this);
|
||||
m_diagnosticsView->setReadOnly(true);
|
||||
m_diagnosticsView->setMaximumHeight(110);
|
||||
m_diagnosticsView->setFont(monospaceFont(11));
|
||||
m_diagnostics->bodyLayout()->addWidget(m_diagnosticsView);
|
||||
m_diagnostics->setVisible(false);
|
||||
|
||||
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(140);
|
||||
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_description);
|
||||
root->addWidget(identity);
|
||||
root->addWidget(connection);
|
||||
root->addWidget(match);
|
||||
root->addWidget(templ);
|
||||
root->addWidget(roleSection);
|
||||
root->addWidget(contextSection);
|
||||
root->addWidget(m_diagnostics);
|
||||
root->addWidget(m_rawToggle, 0, Qt::AlignLeft);
|
||||
root->addWidget(m_rawToml);
|
||||
root->addStretch(1);
|
||||
|
||||
clear();
|
||||
applyCodePalette();
|
||||
}
|
||||
|
||||
void AgentDetailPane::setInstanceFactory(Providers::ProviderInstanceFactory *factory)
|
||||
{
|
||||
m_instanceFactory = factory;
|
||||
m_providerComboPopulated = false;
|
||||
populateProviderCombo();
|
||||
}
|
||||
|
||||
void AgentDetailPane::populateProviderCombo()
|
||||
{
|
||||
if (m_providerComboPopulated)
|
||||
return;
|
||||
m_providerCombo->clear();
|
||||
m_providerComboHasSentinel = false;
|
||||
if (m_instanceFactory) {
|
||||
for (const auto &inst : m_instanceFactory->instances()) {
|
||||
m_providerCombo->addItem(
|
||||
QStringLiteral("%1 (%2)").arg(inst.name, inst.clientApi), inst.name);
|
||||
}
|
||||
}
|
||||
m_providerComboPopulated = true;
|
||||
}
|
||||
|
||||
void AgentDetailPane::setAgent(const AgentConfig &cfg)
|
||||
{
|
||||
m_currentStorage = cfg;
|
||||
m_current = &m_currentStorage;
|
||||
const bool user = cfg.isUserSource();
|
||||
|
||||
m_name->setText(cfg.name);
|
||||
m_path->setText(cfg.sourcePath);
|
||||
m_description->setText(cfg.description.isEmpty()
|
||||
? tr("No description provided.")
|
||||
: cfg.description);
|
||||
|
||||
m_nameValue->setText(cfg.name);
|
||||
if (cfg.extendsName.isEmpty()) {
|
||||
m_extendsLabel->setVisible(false);
|
||||
m_extendsHolder->setVisible(false);
|
||||
} else {
|
||||
m_extendsLabel->setVisible(true);
|
||||
m_extendsHolder->setVisible(true);
|
||||
m_extendsValue->setText(cfg.extendsName);
|
||||
}
|
||||
m_descriptionEdit->setPlainText(cfg.description);
|
||||
m_tagsValue->setText(cfg.tags.join(QStringLiteral(", ")));
|
||||
|
||||
populateProviderCombo();
|
||||
|
||||
if (m_providerComboHasSentinel) {
|
||||
m_providerCombo->removeItem(0);
|
||||
m_providerComboHasSentinel = false;
|
||||
}
|
||||
|
||||
QString resolvedUrl;
|
||||
if (m_instanceFactory) {
|
||||
if (const auto *inst = m_instanceFactory->instanceByName(cfg.providerInstance))
|
||||
resolvedUrl = inst->url;
|
||||
}
|
||||
const int idx = m_providerCombo->findData(cfg.providerInstance);
|
||||
if (idx >= 0) {
|
||||
m_providerCombo->setCurrentIndex(idx);
|
||||
} else if (!cfg.providerInstance.isEmpty()) {
|
||||
m_providerCombo->insertItem(
|
||||
0, tr("%1 (missing — not in provider library)")
|
||||
.arg(cfg.providerInstance),
|
||||
cfg.providerInstance);
|
||||
m_providerCombo->setCurrentIndex(0);
|
||||
m_providerComboHasSentinel = true;
|
||||
}
|
||||
|
||||
m_endpointValue->setText(cfg.endpoint);
|
||||
m_endpointValue->setPlaceholderText(tr("(provider default)"));
|
||||
m_modelValue->setText(cfg.model);
|
||||
|
||||
const QString eff = resolvedUrl + cfg.endpoint;
|
||||
m_effectiveUrl->setText(
|
||||
eff.isEmpty()
|
||||
? tr("# effective request line\n(unknown — provider instance not found)")
|
||||
: QStringLiteral("# %1\nPOST %2")
|
||||
.arg(tr("effective request line"), eff));
|
||||
|
||||
m_roleText->setPlainText(
|
||||
cfg.role.isEmpty() ? tr("(no system role set)") : cfg.role);
|
||||
m_contextText->setPlainText(
|
||||
cfg.context.isEmpty() ? tr("(no context block)") : cfg.context);
|
||||
|
||||
m_filePatternsValue->setText(cfg.match.filePatterns.join(QStringLiteral(", ")));
|
||||
m_filePatternsValue->setPlaceholderText(tr("(matches every file)"));
|
||||
|
||||
m_messageFormat->setPlainText(
|
||||
cfg.messageFormat.isEmpty() ? tr("(inherited from parent / none)")
|
||||
: cfg.messageFormat);
|
||||
|
||||
const FileReadResult raw = readFileTextCapped(cfg.sourcePath, kRawTomlMaxBytes);
|
||||
switch (raw.status) {
|
||||
case FileReadStatus::Ok:
|
||||
m_rawToml->setPlainText(raw.content);
|
||||
break;
|
||||
case FileReadStatus::Truncated:
|
||||
m_rawToml->setPlainText(
|
||||
raw.content + QStringLiteral("\n\n")
|
||||
+ tr("(truncated at %1 bytes)").arg(kRawTomlMaxBytes));
|
||||
break;
|
||||
case FileReadStatus::Empty:
|
||||
m_rawToml->setPlainText(tr("(source file is empty)"));
|
||||
break;
|
||||
case FileReadStatus::OpenFailed:
|
||||
m_rawToml->setPlainText(tr("(source file unavailable: %1)").arg(raw.error));
|
||||
break;
|
||||
}
|
||||
|
||||
m_openBtn->setEnabled(user);
|
||||
m_openBtn->setToolTip(user ? QString()
|
||||
: tr("Bundled agents are read-only — "
|
||||
"duplicate to edit."));
|
||||
m_deleteBtn->setEnabled(user);
|
||||
m_deleteBtn->setToolTip(user ? QString()
|
||||
: tr("Bundled agents cannot be deleted."));
|
||||
m_dupBtn->setEnabled(true);
|
||||
applyCodePalette();
|
||||
}
|
||||
|
||||
void AgentDetailPane::clear()
|
||||
{
|
||||
m_currentStorage = AgentConfig{};
|
||||
m_current = nullptr;
|
||||
m_name->setText(tr("Select an agent"));
|
||||
m_path->clear();
|
||||
m_description->setText(tr("Pick an agent from the list to see its details."));
|
||||
m_nameValue->clear();
|
||||
m_extendsLabel->setVisible(false);
|
||||
m_extendsHolder->setVisible(false);
|
||||
m_descriptionEdit->clear();
|
||||
m_tagsValue->clear();
|
||||
if (m_providerComboHasSentinel) {
|
||||
m_providerCombo->removeItem(0);
|
||||
m_providerComboHasSentinel = false;
|
||||
}
|
||||
m_providerCombo->setCurrentIndex(-1);
|
||||
m_endpointValue->clear();
|
||||
m_modelValue->clear();
|
||||
m_effectiveUrl->clear();
|
||||
m_roleText->clear();
|
||||
m_contextText->clear();
|
||||
m_filePatternsValue->clear();
|
||||
m_messageFormat->clear();
|
||||
m_rawToml->clear();
|
||||
m_openBtn->setEnabled(false);
|
||||
m_dupBtn->setEnabled(false);
|
||||
m_deleteBtn->setEnabled(false);
|
||||
}
|
||||
|
||||
void AgentDetailPane::setLoadDiagnostics(const QStringList &errors, const QStringList &warnings)
|
||||
{
|
||||
QStringList lines;
|
||||
for (const QString &e : errors)
|
||||
lines << tr("error: %1").arg(e);
|
||||
for (const QString &w : warnings)
|
||||
lines << tr("warning: %1").arg(w);
|
||||
m_diagnostics->setVisible(!lines.isEmpty());
|
||||
m_diagnosticsView->setPlainText(lines.join(QLatin1Char('\n')));
|
||||
}
|
||||
|
||||
void AgentDetailPane::changeEvent(QEvent *event)
|
||||
{
|
||||
QWidget::changeEvent(event);
|
||||
if (m_inApplyPalette)
|
||||
return;
|
||||
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
|
||||
applyCodePalette();
|
||||
}
|
||||
|
||||
QLineEdit *AgentDetailPane::makeReadOnlyLine(bool mono)
|
||||
{
|
||||
auto *e = new QLineEdit(this);
|
||||
e->setReadOnly(true);
|
||||
if (mono)
|
||||
e->setFont(monospaceFont(11));
|
||||
return e;
|
||||
}
|
||||
|
||||
void AgentDetailPane::applyCodePalette()
|
||||
{
|
||||
QScopedValueRollback<bool> guard(m_inApplyPalette, true);
|
||||
const Theme theme = themeFor(palette());
|
||||
QPalette p = m_effectiveUrl->palette();
|
||||
p.setColor(QPalette::Window, QColor(theme.codeBg));
|
||||
p.setColor(QPalette::WindowText, palette().color(QPalette::Text));
|
||||
m_effectiveUrl->setPalette(p);
|
||||
m_effectiveUrl->setStyleSheet(QStringLiteral(
|
||||
"QLabel { background:%1; border:1px solid %2; }")
|
||||
.arg(theme.codeBg, theme.rowSeparator));
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
91
settings/AgentDetailPane.hpp
Normal file
91
settings/AgentDetailPane.hpp
Normal file
@@ -0,0 +1,91 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QPointer>
|
||||
#include <QStringList>
|
||||
#include <QWidget>
|
||||
|
||||
#include <AgentConfig.hpp>
|
||||
|
||||
class QComboBox;
|
||||
class QLabel;
|
||||
class QLineEdit;
|
||||
class QPlainTextEdit;
|
||||
class QPushButton;
|
||||
class QToolButton;
|
||||
|
||||
namespace QodeAssist::Providers {
|
||||
class ProviderInstanceFactory;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
class SectionBox;
|
||||
|
||||
class AgentDetailPane : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit AgentDetailPane(QWidget *parent = nullptr);
|
||||
|
||||
void setInstanceFactory(Providers::ProviderInstanceFactory *factory);
|
||||
void setAgent(const AgentConfig &cfg);
|
||||
void clear();
|
||||
void setLoadDiagnostics(const QStringList &errors, const QStringList &warnings);
|
||||
|
||||
signals:
|
||||
void openInEditorRequested(const AgentConfig &cfg);
|
||||
void customizeRequested(const AgentConfig &cfg);
|
||||
void deleteRequested(const AgentConfig &cfg);
|
||||
|
||||
protected:
|
||||
void changeEvent(QEvent *event) override;
|
||||
|
||||
private:
|
||||
QLineEdit *makeReadOnlyLine(bool mono = false);
|
||||
void applyCodePalette();
|
||||
void populateProviderCombo();
|
||||
|
||||
bool m_inApplyPalette = false;
|
||||
bool m_providerComboPopulated = false;
|
||||
bool m_providerComboHasSentinel = false;
|
||||
|
||||
AgentConfig m_currentStorage;
|
||||
const AgentConfig *m_current = nullptr;
|
||||
|
||||
QLabel *m_name = nullptr;
|
||||
QLabel *m_path = nullptr;
|
||||
QPushButton *m_openBtn = nullptr;
|
||||
QPushButton *m_dupBtn = nullptr;
|
||||
QPushButton *m_deleteBtn = nullptr;
|
||||
QLabel *m_description = nullptr;
|
||||
|
||||
QLineEdit *m_nameValue = nullptr;
|
||||
QLabel *m_extendsLabel = nullptr;
|
||||
QWidget *m_extendsHolder = nullptr;
|
||||
QLineEdit *m_extendsValue = nullptr;
|
||||
QPlainTextEdit *m_descriptionEdit = nullptr;
|
||||
QLineEdit *m_tagsValue = nullptr;
|
||||
|
||||
QComboBox *m_providerCombo = nullptr;
|
||||
QPointer<Providers::ProviderInstanceFactory> m_instanceFactory;
|
||||
QLineEdit *m_endpointValue = nullptr;
|
||||
QLineEdit *m_modelValue = nullptr;
|
||||
QLabel *m_effectiveUrl = nullptr;
|
||||
|
||||
QLineEdit *m_filePatternsValue = nullptr;
|
||||
|
||||
QPlainTextEdit *m_roleText = nullptr;
|
||||
QPlainTextEdit *m_contextText = nullptr;
|
||||
QPlainTextEdit *m_messageFormat = nullptr;
|
||||
|
||||
SectionBox *m_diagnostics = nullptr;
|
||||
QPlainTextEdit *m_diagnosticsView = nullptr;
|
||||
|
||||
QToolButton *m_rawToggle = nullptr;
|
||||
QPlainTextEdit *m_rawToml = nullptr;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
120
settings/AgentDuplicator.cpp
Normal file
120
settings/AgentDuplicator.cpp
Normal file
@@ -0,0 +1,120 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "AgentDuplicator.hpp"
|
||||
|
||||
#include <Agent.hpp>
|
||||
#include <AgentConfig.hpp>
|
||||
#include <AgentFactory.hpp>
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QSaveFile>
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
namespace {
|
||||
|
||||
QString tomlEscape(const QString &s)
|
||||
{
|
||||
QString out;
|
||||
out.reserve(s.size());
|
||||
for (QChar c : s) {
|
||||
switch (c.unicode()) {
|
||||
case '\\': out += QLatin1String("\\\\"); break;
|
||||
case '"': out += QLatin1String("\\\""); break;
|
||||
case '\n': out += QLatin1String("\\n"); break;
|
||||
case '\r': out += QLatin1String("\\r"); break;
|
||||
case '\t': out += QLatin1String("\\t"); break;
|
||||
default: out += c;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
constexpr int kMaxUniqueAttempts = 1000;
|
||||
|
||||
QString uniqueFilename(const QString &userDir, const QString &parentBasename)
|
||||
{
|
||||
QString fileName = parentBasename + QStringLiteral("_custom.toml");
|
||||
for (int n = 2; n < kMaxUniqueAttempts
|
||||
&& QFile::exists(QDir(userDir).filePath(fileName));
|
||||
++n)
|
||||
fileName = QStringLiteral("%1_custom_%2.toml").arg(parentBasename).arg(n);
|
||||
return QDir(userDir).filePath(fileName);
|
||||
}
|
||||
|
||||
QString uniqueName(const QString &parentName, const AgentFactory &factory)
|
||||
{
|
||||
QString newName = QStringLiteral("%1 (Custom)").arg(parentName);
|
||||
for (int n = 2; n < kMaxUniqueAttempts && factory.configByName(newName); ++n)
|
||||
newName = QStringLiteral("%1 (Custom %2)").arg(parentName).arg(n);
|
||||
return newName;
|
||||
}
|
||||
|
||||
QString trUser(const char *src)
|
||||
{
|
||||
return QCoreApplication::translate("QodeAssist::Settings::AgentDuplicator", src);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AgentDuplicateResult duplicateAgentInUserDir(
|
||||
const AgentConfig &parent, const AgentFactory &factory)
|
||||
{
|
||||
AgentDuplicateResult result;
|
||||
if (parent.name.trimmed().isEmpty()) {
|
||||
result.error = trUser("Parent agent has no name; cannot duplicate.");
|
||||
return result;
|
||||
}
|
||||
|
||||
const QString userDir = AgentFactory::userAgentsDir();
|
||||
if (!QDir().mkpath(userDir)) {
|
||||
result.error = trUser("Cannot create user agents folder: %1").arg(userDir);
|
||||
return result;
|
||||
}
|
||||
|
||||
const QString parentBasename = QFileInfo(parent.sourcePath).baseName();
|
||||
result.filePath = uniqueFilename(userDir, parentBasename);
|
||||
if (QFile::exists(result.filePath)) {
|
||||
result.error = trUser("Could not find a free filename after %1 attempts.")
|
||||
.arg(kMaxUniqueAttempts);
|
||||
return result;
|
||||
}
|
||||
result.newName = uniqueName(parent.name, factory);
|
||||
if (factory.configByName(result.newName)) {
|
||||
result.error = trUser("Could not find a free agent name after %1 attempts.")
|
||||
.arg(kMaxUniqueAttempts);
|
||||
return result;
|
||||
}
|
||||
|
||||
QSaveFile f(result.filePath);
|
||||
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||
result.error = trUser("Cannot create %1: %2").arg(result.filePath, f.errorString());
|
||||
return result;
|
||||
}
|
||||
const QString description
|
||||
= QStringLiteral("User customization of '%1'. Override fields below to taste; "
|
||||
"values not overridden are inherited from the parent.")
|
||||
.arg(parent.name);
|
||||
const QString body = QStringLiteral(
|
||||
"schema_version = 1\n"
|
||||
"name = \"%1\"\n"
|
||||
"extends = \"%2\"\n"
|
||||
"description = \"%3\"\n")
|
||||
.arg(tomlEscape(result.newName),
|
||||
tomlEscape(parent.name),
|
||||
tomlEscape(description));
|
||||
const QByteArray payload = body.toUtf8();
|
||||
if (f.write(payload) != payload.size() || !f.commit()) {
|
||||
result.error = trUser("Failed to write %1: %2").arg(result.filePath, f.errorString());
|
||||
return result;
|
||||
}
|
||||
result.ok = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
26
settings/AgentDuplicator.hpp
Normal file
26
settings/AgentDuplicator.hpp
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
namespace QodeAssist {
|
||||
class AgentFactory;
|
||||
struct AgentConfig;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
struct AgentDuplicateResult
|
||||
{
|
||||
bool ok = false;
|
||||
QString filePath;
|
||||
QString newName;
|
||||
QString error;
|
||||
};
|
||||
|
||||
AgentDuplicateResult duplicateAgentInUserDir(
|
||||
const AgentConfig &parent, const AgentFactory &factory);
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
127
settings/AgentListItem.cpp
Normal file
127
settings/AgentListItem.cpp
Normal file
@@ -0,0 +1,127 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "AgentListItem.hpp"
|
||||
|
||||
#include "SettingsTheme.hpp"
|
||||
#include "TagChip.hpp"
|
||||
|
||||
#include <QEvent>
|
||||
#include <QFont>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QMouseEvent>
|
||||
#include <QPalette>
|
||||
#include <QScopedValueRollback>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
AgentListItem::AgentListItem(const AgentConfig &cfg, QWidget *parent)
|
||||
: QFrame(parent)
|
||||
, m_name(cfg.name)
|
||||
{
|
||||
setObjectName(QStringLiteral("AgentListItem"));
|
||||
setFrameShape(QFrame::NoFrame);
|
||||
setAutoFillBackground(true);
|
||||
setCursor(Qt::PointingHandCursor);
|
||||
|
||||
auto *dot = new QLabel(QStringLiteral("●"), this);
|
||||
QFont df = dot->font();
|
||||
df.setPixelSize(10);
|
||||
dot->setFont(df);
|
||||
QPalette dp = dot->palette();
|
||||
dp.setColor(QPalette::WindowText, dp.color(QPalette::Mid));
|
||||
dot->setPalette(dp);
|
||||
|
||||
auto *nameLbl = new QLabel(cfg.name, this);
|
||||
QFont nf = nameLbl->font();
|
||||
nf.setBold(true);
|
||||
nf.setPixelSize(12);
|
||||
nameLbl->setFont(nf);
|
||||
|
||||
auto *headerRow = new QHBoxLayout;
|
||||
headerRow->setContentsMargins(0, 0, 0, 0);
|
||||
headerRow->setSpacing(6);
|
||||
headerRow->addWidget(dot, 0, Qt::AlignVCenter);
|
||||
headerRow->addWidget(nameLbl, 1);
|
||||
|
||||
auto *col = new QVBoxLayout;
|
||||
col->setContentsMargins(0, 0, 0, 0);
|
||||
col->setSpacing(2);
|
||||
col->addLayout(headerRow);
|
||||
|
||||
if (!cfg.model.isEmpty()) {
|
||||
auto *model = new QLabel(cfg.model, this);
|
||||
model->setFont(monospaceFont(11));
|
||||
model->setContentsMargins(16, 0, 0, 0);
|
||||
QPalette mp = model->palette();
|
||||
mp.setColor(QPalette::WindowText, mp.color(QPalette::Mid));
|
||||
model->setPalette(mp);
|
||||
col->addWidget(model);
|
||||
}
|
||||
|
||||
if (!cfg.tags.isEmpty()) {
|
||||
auto *tagsHolder = new QWidget(this);
|
||||
auto *tagsLay = new QHBoxLayout(tagsHolder);
|
||||
tagsLay->setContentsMargins(16, 2, 0, 0);
|
||||
tagsLay->setSpacing(3);
|
||||
for (const QString &t : cfg.tags) {
|
||||
auto *chip = new TagChip(t, -1, tagsHolder);
|
||||
connect(chip, &TagChip::clicked, this, &AgentListItem::tagClicked);
|
||||
m_chips.append(chip);
|
||||
tagsLay->addWidget(chip);
|
||||
}
|
||||
tagsLay->addStretch(1);
|
||||
col->addWidget(tagsHolder);
|
||||
}
|
||||
|
||||
auto *outer = new QVBoxLayout(this);
|
||||
outer->setContentsMargins(8, 6, 8, 6);
|
||||
outer->setSpacing(0);
|
||||
outer->addLayout(col);
|
||||
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
void AgentListItem::setSelected(bool selected)
|
||||
{
|
||||
if (m_selected == selected)
|
||||
return;
|
||||
m_selected = selected;
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
void AgentListItem::setActiveTags(const QSet<QString> &active)
|
||||
{
|
||||
for (auto *chip : m_chips)
|
||||
chip->setActive(active.contains(chip->tag()));
|
||||
}
|
||||
|
||||
void AgentListItem::mouseReleaseEvent(QMouseEvent *event)
|
||||
{
|
||||
if (event->button() == Qt::LeftButton)
|
||||
emit clicked(m_name);
|
||||
QFrame::mouseReleaseEvent(event);
|
||||
}
|
||||
|
||||
void AgentListItem::changeEvent(QEvent *event)
|
||||
{
|
||||
QFrame::changeEvent(event);
|
||||
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
void AgentListItem::applyTheme()
|
||||
{
|
||||
if (m_inApplyTheme)
|
||||
return;
|
||||
QScopedValueRollback<bool> guard(m_inApplyTheme, true);
|
||||
const Theme theme = themeFor(palette());
|
||||
setStyleSheet(QStringLiteral(
|
||||
"#AgentListItem { background:%1; border-top:1px solid %2; }")
|
||||
.arg(m_selected ? theme.rowSelectedBg : QStringLiteral("transparent"),
|
||||
theme.rowSeparator));
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
44
settings/AgentListItem.hpp
Normal file
44
settings/AgentListItem.hpp
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QFrame>
|
||||
#include <QList>
|
||||
#include <QSet>
|
||||
#include <QString>
|
||||
|
||||
#include <AgentConfig.hpp>
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
class TagChip;
|
||||
|
||||
class AgentListItem : public QFrame
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit AgentListItem(const AgentConfig &cfg, QWidget *parent = nullptr);
|
||||
|
||||
QString agentName() const { return m_name; }
|
||||
void setSelected(bool selected);
|
||||
void setActiveTags(const QSet<QString> &active);
|
||||
|
||||
signals:
|
||||
void clicked(const QString &name);
|
||||
void tagClicked(const QString &tag);
|
||||
|
||||
protected:
|
||||
void mouseReleaseEvent(QMouseEvent *event) override;
|
||||
void changeEvent(QEvent *event) override;
|
||||
|
||||
private:
|
||||
void applyTheme();
|
||||
|
||||
QString m_name;
|
||||
bool m_selected = false;
|
||||
bool m_inApplyTheme = false;
|
||||
QList<TagChip *> m_chips;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
236
settings/AgentListPane.cpp
Normal file
236
settings/AgentListPane.cpp
Normal file
@@ -0,0 +1,236 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "AgentListPane.hpp"
|
||||
|
||||
#include "AgentListItem.hpp"
|
||||
#include "SettingsTheme.hpp"
|
||||
#include "SettingsUiBuilders.hpp"
|
||||
#include "TagFilterStrip.hpp"
|
||||
|
||||
#include <Agent.hpp>
|
||||
#include <AgentFactory.hpp>
|
||||
|
||||
#include <QEvent>
|
||||
#include <QFont>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QMap>
|
||||
#include <QPalette>
|
||||
#include <QScrollArea>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
#include <algorithm>
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
AgentListPane::AgentListPane(AgentFactory *factory, QWidget *parent)
|
||||
: QFrame(parent)
|
||||
, m_factory(factory)
|
||||
{
|
||||
setFrameShape(QFrame::StyledPanel);
|
||||
|
||||
m_filterEdit = new QLineEdit(this);
|
||||
m_filterEdit->setPlaceholderText(tr("Filter agents…"));
|
||||
m_filterEdit->setClearButtonEnabled(true);
|
||||
|
||||
auto *filterRow = new QHBoxLayout;
|
||||
filterRow->setContentsMargins(6, 6, 6, 6);
|
||||
filterRow->addWidget(m_filterEdit, 1);
|
||||
m_filterHolder = new QWidget(this);
|
||||
m_filterHolder->setObjectName(QStringLiteral("FilterHolder"));
|
||||
m_filterHolder->setLayout(filterRow);
|
||||
m_filterHolder->setAutoFillBackground(true);
|
||||
|
||||
m_tagStrip = new TagFilterStrip(this);
|
||||
|
||||
m_listScroll = new QScrollArea(this);
|
||||
m_listScroll->setWidgetResizable(true);
|
||||
m_listScroll->setFrameShape(QFrame::NoFrame);
|
||||
m_listScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
|
||||
auto *outer = new QVBoxLayout(this);
|
||||
outer->setContentsMargins(0, 0, 0, 0);
|
||||
outer->setSpacing(0);
|
||||
outer->addWidget(m_filterHolder);
|
||||
outer->addWidget(m_tagStrip);
|
||||
outer->addWidget(m_listScroll, 1);
|
||||
|
||||
m_filterDebounce = new QTimer(this);
|
||||
m_filterDebounce->setSingleShot(true);
|
||||
m_filterDebounce->setInterval(100);
|
||||
connect(m_filterDebounce, &QTimer::timeout, this, &AgentListPane::rebuildList);
|
||||
connect(m_filterEdit, &QLineEdit::textChanged, this,
|
||||
[this](const QString &) { m_filterDebounce->start(); });
|
||||
|
||||
connect(m_tagStrip, &TagFilterStrip::activeTagsChanged, this,
|
||||
[this](const QSet<QString> &) { rebuildList(); },
|
||||
Qt::QueuedConnection);
|
||||
|
||||
applyFilterHolderTheme();
|
||||
}
|
||||
|
||||
void AgentListPane::selectByName(const QString &name)
|
||||
{
|
||||
if (name.isEmpty())
|
||||
return;
|
||||
setCurrentNameInternal(name, false);
|
||||
rebuildList();
|
||||
for (auto *item : m_rows) {
|
||||
if (item->agentName() == name) {
|
||||
QTimer::singleShot(0, this, [this, item] {
|
||||
m_listScroll->ensureWidgetVisible(item, 0, 60);
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void AgentListPane::refresh()
|
||||
{
|
||||
QMap<QString, int> counts;
|
||||
for (const auto *a : visibleAgents())
|
||||
for (const QString &t : a->tags)
|
||||
counts[t] += 1;
|
||||
m_tagStrip->setAvailableTags(counts);
|
||||
rebuildList();
|
||||
}
|
||||
|
||||
void AgentListPane::changeEvent(QEvent *event)
|
||||
{
|
||||
QFrame::changeEvent(event);
|
||||
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
|
||||
applyFilterHolderTheme();
|
||||
}
|
||||
|
||||
void AgentListPane::applyFilterHolderTheme()
|
||||
{
|
||||
if (!m_filterHolder)
|
||||
return;
|
||||
const Theme theme = themeFor(palette());
|
||||
m_filterHolder->setStyleSheet(
|
||||
QStringLiteral("QWidget#FilterHolder { background:%1;"
|
||||
" border-bottom:1px solid %2; }")
|
||||
.arg(theme.listHeaderBg, theme.rowSeparator));
|
||||
}
|
||||
|
||||
std::vector<const AgentConfig *> AgentListPane::visibleAgents() const
|
||||
{
|
||||
std::vector<const AgentConfig *> out;
|
||||
if (!m_factory)
|
||||
return out;
|
||||
for (const auto &a : m_factory->configs()) {
|
||||
if (a.hidden)
|
||||
continue;
|
||||
out.push_back(&a);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool AgentListPane::matchesFilters(const AgentConfig &a, const QString &lowerFilter) const
|
||||
{
|
||||
if (!lowerFilter.isEmpty()
|
||||
&& !(a.name + QLatin1Char(' ') + a.model).toLower().contains(lowerFilter))
|
||||
return false;
|
||||
const QSet<QString> &active = m_tagStrip->activeTags();
|
||||
for (const QString &t : active)
|
||||
if (!a.tags.contains(t))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void AgentListPane::rebuildList()
|
||||
{
|
||||
const QString lowerFilter = m_filterEdit->text().trimmed().toLower();
|
||||
|
||||
std::vector<const AgentConfig *> userAgents;
|
||||
std::vector<const AgentConfig *> bundledAgents;
|
||||
for (const auto *a : visibleAgents()) {
|
||||
if (!matchesFilters(*a, lowerFilter))
|
||||
continue;
|
||||
if (a->isUserSource())
|
||||
userAgents.push_back(a);
|
||||
else
|
||||
bundledAgents.push_back(a);
|
||||
}
|
||||
auto byName = [](const AgentConfig *a, const AgentConfig *b) {
|
||||
return a->name.localeAwareCompare(b->name) < 0;
|
||||
};
|
||||
std::sort(userAgents.begin(), userAgents.end(), byName);
|
||||
std::sort(bundledAgents.begin(), bundledAgents.end(), byName);
|
||||
|
||||
QList<AgentListItem *> newRows;
|
||||
auto *content = new QWidget;
|
||||
content->setAutoFillBackground(true);
|
||||
auto *contentLayout = new QVBoxLayout(content);
|
||||
contentLayout->setContentsMargins(0, 0, 0, 0);
|
||||
contentLayout->setSpacing(0);
|
||||
|
||||
const QSet<QString> &activeTags = m_tagStrip->activeTags();
|
||||
auto addAgents = [&](const std::vector<const AgentConfig *> &agents) {
|
||||
for (const AgentConfig *cfg : agents) {
|
||||
auto *item = new AgentListItem(*cfg, content);
|
||||
item->setSelected(cfg->name == m_currentName);
|
||||
item->setActiveTags(activeTags);
|
||||
connect(item, &AgentListItem::clicked, this, &AgentListPane::onRowClicked);
|
||||
connect(item, &AgentListItem::tagClicked, this,
|
||||
[this](const QString &) { refresh(); },
|
||||
Qt::QueuedConnection);
|
||||
contentLayout->addWidget(item);
|
||||
newRows.append(item);
|
||||
}
|
||||
};
|
||||
|
||||
if (!userAgents.empty()) {
|
||||
contentLayout->addWidget(makeSectionHeader(tr("User"), content));
|
||||
addAgents(userAgents);
|
||||
}
|
||||
if (!bundledAgents.empty()) {
|
||||
contentLayout->addWidget(makeSectionHeader(tr("Bundled"), content));
|
||||
addAgents(bundledAgents);
|
||||
}
|
||||
if (newRows.isEmpty()) {
|
||||
auto *empty = new QLabel(tr("No agents match these filters."), content);
|
||||
empty->setAlignment(Qt::AlignCenter);
|
||||
empty->setContentsMargins(10, 16, 10, 16);
|
||||
QPalette ep = empty->palette();
|
||||
ep.setColor(QPalette::WindowText, ep.color(QPalette::Mid));
|
||||
empty->setPalette(ep);
|
||||
contentLayout->addWidget(empty);
|
||||
}
|
||||
contentLayout->addStretch(1);
|
||||
|
||||
m_rows = newRows;
|
||||
m_listScroll->setWidget(content);
|
||||
|
||||
const AgentConfig *current
|
||||
= m_currentName.isEmpty() || !m_factory
|
||||
? nullptr
|
||||
: m_factory->configByName(m_currentName);
|
||||
if (!current && !m_rows.isEmpty()) {
|
||||
const QString fallback = m_rows.front()->agentName();
|
||||
m_rows.front()->setSelected(true);
|
||||
setCurrentNameInternal(fallback, /*emitSignal*/ true);
|
||||
return;
|
||||
}
|
||||
emit currentAgentChanged(m_currentName);
|
||||
}
|
||||
|
||||
void AgentListPane::onRowClicked(const QString &name)
|
||||
{
|
||||
setCurrentNameInternal(name, /*emitSignal*/ true);
|
||||
}
|
||||
|
||||
void AgentListPane::setCurrentNameInternal(const QString &name, bool emitSignal)
|
||||
{
|
||||
if (name == m_currentName)
|
||||
return;
|
||||
m_currentName = name;
|
||||
for (auto *item : m_rows)
|
||||
item->setSelected(item->agentName() == name);
|
||||
if (emitSignal)
|
||||
emit currentAgentChanged(m_currentName);
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
62
settings/AgentListPane.hpp
Normal file
62
settings/AgentListPane.hpp
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QFrame>
|
||||
#include <QList>
|
||||
#include <QSet>
|
||||
#include <QString>
|
||||
#include <vector>
|
||||
|
||||
#include <AgentConfig.hpp>
|
||||
|
||||
class QLineEdit;
|
||||
class QScrollArea;
|
||||
class QTimer;
|
||||
class QVBoxLayout;
|
||||
|
||||
namespace QodeAssist {
|
||||
class AgentFactory;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
class AgentListItem;
|
||||
class TagFilterStrip;
|
||||
|
||||
class AgentListPane : public QFrame
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit AgentListPane(AgentFactory *factory, QWidget *parent = nullptr);
|
||||
|
||||
QString currentName() const { return m_currentName; }
|
||||
void selectByName(const QString &name);
|
||||
void refresh();
|
||||
|
||||
signals:
|
||||
void currentAgentChanged(const QString &name);
|
||||
|
||||
protected:
|
||||
void changeEvent(QEvent *event) override;
|
||||
|
||||
private:
|
||||
void rebuildList();
|
||||
void applyFilterHolderTheme();
|
||||
bool matchesFilters(const AgentConfig &a, const QString &lowerFilter) const;
|
||||
std::vector<const AgentConfig *> visibleAgents() const;
|
||||
void setCurrentNameInternal(const QString &name, bool emitSignal);
|
||||
void onRowClicked(const QString &name);
|
||||
|
||||
AgentFactory *m_factory;
|
||||
QLineEdit *m_filterEdit = nullptr;
|
||||
QTimer *m_filterDebounce = nullptr;
|
||||
QWidget *m_filterHolder = nullptr;
|
||||
TagFilterStrip *m_tagStrip = nullptr;
|
||||
QScrollArea *m_listScroll = nullptr;
|
||||
QList<AgentListItem *> m_rows;
|
||||
QString m_currentName;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
280
settings/AgentsSettingsPage.cpp
Normal file
280
settings/AgentsSettingsPage.cpp
Normal file
@@ -0,0 +1,280 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "AgentsSettingsPage.hpp"
|
||||
|
||||
#include "AgentDetailPane.hpp"
|
||||
#include "AgentDuplicator.hpp"
|
||||
#include "AgentListPane.hpp"
|
||||
#include "SettingsTheme.hpp"
|
||||
#include "SettingsConstants.hpp"
|
||||
|
||||
#include <coreplugin/dialogs/ioptionspage.h>
|
||||
#include <coreplugin/editormanager/editormanager.h>
|
||||
|
||||
#include <utils/filepath.h>
|
||||
|
||||
#include <QDesktopServices>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFont>
|
||||
#include <QFontMetrics>
|
||||
#include <QFrame>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QMessageBox>
|
||||
#include <QPalette>
|
||||
#include <QPointer>
|
||||
#include <QPushButton>
|
||||
#include <QScrollArea>
|
||||
#include <QSplitter>
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include <Agent.hpp>
|
||||
#include <AgentFactory.hpp>
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
AgentsPageNavigator::AgentsPageNavigator(QObject *parent)
|
||||
: QObject(parent)
|
||||
{}
|
||||
|
||||
void AgentsPageNavigator::requestSelectAgent(const QString &name)
|
||||
{
|
||||
m_pending = name;
|
||||
emit selectAgentRequested(name);
|
||||
}
|
||||
|
||||
QString AgentsPageNavigator::takePendingSelection()
|
||||
{
|
||||
QString p = m_pending;
|
||||
m_pending.clear();
|
||||
return p;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
class AgentsWidget : public Core::IOptionsPageWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit AgentsWidget(AgentFactory *agentFactory, AgentsPageNavigator *navigator)
|
||||
: m_agentFactory(agentFactory)
|
||||
, m_navigator(navigator)
|
||||
{
|
||||
Q_ASSERT(m_agentFactory);
|
||||
|
||||
m_titleLabel = new QLabel(tr("Agents"), this);
|
||||
QFont tf = m_titleLabel->font();
|
||||
tf.setBold(true);
|
||||
tf.setPixelSize(13);
|
||||
m_titleLabel->setFont(tf);
|
||||
|
||||
m_reload = new QPushButton(tr("Reload from disk"), this);
|
||||
m_openUserDir = new QPushButton(tr("Open agents folder"), this);
|
||||
|
||||
m_userPathLabel = new QLabel(this);
|
||||
m_userPathLabel->setFont(monospaceFont(11));
|
||||
QPalette mutedPal = m_userPathLabel->palette();
|
||||
mutedPal.setColor(QPalette::WindowText, mutedPal.color(QPalette::Mid));
|
||||
m_userPathLabel->setPalette(mutedPal);
|
||||
m_userPathLabel->setMaximumWidth(260);
|
||||
|
||||
auto *headerRow = new QHBoxLayout;
|
||||
headerRow->setContentsMargins(0, 0, 0, 0);
|
||||
headerRow->setSpacing(8);
|
||||
headerRow->addWidget(m_titleLabel);
|
||||
headerRow->addStretch(1);
|
||||
headerRow->addWidget(m_reload);
|
||||
headerRow->addWidget(m_userPathLabel);
|
||||
headerRow->addWidget(m_openUserDir);
|
||||
|
||||
auto *headerSep = new QFrame(this);
|
||||
headerSep->setFrameShape(QFrame::HLine);
|
||||
headerSep->setFrameShadow(QFrame::Sunken);
|
||||
|
||||
m_listPane = new AgentListPane(m_agentFactory, this);
|
||||
|
||||
m_detail = new AgentDetailPane(this);
|
||||
m_detail->setInstanceFactory(m_agentFactory->instanceFactory());
|
||||
m_detailScroll = new QScrollArea(this);
|
||||
m_detailScroll->setWidgetResizable(true);
|
||||
m_detailScroll->setFrameShape(QFrame::StyledPanel);
|
||||
m_detailScroll->setWidget(m_detail);
|
||||
|
||||
auto *splitter = new QSplitter(Qt::Horizontal, this);
|
||||
splitter->addWidget(m_listPane);
|
||||
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_reload, &QPushButton::clicked, this, &AgentsWidget::reloadFromDisk);
|
||||
connect(m_openUserDir, &QPushButton::clicked, this, [] {
|
||||
const QString dir = QodeAssist::AgentFactory::userAgentsDir();
|
||||
QDir().mkpath(dir);
|
||||
QDesktopServices::openUrl(QUrl::fromLocalFile(dir));
|
||||
});
|
||||
|
||||
connect(m_listPane, &AgentListPane::currentAgentChanged, this,
|
||||
[this](const QString &name) {
|
||||
if (const AgentConfig *cfg = m_agentFactory->configByName(name))
|
||||
m_detail->setAgent(*cfg);
|
||||
else
|
||||
m_detail->clear();
|
||||
});
|
||||
|
||||
connect(m_detail, &AgentDetailPane::openInEditorRequested,
|
||||
this, &AgentsWidget::openAgentInEditor);
|
||||
connect(m_detail, &AgentDetailPane::customizeRequested,
|
||||
this, &AgentsWidget::customizeAgent);
|
||||
connect(m_detail, &AgentDetailPane::deleteRequested,
|
||||
this, &AgentsWidget::deleteAgent);
|
||||
|
||||
if (m_navigator) {
|
||||
connect(m_navigator, &AgentsPageNavigator::selectAgentRequested,
|
||||
m_listPane, &AgentListPane::selectByName);
|
||||
}
|
||||
|
||||
reloadFromDisk();
|
||||
|
||||
if (m_navigator) {
|
||||
QTimer::singleShot(0, this, [this] {
|
||||
if (!m_navigator)
|
||||
return;
|
||||
const QString pending = m_navigator->takePendingSelection();
|
||||
if (!pending.isEmpty())
|
||||
m_listPane->selectByName(pending);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void apply() final {}
|
||||
|
||||
private:
|
||||
void reloadFromDisk()
|
||||
{
|
||||
m_agentFactory->reload();
|
||||
m_detail->setLoadDiagnostics(
|
||||
m_agentFactory->lastLoadErrors(), m_agentFactory->lastLoadWarnings());
|
||||
updateUserPathLabel();
|
||||
m_listPane->refresh();
|
||||
}
|
||||
|
||||
void updateUserPathLabel()
|
||||
{
|
||||
const QString dir = QodeAssist::AgentFactory::userAgentsDir();
|
||||
m_userPathLabel->setText(
|
||||
QFontMetrics(m_userPathLabel->font()).elidedText(dir, Qt::ElideLeft, 256));
|
||||
m_userPathLabel->setToolTip(dir);
|
||||
}
|
||||
|
||||
void openAgentInEditor(const AgentConfig &agent)
|
||||
{
|
||||
const QString name = agent.name;
|
||||
const QString sourcePath = agent.sourcePath;
|
||||
const bool isUser = agent.isUserSource();
|
||||
|
||||
if (!isUser) {
|
||||
QMessageBox::information(
|
||||
this, tr("Open agent"),
|
||||
tr("'%1' is bundled with the plugin and read-only.\n"
|
||||
"Use Duplicate to create an editable user copy.")
|
||||
.arg(name));
|
||||
return;
|
||||
}
|
||||
if (sourcePath.isEmpty() || sourcePath.startsWith(QLatin1String(":/"))) {
|
||||
QMessageBox::warning(
|
||||
this, tr("Open agent"),
|
||||
tr("Agent '%1' has no editable source file.").arg(name));
|
||||
return;
|
||||
}
|
||||
if (!Core::EditorManager::openEditor(Utils::FilePath::fromString(sourcePath))) {
|
||||
QMessageBox::warning(
|
||||
this, tr("Open agent"),
|
||||
tr("Could not open %1.").arg(sourcePath));
|
||||
}
|
||||
}
|
||||
|
||||
void customizeAgent(const AgentConfig &parent)
|
||||
{
|
||||
const AgentDuplicateResult res = duplicateAgentInUserDir(parent, *m_agentFactory);
|
||||
if (!res.ok) {
|
||||
QMessageBox::warning(this, tr("Duplicate"), res.error);
|
||||
return;
|
||||
}
|
||||
const QString newName = res.newName;
|
||||
reloadFromDisk();
|
||||
m_listPane->selectByName(newName);
|
||||
}
|
||||
|
||||
void deleteAgent(const AgentConfig &agent)
|
||||
{
|
||||
if (!agent.isUserSource())
|
||||
return;
|
||||
const QString name = agent.name;
|
||||
const QString sourcePath = agent.sourcePath;
|
||||
|
||||
if (QMessageBox::question(
|
||||
this, tr("Delete Agent"),
|
||||
tr("Delete agent '%1'?\n\nThis will remove the file:\n%2")
|
||||
.arg(name, sourcePath),
|
||||
QMessageBox::Yes | QMessageBox::No, QMessageBox::No)
|
||||
!= QMessageBox::Yes)
|
||||
return;
|
||||
if (!QFile::remove(sourcePath)) {
|
||||
QMessageBox::warning(
|
||||
this, tr("Delete Agent"),
|
||||
tr("Could not delete the agent file:\n%1").arg(sourcePath));
|
||||
return;
|
||||
}
|
||||
reloadFromDisk();
|
||||
}
|
||||
|
||||
AgentFactory *m_agentFactory;
|
||||
QPointer<AgentsPageNavigator> m_navigator;
|
||||
|
||||
QLabel *m_titleLabel = nullptr;
|
||||
QPushButton *m_reload = nullptr;
|
||||
QPushButton *m_openUserDir = nullptr;
|
||||
QLabel *m_userPathLabel = nullptr;
|
||||
|
||||
AgentListPane *m_listPane = nullptr;
|
||||
QScrollArea *m_detailScroll = nullptr;
|
||||
AgentDetailPane *m_detail = nullptr;
|
||||
};
|
||||
|
||||
class AgentsSettingsPage : public Core::IOptionsPage
|
||||
{
|
||||
public:
|
||||
AgentsSettingsPage(AgentFactory *agentFactory, AgentsPageNavigator *navigator)
|
||||
{
|
||||
setId(Constants::QODE_ASSIST_AGENTS_SETTINGS_PAGE_ID);
|
||||
setDisplayName(QObject::tr("Agents"));
|
||||
setCategory(Constants::QODE_ASSIST_GENERAL_OPTIONS_CATEGORY);
|
||||
setWidgetCreator([agentFactory, navigator]() {
|
||||
return new AgentsWidget(agentFactory, navigator);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
std::unique_ptr<Core::IOptionsPage> createAgentsSettingsPage(
|
||||
AgentFactory *agentFactory, AgentsPageNavigator *navigator)
|
||||
{
|
||||
return std::make_unique<AgentsSettingsPage>(agentFactory, navigator);
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
|
||||
#include "AgentsSettingsPage.moc"
|
||||
38
settings/AgentsSettingsPage.hpp
Normal file
38
settings/AgentsSettingsPage.hpp
Normal file
@@ -0,0 +1,38 @@
|
||||
// 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 {
|
||||
class AgentFactory;
|
||||
}
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
class AgentsPageNavigator : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit AgentsPageNavigator(QObject *parent = nullptr);
|
||||
|
||||
void requestSelectAgent(const QString &name);
|
||||
QString takePendingSelection();
|
||||
|
||||
signals:
|
||||
void selectAgentRequested(const QString &name);
|
||||
|
||||
private:
|
||||
QString m_pending;
|
||||
};
|
||||
|
||||
std::unique_ptr<Core::IOptionsPage> createAgentsSettingsPage(
|
||||
AgentFactory *agentFactory, AgentsPageNavigator *navigator);
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
@@ -17,16 +17,23 @@ add_library(QodeAssistSettings STATIC
|
||||
ProviderSettings.hpp ProviderSettings.cpp
|
||||
ProviderNameMigration.hpp
|
||||
ProvidersSettingsPage.hpp ProvidersSettingsPage.cpp
|
||||
ProvidersSettingsHelpers.hpp
|
||||
SettingsTheme.hpp
|
||||
SettingsUiBuilders.hpp SettingsUiBuilders.cpp
|
||||
SectionBox.hpp SectionBox.cpp
|
||||
TagChip.hpp TagChip.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
|
||||
AgentRoleDialog.hpp AgentRoleDialog.cpp
|
||||
AgentRolesWidget.hpp AgentRolesWidget.cpp
|
||||
AgentsSettingsPage.hpp AgentsSettingsPage.cpp
|
||||
AgentDetailPane.hpp AgentDetailPane.cpp
|
||||
AgentListItem.hpp AgentListItem.cpp
|
||||
AgentListPane.hpp AgentListPane.cpp
|
||||
AgentDuplicator.hpp AgentDuplicator.cpp
|
||||
TagFilterStrip.hpp TagFilterStrip.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(QodeAssistSettings
|
||||
@@ -38,6 +45,7 @@ target_link_libraries(QodeAssistSettings
|
||||
QtCreator::Utils
|
||||
QodeAssistLogger
|
||||
ProvidersConfig
|
||||
Agents
|
||||
Skills
|
||||
)
|
||||
target_include_directories(QodeAssistSettings PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
// 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
|
||||
@@ -1,34 +0,0 @@
|
||||
// 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
|
||||
@@ -18,8 +18,9 @@
|
||||
#include <solutions/terminal/terminalview.h>
|
||||
|
||||
#include "ProviderInstanceWriter.hpp"
|
||||
#include "ProvidersSettingsHelpers.hpp"
|
||||
#include "SectionBox.hpp"
|
||||
#include "SettingsTheme.hpp"
|
||||
#include "SettingsUiBuilders.hpp"
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
@@ -112,15 +113,12 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent)
|
||||
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));
|
||||
FormBuilder(identityGrid)
|
||||
.row(tr("Name:"), m_nameEdit)
|
||||
.row(tr("Client API:"), m_typeEdit,
|
||||
tr("The client API this provider speaks. "
|
||||
"Cannot be changed after creation."))
|
||||
.row(tr("Description:"), m_descriptionEdit);
|
||||
identitySection->bodyLayout()->addLayout(identityGrid);
|
||||
|
||||
auto *endpointSection = new SectionBox(tr("Endpoint"), this);
|
||||
@@ -130,11 +128,9 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent)
|
||||
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."));
|
||||
FormBuilder(endpointGrid).row(tr("URL:"), 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);
|
||||
@@ -176,14 +172,7 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent)
|
||||
});
|
||||
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);
|
||||
m_keyHint = makeHintLabel(QString{}, this);
|
||||
|
||||
auto *keyRow = new QHBoxLayout;
|
||||
keyRow->setContentsMargins(0, 0, 0, 0);
|
||||
@@ -197,9 +186,9 @@ ProviderDetailPane::ProviderDetailPane(QWidget *parent)
|
||||
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);
|
||||
FormBuilder credForm(credGrid);
|
||||
credForm.row(tr("API key:"), keyRow);
|
||||
credGrid->addWidget(m_keyHint, credForm.currentRow(), 1);
|
||||
credSection->bodyLayout()->addLayout(credGrid);
|
||||
|
||||
m_launchSection = new SectionBox(tr("Launch"), this);
|
||||
@@ -483,12 +472,10 @@ Providers::ProviderInstance ProviderDetailPane::collectEdits() const
|
||||
|
||||
void ProviderDetailPane::applyPreviewPalette()
|
||||
{
|
||||
const bool dark = isDarkPalette(palette());
|
||||
const QString bg = dark ? QStringLiteral("#1f1f1f") : QStringLiteral("#f4f4f4");
|
||||
const QString bd = dark ? QStringLiteral("#3a3a3a") : QStringLiteral("#dcdcdc");
|
||||
const Theme theme = themeFor(palette());
|
||||
m_samplePreview->setStyleSheet(QStringLiteral(
|
||||
"QLabel { background:%1; border:1px solid %2; }")
|
||||
.arg(bg, bd));
|
||||
.arg(theme.codeBg, theme.rowSeparator));
|
||||
}
|
||||
|
||||
void ProviderDetailPane::applyTerminalPalette()
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "ProviderInstance.hpp"
|
||||
#include "ProvidersSettingsHelpers.hpp"
|
||||
#include "SettingsTheme.hpp"
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
@@ -99,12 +99,11 @@ 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");
|
||||
const Theme theme = themeFor(palette());
|
||||
setStyleSheet(QStringLiteral(
|
||||
"#ProvListItem { background:%1; border-top: 1px solid %2; }")
|
||||
.arg(m_selected ? sel : QStringLiteral("transparent"), sep));
|
||||
.arg(m_selected ? theme.rowSelectedBg : QStringLiteral("transparent"),
|
||||
theme.rowSeparator));
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
|
||||
@@ -227,6 +227,8 @@ public:
|
||||
}
|
||||
};
|
||||
|
||||
#ifndef QODEASSIST_EXPERIMENTAL
|
||||
const ProviderSettingsPage providerSettingsPage;
|
||||
#endif
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
// 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
|
||||
@@ -28,7 +28,6 @@
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "NewProviderDialog.hpp"
|
||||
#include "ProviderDetailPane.hpp"
|
||||
#include "ProviderInstance.hpp"
|
||||
#include "ProviderInstanceFactory.hpp"
|
||||
@@ -36,8 +35,8 @@
|
||||
#include "ProviderLauncher.hpp"
|
||||
#include "ProviderListItem.hpp"
|
||||
#include "ProviderSecretsStore.hpp"
|
||||
#include "ProvidersSettingsHelpers.hpp"
|
||||
#include "SettingsConstants.hpp"
|
||||
#include "SettingsTheme.hpp"
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
@@ -80,13 +79,10 @@ public:
|
||||
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);
|
||||
@@ -181,7 +177,6 @@ public:
|
||||
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);
|
||||
@@ -248,9 +243,9 @@ private slots:
|
||||
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));
|
||||
header->setStyleSheet(
|
||||
QStringLiteral("QLabel { background:%1; }")
|
||||
.arg(themeFor(palette()).listHeaderBg));
|
||||
m_listLayout->insertWidget(m_listLayout->count() - 1, header);
|
||||
|
||||
std::vector<const Providers::ProviderInstance *> sorted;
|
||||
@@ -316,57 +311,6 @@ private slots:
|
||||
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())
|
||||
@@ -589,7 +533,6 @@ private:
|
||||
QPointer<ProvidersPageNavigator> m_navigator;
|
||||
|
||||
QLabel *m_titleLabel = nullptr;
|
||||
QPushButton *m_newBtn = nullptr;
|
||||
QLineEdit *m_filterEdit = nullptr;
|
||||
|
||||
QScrollArea *m_listScroll = nullptr;
|
||||
|
||||
@@ -138,6 +138,9 @@ const char QODE_ASSIST_GENERAL_OPTIONS_DISPLAY_CATEGORY[] = "QodeAssist";
|
||||
// Provider Settings Page ID
|
||||
const char QODE_ASSIST_PROVIDER_SETTINGS_PAGE_ID[] = "QodeAssist.7ProviderSettingsPageId";
|
||||
|
||||
// Agents Settings Page ID
|
||||
const char QODE_ASSIST_AGENTS_SETTINGS_PAGE_ID[] = "QodeAssist.8AgentsSettingsPageId";
|
||||
|
||||
// Provider API Keys
|
||||
const char OPEN_ROUTER_API_KEY[] = "QodeAssist.openRouterApiKey";
|
||||
const char OPEN_ROUTER_API_KEY_HISTORY[] = "QodeAssist.openRouterApiKeyHistory";
|
||||
|
||||
52
settings/SettingsTheme.hpp
Normal file
52
settings/SettingsTheme.hpp
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QFont>
|
||||
#include <QFontDatabase>
|
||||
#include <QPalette>
|
||||
#include <QString>
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
struct Theme
|
||||
{
|
||||
bool dark = false;
|
||||
QString listHeaderBg;
|
||||
QString rowSeparator;
|
||||
QString rowSelectedBg;
|
||||
QString codeBg;
|
||||
};
|
||||
|
||||
inline bool isDarkPalette(const QPalette &p)
|
||||
{
|
||||
return p.color(QPalette::Window).lightness() < 128;
|
||||
}
|
||||
|
||||
inline Theme themeFor(const QPalette &p)
|
||||
{
|
||||
const bool dark = isDarkPalette(p);
|
||||
if (dark)
|
||||
return {true,
|
||||
QStringLiteral("#262626"),
|
||||
QStringLiteral("#3a3a3a"),
|
||||
QStringLiteral("#2c4060"),
|
||||
QStringLiteral("#1f1f1f")};
|
||||
return {false,
|
||||
QStringLiteral("#f0f0f0"),
|
||||
QStringLiteral("#dcdcdc"),
|
||||
QStringLiteral("#cfe2ff"),
|
||||
QStringLiteral("#f4f4f4")};
|
||||
}
|
||||
|
||||
inline QFont monospaceFont(int pixelSize = 11)
|
||||
{
|
||||
QFont f = QFontDatabase::systemFont(QFontDatabase::FixedFont);
|
||||
f.setStyleHint(QFont::Monospace);
|
||||
if (pixelSize > 0)
|
||||
f.setPixelSize(pixelSize);
|
||||
return f;
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
100
settings/SettingsUiBuilders.cpp
Normal file
100
settings/SettingsUiBuilders.cpp
Normal file
@@ -0,0 +1,100 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "SettingsUiBuilders.hpp"
|
||||
|
||||
#include "SettingsTheme.hpp"
|
||||
|
||||
#include <QFont>
|
||||
#include <QGridLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QPalette>
|
||||
#include <QWidget>
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
void applyMutedSmallCaps(QLabel *label)
|
||||
{
|
||||
QFont f = label->font();
|
||||
f.setPixelSize(10);
|
||||
f.setLetterSpacing(QFont::AbsoluteSpacing, 0.4);
|
||||
label->setFont(f);
|
||||
QPalette p = label->palette();
|
||||
p.setColor(QPalette::WindowText, p.color(QPalette::Mid));
|
||||
label->setPalette(p);
|
||||
}
|
||||
|
||||
QLabel *makeSectionHeader(const QString &title, QWidget *parent)
|
||||
{
|
||||
auto *header = new QLabel(title.toUpper(), parent);
|
||||
applyMutedSmallCaps(header);
|
||||
header->setContentsMargins(8, 4, 8, 4);
|
||||
header->setAutoFillBackground(true);
|
||||
const Theme theme = themeFor(parent ? parent->palette() : QPalette());
|
||||
header->setStyleSheet(
|
||||
QStringLiteral("QLabel { background:%1; border-top:1px solid %2;"
|
||||
" border-bottom:1px solid %2; }")
|
||||
.arg(theme.listHeaderBg, theme.rowSeparator));
|
||||
return header;
|
||||
}
|
||||
|
||||
QLabel *makeHintLabel(const QString &text, QWidget *parent)
|
||||
{
|
||||
auto *h = new QLabel(text, parent);
|
||||
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);
|
||||
return h;
|
||||
}
|
||||
|
||||
QHBoxLayout *singleField(QWidget *w)
|
||||
{
|
||||
auto *lay = new QHBoxLayout;
|
||||
lay->setContentsMargins(0, 0, 0, 0);
|
||||
lay->setSpacing(4);
|
||||
lay->addWidget(w, 1);
|
||||
return lay;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
QLabel *makeFormLabel(const QString &text)
|
||||
{
|
||||
auto *l = new QLabel(text);
|
||||
l->setMinimumWidth(96);
|
||||
l->setAlignment(Qt::AlignLeft | Qt::AlignTop);
|
||||
return l;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
FormBuilder::FormBuilder(QGridLayout *grid, int startRow)
|
||||
: m_grid(grid)
|
||||
, m_row(startRow)
|
||||
{}
|
||||
|
||||
FormBuilder &FormBuilder::row(const QString &label, QLayout *value, const QString &hint)
|
||||
{
|
||||
m_grid->addWidget(makeFormLabel(label), m_row, 0, Qt::AlignTop);
|
||||
auto *holder = new QWidget;
|
||||
holder->setLayout(value);
|
||||
m_grid->addWidget(holder, m_row, 1);
|
||||
++m_row;
|
||||
if (!hint.isEmpty()) {
|
||||
m_grid->addWidget(makeHintLabel(hint), m_row, 1);
|
||||
++m_row;
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
FormBuilder &FormBuilder::row(const QString &label, QWidget *value, const QString &hint)
|
||||
{
|
||||
return row(label, singleField(value), hint);
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
39
settings/SettingsUiBuilders.hpp
Normal file
39
settings/SettingsUiBuilders.hpp
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
class QGridLayout;
|
||||
class QHBoxLayout;
|
||||
class QLabel;
|
||||
class QLayout;
|
||||
class QWidget;
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
void applyMutedSmallCaps(QLabel *label);
|
||||
|
||||
QLabel *makeSectionHeader(const QString &title, QWidget *parent);
|
||||
|
||||
QLabel *makeHintLabel(const QString &text, QWidget *parent = nullptr);
|
||||
|
||||
QHBoxLayout *singleField(QWidget *w);
|
||||
|
||||
class FormBuilder
|
||||
{
|
||||
public:
|
||||
explicit FormBuilder(QGridLayout *grid, int startRow = 0);
|
||||
|
||||
FormBuilder &row(const QString &label, QLayout *value, const QString &hint = {});
|
||||
FormBuilder &row(const QString &label, QWidget *value, const QString &hint = {});
|
||||
|
||||
int currentRow() const { return m_row; }
|
||||
|
||||
private:
|
||||
QGridLayout *m_grid;
|
||||
int m_row;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
88
settings/TagChip.cpp
Normal file
88
settings/TagChip.cpp
Normal file
@@ -0,0 +1,88 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "TagChip.hpp"
|
||||
|
||||
#include "SettingsTheme.hpp"
|
||||
|
||||
#include <QEvent>
|
||||
#include <QFont>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QMouseEvent>
|
||||
#include <QPalette>
|
||||
#include <QScopedValueRollback>
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
TagChip::TagChip(const QString &tag, int count, QWidget *parent)
|
||||
: QFrame(parent)
|
||||
, m_tag(tag)
|
||||
{
|
||||
setObjectName(QStringLiteral("TagChip"));
|
||||
setCursor(Qt::PointingHandCursor);
|
||||
|
||||
m_label = new QLabel(tag, this);
|
||||
m_label->setFont(monospaceFont(11));
|
||||
|
||||
auto *row = new QHBoxLayout(this);
|
||||
row->setContentsMargins(5, 0, 5, 0);
|
||||
row->setSpacing(4);
|
||||
row->addWidget(m_label);
|
||||
|
||||
if (count >= 0) {
|
||||
m_count = new QLabel(QString::number(count), this);
|
||||
QFont cf = m_count->font();
|
||||
cf.setPixelSize(10);
|
||||
m_count->setFont(cf);
|
||||
row->addWidget(m_count);
|
||||
}
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
void TagChip::setActive(bool on)
|
||||
{
|
||||
if (m_active == on)
|
||||
return;
|
||||
m_active = on;
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
void TagChip::mouseReleaseEvent(QMouseEvent *event)
|
||||
{
|
||||
if (event->button() == Qt::LeftButton)
|
||||
emit clicked(m_tag);
|
||||
QFrame::mouseReleaseEvent(event);
|
||||
}
|
||||
|
||||
void TagChip::changeEvent(QEvent *event)
|
||||
{
|
||||
QFrame::changeEvent(event);
|
||||
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
void TagChip::applyTheme()
|
||||
{
|
||||
if (m_inApplyTheme)
|
||||
return;
|
||||
QScopedValueRollback<bool> guard(m_inApplyTheme, true);
|
||||
const Theme theme = themeFor(palette());
|
||||
const QString text = palette().color(QPalette::WindowText).name();
|
||||
const QString mute = palette().color(QPalette::Mid).name();
|
||||
const QString border = m_active ? text : theme.rowSeparator;
|
||||
const QString bg = m_active ? theme.rowSelectedBg : QStringLiteral("transparent");
|
||||
setStyleSheet(QStringLiteral(
|
||||
"#TagChip { background:%1; border:1px solid %2; }")
|
||||
.arg(bg, border));
|
||||
QPalette lp = m_label->palette();
|
||||
lp.setColor(QPalette::WindowText, m_active ? QColor(text) : QColor(mute));
|
||||
m_label->setPalette(lp);
|
||||
if (m_count) {
|
||||
QPalette cp = m_count->palette();
|
||||
cp.setColor(QPalette::WindowText, QColor(mute));
|
||||
m_count->setPalette(cp);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
39
settings/TagChip.hpp
Normal file
39
settings/TagChip.hpp
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QFrame>
|
||||
#include <QString>
|
||||
|
||||
class QLabel;
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
class TagChip : public QFrame
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit TagChip(const QString &tag, int count, QWidget *parent = nullptr);
|
||||
|
||||
void setActive(bool on);
|
||||
QString tag() const { return m_tag; }
|
||||
|
||||
signals:
|
||||
void clicked(const QString &tag);
|
||||
|
||||
protected:
|
||||
void mouseReleaseEvent(QMouseEvent *event) override;
|
||||
void changeEvent(QEvent *event) override;
|
||||
|
||||
private:
|
||||
void applyTheme();
|
||||
|
||||
QString m_tag;
|
||||
bool m_active = false;
|
||||
bool m_inApplyTheme = false;
|
||||
QLabel *m_label = nullptr;
|
||||
QLabel *m_count = nullptr;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
163
settings/TagFilterStrip.cpp
Normal file
163
settings/TagFilterStrip.cpp
Normal file
@@ -0,0 +1,163 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "TagFilterStrip.hpp"
|
||||
|
||||
#include "SettingsTheme.hpp"
|
||||
#include "SettingsUiBuilders.hpp"
|
||||
#include "TagChip.hpp"
|
||||
|
||||
#include <QEvent>
|
||||
#include <QFont>
|
||||
#include <QGridLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QLayoutItem>
|
||||
#include <QPalette>
|
||||
#include <QScopedValueRollback>
|
||||
#include <QStringList>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
#include <algorithm>
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
TagFilterStrip::TagFilterStrip(QWidget *parent)
|
||||
: QWidget(parent)
|
||||
{
|
||||
setObjectName(QStringLiteral("TagStrip"));
|
||||
setAutoFillBackground(true);
|
||||
m_layout = new QVBoxLayout(this);
|
||||
m_layout->setContentsMargins(8, 6, 8, 6);
|
||||
m_layout->setSpacing(5);
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
void TagFilterStrip::setAvailableTags(const QMap<QString, int> &countsByTag)
|
||||
{
|
||||
m_counts = countsByTag;
|
||||
QSet<QString> stillExisting;
|
||||
for (auto it = m_counts.cbegin(); it != m_counts.cend(); ++it)
|
||||
stillExisting.insert(it.key());
|
||||
QSet<QString> trimmed;
|
||||
for (const QString &t : m_activeTags)
|
||||
if (stillExisting.contains(t))
|
||||
trimmed.insert(t);
|
||||
const bool activeChanged = trimmed != m_activeTags;
|
||||
if (activeChanged)
|
||||
m_activeTags = trimmed;
|
||||
rebuild();
|
||||
if (activeChanged)
|
||||
emit activeTagsChanged(m_activeTags);
|
||||
}
|
||||
|
||||
void TagFilterStrip::changeEvent(QEvent *event)
|
||||
{
|
||||
QWidget::changeEvent(event);
|
||||
if (m_inApplyTheme)
|
||||
return;
|
||||
if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
void TagFilterStrip::toggleTag(const QString &tag)
|
||||
{
|
||||
if (m_activeTags.contains(tag))
|
||||
m_activeTags.remove(tag);
|
||||
else
|
||||
m_activeTags.insert(tag);
|
||||
refreshActiveStates();
|
||||
emit activeTagsChanged(m_activeTags);
|
||||
}
|
||||
|
||||
void TagFilterStrip::refreshActiveStates()
|
||||
{
|
||||
for (auto it = m_chipByTag.cbegin(); it != m_chipByTag.cend(); ++it)
|
||||
it.value()->setActive(m_activeTags.contains(it.key()));
|
||||
}
|
||||
|
||||
void TagFilterStrip::applyTheme()
|
||||
{
|
||||
if (m_inApplyTheme)
|
||||
return;
|
||||
QScopedValueRollback<bool> guard(m_inApplyTheme, true);
|
||||
const Theme theme = themeFor(palette());
|
||||
setStyleSheet(QStringLiteral("QWidget#TagStrip { background:%1;"
|
||||
" border-bottom:1px solid %2; }")
|
||||
.arg(theme.listHeaderBg, theme.rowSeparator));
|
||||
}
|
||||
|
||||
void TagFilterStrip::rebuild()
|
||||
{
|
||||
while (auto *item = m_layout->takeAt(0)) {
|
||||
if (auto *w = item->widget())
|
||||
w->deleteLater();
|
||||
if (auto *l = item->layout()) {
|
||||
while (auto *sub = l->takeAt(0)) {
|
||||
if (auto *sw = sub->widget())
|
||||
sw->deleteLater();
|
||||
delete sub;
|
||||
}
|
||||
}
|
||||
delete item;
|
||||
}
|
||||
m_chipByTag.clear();
|
||||
|
||||
if (m_counts.isEmpty()) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
setVisible(true);
|
||||
|
||||
auto *headerLine = new QHBoxLayout;
|
||||
headerLine->setContentsMargins(0, 0, 0, 0);
|
||||
headerLine->setSpacing(6);
|
||||
auto *title = new QLabel(tr("FILTER BY TAG"), this);
|
||||
applyMutedSmallCaps(title);
|
||||
headerLine->addWidget(title);
|
||||
headerLine->addStretch(1);
|
||||
if (!m_activeTags.isEmpty()) {
|
||||
auto *clear = new QLabel(QStringLiteral("<a href=\"#\">%1</a>").arg(tr("clear")), this);
|
||||
connect(clear, &QLabel::linkActivated, this, [this](const QString &) {
|
||||
if (m_activeTags.isEmpty())
|
||||
return;
|
||||
m_activeTags.clear();
|
||||
refreshActiveStates();
|
||||
emit activeTagsChanged(m_activeTags);
|
||||
});
|
||||
headerLine->addWidget(clear);
|
||||
}
|
||||
m_layout->addLayout(headerLine);
|
||||
|
||||
std::vector<std::pair<QString, int>> sorted;
|
||||
sorted.reserve(m_counts.size());
|
||||
for (auto it = m_counts.cbegin(); it != m_counts.cend(); ++it)
|
||||
sorted.emplace_back(it.key(), it.value());
|
||||
std::sort(sorted.begin(), sorted.end(),
|
||||
[](const auto &a, const auto &b) {
|
||||
if (a.second != b.second)
|
||||
return a.second > b.second;
|
||||
return a.first.localeAwareCompare(b.first) < 0;
|
||||
});
|
||||
|
||||
auto *grid = new QGridLayout;
|
||||
grid->setContentsMargins(0, 0, 0, 0);
|
||||
grid->setHorizontalSpacing(3);
|
||||
grid->setVerticalSpacing(3);
|
||||
int col = 0, gridRow = 0;
|
||||
for (const auto &[tag, count] : sorted) {
|
||||
auto *chip = new TagChip(tag, count, this);
|
||||
chip->setActive(m_activeTags.contains(tag));
|
||||
connect(chip, &TagChip::clicked, this, &TagFilterStrip::toggleTag);
|
||||
grid->addWidget(chip, gridRow, col, Qt::AlignLeft);
|
||||
m_chipByTag.insert(tag, chip);
|
||||
if (++col >= 4) {
|
||||
col = 0;
|
||||
++gridRow;
|
||||
}
|
||||
}
|
||||
grid->setColumnStretch(4, 1);
|
||||
m_layout->addLayout(grid);
|
||||
}
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
46
settings/TagFilterStrip.hpp
Normal file
46
settings/TagFilterStrip.hpp
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright (C) 2024-2026 Petr Mironychev
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QHash>
|
||||
#include <QMap>
|
||||
#include <QSet>
|
||||
#include <QString>
|
||||
#include <QWidget>
|
||||
|
||||
class QVBoxLayout;
|
||||
|
||||
namespace QodeAssist::Settings {
|
||||
|
||||
class TagChip;
|
||||
|
||||
class TagFilterStrip : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit TagFilterStrip(QWidget *parent = nullptr);
|
||||
|
||||
void setAvailableTags(const QMap<QString, int> &countsByTag);
|
||||
const QSet<QString> &activeTags() const { return m_activeTags; }
|
||||
|
||||
signals:
|
||||
void activeTagsChanged(const QSet<QString> &tags);
|
||||
|
||||
protected:
|
||||
void changeEvent(QEvent *event) override;
|
||||
|
||||
private:
|
||||
void rebuild();
|
||||
void refreshActiveStates();
|
||||
void applyTheme();
|
||||
void toggleTag(const QString &tag);
|
||||
|
||||
QMap<QString, int> m_counts;
|
||||
QSet<QString> m_activeTags;
|
||||
QVBoxLayout *m_layout = nullptr;
|
||||
QHash<QString, TagChip *> m_chipByTag;
|
||||
bool m_inApplyTheme = false;
|
||||
};
|
||||
|
||||
} // namespace QodeAssist::Settings
|
||||
Reference in New Issue
Block a user