From 608103b92ed005e17630a62358f0233f0c7f14d2 Mon Sep 17 00:00:00 2001 From: Petr Mironychev <9195189+Palm1r@users.noreply.github.com> Date: Thu, 23 Oct 2025 21:41:59 +0200 Subject: [PATCH] feat: Popup to show current project rules (#241) * feat: Popup to show current project rules * feat: Add icon to show project rules button * feat: Add counter for active rules --- ChatView/CMakeLists.txt | 2 + ChatView/ChatRootView.cpp | 76 ++++++++- ChatView/ChatRootView.hpp | 14 +- ChatView/icons/rules-icon.svg | 9 + ChatView/qml/RootItem.qml | 21 ++- ChatView/qml/parts/RulesViewer.qml | 259 +++++++++++++++++++++++++++++ ChatView/qml/parts/TopBar.qml | 34 ++++ llmcore/RulesLoader.cpp | 69 ++++++++ llmcore/RulesLoader.hpp | 13 ++ 9 files changed, 491 insertions(+), 6 deletions(-) create mode 100644 ChatView/icons/rules-icon.svg create mode 100644 ChatView/qml/parts/RulesViewer.qml diff --git a/ChatView/CMakeLists.txt b/ChatView/CMakeLists.txt index d2eb5a3..63107a7 100644 --- a/ChatView/CMakeLists.txt +++ b/ChatView/CMakeLists.txt @@ -19,6 +19,7 @@ qt_add_qml_module(QodeAssistChatView qml/parts/ErrorToast.qml qml/ToolStatusItem.qml qml/FileEditChangesItem.qml + qml/parts/RulesViewer.qml RESOURCES icons/attach-file-light.svg @@ -35,6 +36,7 @@ qt_add_qml_module(QodeAssistChatView icons/window-unlock.svg icons/chat-icon.svg icons/chat-pause-icon.svg + icons/rules-icon.svg SOURCES ChatWidget.hpp ChatWidget.cpp diff --git a/ChatView/ChatRootView.cpp b/ChatView/ChatRootView.cpp index 6b663e7..b709f72 100644 --- a/ChatView/ChatRootView.cpp +++ b/ChatView/ChatRootView.cpp @@ -39,6 +39,7 @@ #include "ProjectSettings.hpp" #include "context/ContextManager.hpp" #include "context/TokenUtils.hpp" +#include "llmcore/RulesLoader.hpp" namespace QodeAssist::Chat { @@ -139,6 +140,14 @@ ChatRootView::ChatRootView(QQuickItem *parent) }); updateInputTokensCount(); + refreshRules(); + + // Refresh rules when project changes + connect( + ProjectExplorer::ProjectManager::instance(), + &ProjectExplorer::ProjectManager::startupProjectChanged, + this, + &ChatRootView::refreshRules); } ChatModel *ChatRootView::chatModel() const @@ -339,8 +348,7 @@ QString ChatRootView::getSuggestedFileName() const QFileInfo finalCheck(fullPath); if (fileName.isEmpty() || finalCheck.exists() || !QFileInfo(finalCheck.path()).isWritable()) { - fileName = QString("chat_%1").arg( - QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm")); + fileName = QString("chat_%1").arg(QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm")); } return fileName; @@ -491,6 +499,25 @@ void ChatRootView::openChatHistoryFolder() QDesktopServices::openUrl(url); } +void ChatRootView::openRulesFolder() +{ + auto project = ProjectExplorer::ProjectManager::startupProject(); + if (!project) { + return; + } + + QString projectPath = project->projectDirectory().toFSPathString(); + QString rulesPath = projectPath + "/.qodeassist/rules"; + + QDir dir(rulesPath); + if (!dir.exists()) { + dir.mkpath("."); + } + + QUrl url = QUrl::fromLocalFile(dir.absolutePath()); + QDesktopServices::openUrl(url); +} + void ChatRootView::updateInputTokensCount() { int inputTokens = m_messageTokensCount; @@ -632,4 +659,49 @@ QString ChatRootView::lastErrorMessage() const return m_lastErrorMessage; } +QVariantList ChatRootView::activeRules() const +{ + return m_activeRules; +} + +int ChatRootView::activeRulesCount() const +{ + return m_activeRules.size(); +} + +QString ChatRootView::getRuleContent(int index) +{ + if (index < 0 || index >= m_activeRules.size()) + return QString(); + + return LLMCore::RulesLoader::loadRuleFileContent( + m_activeRules[index].toMap()["filePath"].toString()); +} + +void ChatRootView::refreshRules() +{ + m_activeRules.clear(); + + auto project = LLMCore::RulesLoader::getActiveProject(); + if (!project) { + emit activeRulesChanged(); + emit activeRulesCountChanged(); + return; + } + + auto ruleFiles + = LLMCore::RulesLoader::getRuleFilesForProject(project, LLMCore::RulesContext::Chat); + + for (const auto &ruleFile : ruleFiles) { + QVariantMap ruleMap; + ruleMap["filePath"] = ruleFile.filePath; + ruleMap["fileName"] = ruleFile.fileName; + ruleMap["category"] = ruleFile.category; + m_activeRules.append(ruleMap); + } + + emit activeRulesChanged(); + emit activeRulesCountChanged(); +} + } // namespace QodeAssist::Chat diff --git a/ChatView/ChatRootView.hpp b/ChatView/ChatRootView.hpp index 50c6cc1..7d94a29 100644 --- a/ChatView/ChatRootView.hpp +++ b/ChatView/ChatRootView.hpp @@ -43,9 +43,10 @@ class ChatRootView : public QQuickItem Q_PROPERTY(int codeFontSize READ codeFontSize NOTIFY codeFontSizeChanged FINAL) Q_PROPERTY(int textFontSize READ textFontSize NOTIFY textFontSizeChanged FINAL) Q_PROPERTY(int textFormat READ textFormat NOTIFY textFormatChanged FINAL) - Q_PROPERTY( - bool isRequestInProgress READ isRequestInProgress NOTIFY isRequestInProgressChanged FINAL) + Q_PROPERTY(bool isRequestInProgress READ isRequestInProgress NOTIFY isRequestInProgressChanged FINAL) Q_PROPERTY(QString lastErrorMessage READ lastErrorMessage NOTIFY lastErrorMessageChanged FINAL) + Q_PROPERTY(QVariantList activeRules READ activeRules NOTIFY activeRulesChanged FINAL) + Q_PROPERTY(int activeRulesCount READ activeRulesCount NOTIFY activeRulesCountChanged FINAL) QML_ELEMENT @@ -74,6 +75,7 @@ public: Q_INVOKABLE void calculateMessageTokensCount(const QString &message); Q_INVOKABLE void setIsSyncOpenFiles(bool state); Q_INVOKABLE void openChatHistoryFolder(); + Q_INVOKABLE void openRulesFolder(); Q_INVOKABLE void updateInputTokensCount(); int inputTokensCount() const; @@ -99,6 +101,11 @@ public: void setRequestProgressStatus(bool state); QString lastErrorMessage() const; + + QVariantList activeRules() const; + int activeRulesCount() const; + Q_INVOKABLE QString getRuleContent(int index); + Q_INVOKABLE void refreshRules(); public slots: void sendMessage(const QString &message); @@ -124,6 +131,8 @@ signals: void isRequestInProgressChanged(); void lastErrorMessageChanged(); + void activeRulesChanged(); + void activeRulesCountChanged(); private: QString getChatsHistoryDir() const; @@ -142,6 +151,7 @@ private: QList m_currentEditors; bool m_isRequestInProgress; QString m_lastErrorMessage; + QVariantList m_activeRules; }; } // namespace QodeAssist::Chat diff --git a/ChatView/icons/rules-icon.svg b/ChatView/icons/rules-icon.svg new file mode 100644 index 0000000..8277a15 --- /dev/null +++ b/ChatView/icons/rules-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ChatView/qml/RootItem.qml b/ChatView/qml/RootItem.qml index 3e42551..7472157 100644 --- a/ChatView/qml/RootItem.qml +++ b/ChatView/qml/RootItem.qml @@ -77,6 +77,8 @@ ChatRootView { text: qsTr("Latest chat file name: %1").arg(root.chatFileName.length > 0 ? root.chatFileName : "Unsaved") } openChatHistory.onClicked: root.openChatHistoryFolder() + rulesButton.onClicked: rulesViewer.open() + activeRulesCount: root.activeRulesCount pinButton { visible: typeof _chatview !== 'undefined' checked: typeof _chatview !== 'undefined' ? _chatview.isPin : false @@ -162,10 +164,10 @@ ChatRootView { toolContent: model.content } } - + Component { id: fileEditSuggestionComponent - + FileEditChangesItem { id: fileEditItem @@ -332,6 +334,21 @@ ChatRootView { z: 1000 } + RulesViewer { + id: rulesViewer + + width: parent.width * 0.8 + height: parent.height * 0.8 + x: (parent.width - width) / 2 + y: (parent.height - height) / 2 + + activeRules: root.activeRules + ruleContentAreaText: root.getRuleContent(rulesViewer.rulesCurrentIndex) + + onRefreshRules: root.refreshRules() + onOpenRulesFolder: root.openRulesFolder() + } + Connections { target: root function onLastErrorMessageChanged() { diff --git a/ChatView/qml/parts/RulesViewer.qml b/ChatView/qml/parts/RulesViewer.qml new file mode 100644 index 0000000..b09b4ec --- /dev/null +++ b/ChatView/qml/parts/RulesViewer.qml @@ -0,0 +1,259 @@ +/* + * Copyright (C) 2025 Petr Mironychev + * + * This file is part of QodeAssist. + * + * QodeAssist is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QodeAssist is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QodeAssist. If not, see . + */ + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Controls.Basic as QQC + +import UIControls +import ChatView + +Popup { + id: root + + property var activeRules + + property alias rulesCurrentIndex: rulesList.currentIndex + property alias ruleContentAreaText: ruleContentArea.text + + signal refreshRules() + signal openRulesFolder() + + modal: true + focus: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + background: Rectangle { + color: palette.window + border.color: palette.mid + border.width: 1 + radius: 4 + } + + ChatUtils { + id: utils + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 10 + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Text { + text: qsTr("Active Project Rules") + font.pixelSize: 16 + font.bold: true + color: palette.text + Layout.fillWidth: true + } + + QoAButton { + text: qsTr("Open Folder") + onClicked: root.openRulesFolder() + } + + QoAButton { + text: qsTr("Refresh") + onClicked: root.refreshRules() + } + + QoAButton { + text: qsTr("Close") + onClicked: root.close() + } + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: palette.mid + } + + SplitView { + Layout.fillWidth: true + Layout.fillHeight: true + orientation: Qt.Horizontal + + Rectangle { + SplitView.minimumWidth: 200 + SplitView.preferredWidth: parent.width * 0.3 + color: palette.base + border.color: palette.mid + border.width: 1 + radius: 2 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 5 + spacing: 5 + + Text { + text: qsTr("Rules Files (%1)").arg(rulesList.count) + font.pixelSize: 12 + font.bold: true + color: palette.text + Layout.fillWidth: true + } + + ListView { + id: rulesList + + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: root.activeRules + currentIndex: 0 + + delegate: ItemDelegate { + required property var modelData + required property int index + + width: ListView.view.width + highlighted: ListView.isCurrentItem + + background: Rectangle { + color: { + if (parent.highlighted) { + return palette.highlight + } else if (parent.hovered) { + return Qt.tint(palette.base, Qt.rgba(0, 0, 0, 0.05)) + } + return "transparent" + } + radius: 2 + } + + contentItem: ColumnLayout { + spacing: 2 + + Text { + text: modelData.fileName + font.pixelSize: 11 + color: parent.parent.highlighted ? palette.highlightedText : palette.text + elide: Text.ElideMiddle + Layout.fillWidth: true + } + + Text { + text: qsTr("Category: %1").arg(modelData.category) + font.pixelSize: 9 + color: parent.parent.highlighted ? palette.highlightedText : palette.mid + Layout.fillWidth: true + } + } + + onClicked: { + rulesList.currentIndex = index + } + } + + ScrollBar.vertical: QQC.ScrollBar { + id: scroll + } + } + + Text { + visible: rulesList.count === 0 + text: qsTr("No rules found.\nCreate .md files in:\n.qodeassist/rules/common/\n.qodeassist/rules/chat/") + font.pixelSize: 10 + color: palette.mid + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + Layout.fillWidth: true + Layout.fillHeight: true + Layout.alignment: Qt.AlignCenter + } + } + } + + Rectangle { + SplitView.fillWidth: true + color: palette.base + border.color: palette.mid + border.width: 1 + radius: 2 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 5 + spacing: 5 + + RowLayout { + Layout.fillWidth: true + spacing: 5 + + Text { + text: qsTr("Content") + font.pixelSize: 12 + font.bold: true + color: palette.text + Layout.fillWidth: true + } + + QoAButton { + text: qsTr("Copy") + enabled: ruleContentArea.text.length > 0 + onClicked: utils.copyToClipboard(ruleContentArea.text) + } + } + + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + + QQC.TextArea { + id: ruleContentArea + + readOnly: true + wrapMode: TextArea.Wrap + selectByMouse: true + color: palette.text + font.family: "monospace" + font.pixelSize: 11 + + background: Rectangle { + color: Qt.darker(palette.base, 1.02) + border.color: palette.mid + border.width: 1 + radius: 2 + } + + placeholderText: qsTr("Select a rule file to view its content") + } + } + } + } + } + + Text { + text: qsTr("Rules are loaded from .qodeassist/rules/ directory in your project.\n" + + "Common rules apply to all contexts, chat rules apply only to chat assistant.") + font.pixelSize: 9 + color: palette.mid + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } +} diff --git a/ChatView/qml/parts/TopBar.qml b/ChatView/qml/parts/TopBar.qml index bb321ab..cf944ef 100644 --- a/ChatView/qml/parts/TopBar.qml +++ b/ChatView/qml/parts/TopBar.qml @@ -33,6 +33,8 @@ Rectangle { property alias recentPath: recentPathId property alias openChatHistory: openChatHistoryId property alias pinButton: pinButtonId + property alias rulesButton: rulesButtonId + property alias activeRulesCount: activeRulesCountId.text color: palette.window.hslLightness > 0.5 ? Qt.darker(palette.window, 1.1) : @@ -126,6 +128,38 @@ Rectangle { ToolTip.text: qsTr("Show in system") } + QoAButton { + id: rulesButtonId + + icon { + source: "qrc:/qt/qml/ChatView/icons/rules-icon.svg" + height: 15 + width: 15 + } + text: " " + + ToolTip.visible: hovered + ToolTip.delay: 250 + ToolTip.text: root.activeRulesCount > 0 + ? qsTr("View active project rules (%1)").arg(root.activeRulesCount) + : qsTr("View active project rules (no rules found)") + + Text { + id: activeRulesCountId + + anchors { + bottom: parent.bottom + bottomMargin: 2 + right: parent.right + rightMargin: 4 + } + + color: palette.text + font.pixelSize: 10 + font.bold: true + } + } + Item { Layout.fillWidth: true } diff --git a/llmcore/RulesLoader.cpp b/llmcore/RulesLoader.cpp index f55cee9..82500f4 100644 --- a/llmcore/RulesLoader.cpp +++ b/llmcore/RulesLoader.cpp @@ -109,4 +109,73 @@ QString RulesLoader::getProjectPath(ProjectExplorer::Project *project) return project->projectDirectory().toUrlishString(); } +QVector RulesLoader::getRuleFiles(const QString &projectPath, RulesContext context) +{ + if (projectPath.isEmpty()) { + return QVector(); + } + + QVector result; + QString basePath = projectPath + "/.qodeassist/rules"; + + // Always include common rules + result.append(collectMarkdownFiles(basePath + "/common", "common")); + + // Add context-specific rules + switch (context) { + case RulesContext::Completions: + result.append(collectMarkdownFiles(basePath + "/completions", "completions")); + break; + case RulesContext::Chat: + result.append(collectMarkdownFiles(basePath + "/chat", "chat")); + break; + case RulesContext::QuickRefactor: + result.append(collectMarkdownFiles(basePath + "/quickrefactor", "quickrefactor")); + break; + } + + return result; +} + +QVector RulesLoader::getRuleFilesForProject( + ProjectExplorer::Project *project, RulesContext context) +{ + if (!project) { + return QVector(); + } + + QString projectPath = getProjectPath(project); + return getRuleFiles(projectPath, context); +} + +QString RulesLoader::loadRuleFileContent(const QString &filePath) +{ + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return QString(); + } + + return file.readAll(); +} + +QVector RulesLoader::collectMarkdownFiles( + const QString &dirPath, const QString &category) +{ + QVector result; + QDir dir(dirPath); + + if (!dir.exists()) { + return result; + } + + QStringList mdFiles = dir.entryList({"*.md"}, QDir::Files, QDir::Name); + + for (const QString &fileName : mdFiles) { + QString fullPath = dir.filePath(fileName); + result.append({fullPath, fileName, category}); + } + + return result; +} + } // namespace QodeAssist::LLMCore diff --git a/llmcore/RulesLoader.hpp b/llmcore/RulesLoader.hpp index d0cd238..2fbe76e 100644 --- a/llmcore/RulesLoader.hpp +++ b/llmcore/RulesLoader.hpp @@ -29,15 +29,28 @@ namespace QodeAssist::LLMCore { enum class RulesContext { Completions, Chat, QuickRefactor }; +struct RuleFileInfo +{ + QString filePath; + QString fileName; + QString category; // "common", "chat", "completions", "quickrefactor" +}; + class RulesLoader { public: static QString loadRules(const QString &projectPath, RulesContext context); static QString loadRulesForProject(ProjectExplorer::Project *project, RulesContext context); static ProjectExplorer::Project *getActiveProject(); + + // New methods for getting rule files info + static QVector getRuleFiles(const QString &projectPath, RulesContext context); + static QVector getRuleFilesForProject(ProjectExplorer::Project *project, RulesContext context); + static QString loadRuleFileContent(const QString &filePath); private: static QString loadAllMarkdownFiles(const QString &dirPath); + static QVector collectMarkdownFiles(const QString &dirPath, const QString &category); static QString getProjectPath(ProjectExplorer::Project *project); };