From 8fe414e25ea2294809ef2b4abe448e3ccd1b5295 Mon Sep 17 00:00:00 2001 From: Petr Mironychev <9195189+Palm1r@users.noreply.github.com> Date: Sun, 13 Apr 2025 15:57:43 +0200 Subject: [PATCH] feat: Add quick refactor command via context menu --- CMakeLists.txt | 1 + QodeAssistClient.cpp | 52 +++++++ QodeAssistClient.hpp | 5 + QuickRefactorHandler.cpp | 296 +++++++++++++++++++++++++++++++++++++++ QuickRefactorHandler.hpp | 80 +++++++++++ qodeassist.cpp | 46 +++++- 6 files changed, 477 insertions(+), 3 deletions(-) create mode 100644 QuickRefactorHandler.cpp create mode 100644 QuickRefactorHandler.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8dddaad..0c1810f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -105,6 +105,7 @@ add_qtc_plugin(QodeAssist widgets/ProgressWidget.hpp widgets/ProgressWidget.cpp widgets/EditorChatButton.hpp widgets/EditorChatButton.cpp widgets/EditorChatButtonHandler.hpp widgets/EditorChatButtonHandler.cpp + QuickRefactorHandler.hpp QuickRefactorHandler.cpp ) get_target_property(QtCreatorCorePath QtCreator::Core LOCATION) diff --git a/QodeAssistClient.cpp b/QodeAssistClient.cpp index 6d5f3ed..9e5ae5a 100644 --- a/QodeAssistClient.cpp +++ b/QodeAssistClient.cpp @@ -24,8 +24,10 @@ #include "QodeAssistClient.hpp" +#include #include +#include #include #include @@ -35,6 +37,7 @@ #include "settings/GeneralSettings.hpp" #include "settings/ProjectSettings.hpp" #include +#include using namespace LanguageServerProtocol; using namespace TextEditor; @@ -170,6 +173,27 @@ void QodeAssistClient::requestCompletions(TextEditor::TextEditorWidget *editor) sendMessage(request); } +void QodeAssistClient::requestQuickRefactor( + TextEditor::TextEditorWidget *editor, const QString &instructions) +{ + auto project = ProjectManager::projectForFile(editor->textDocument()->filePath()); + + if (!isEnabled(project)) + return; + + if (!m_refactorHandler) { + m_refactorHandler = new QuickRefactorHandler(this); + connect( + m_refactorHandler, + &QuickRefactorHandler::refactoringCompleted, + this, + &QodeAssistClient::handleRefactoringResult); + } + + m_progressHandler.showProgress(editor); + m_refactorHandler->sendRefactorRequest(editor, instructions); +} + void QodeAssistClient::scheduleRequest(TextEditor::TextEditorWidget *editor) { cancelRunningRequest(editor); @@ -301,4 +325,32 @@ void QodeAssistClient::cleanupConnections() m_scheduledRequests.clear(); } +void QodeAssistClient::handleRefactoringResult(const RefactorResult &result) +{ + m_progressHandler.hideProgress(); + if (!result.success) { + LOG_MESSAGE(QString("Refactoring failed: %1").arg(result.errorMessage)); + return; + } + + auto editor = BaseTextEditor::currentTextEditor(); + if (!editor) { + LOG_MESSAGE("Refactoring failed: No active editor found"); + return; + } + + auto editorWidget = editor->editorWidget(); + + QTextCursor cursor = editorWidget->textCursor(); + cursor.beginEditBlock(); + + int startPos = result.insertRange.begin.toPositionInDocument(editorWidget->document()); + int endPos = result.insertRange.end.toPositionInDocument(editorWidget->document()); + + cursor.setPosition(startPos); + cursor.setPosition(endPos, QTextCursor::KeepAnchor); + + cursor.insertText(result.newText); + cursor.endEditBlock(); +} } // namespace QodeAssist diff --git a/QodeAssistClient.hpp b/QodeAssistClient.hpp index 0bf8a39..bde3523 100644 --- a/QodeAssistClient.hpp +++ b/QodeAssistClient.hpp @@ -26,6 +26,7 @@ #include "LLMClientInterface.hpp" #include "LSPCompletion.hpp" +#include "QuickRefactorHandler.hpp" #include "widgets/CompletionProgressHandler.hpp" #include "widgets/EditorChatButtonHandler.hpp" #include @@ -44,6 +45,8 @@ public: bool canOpenProject(ProjectExplorer::Project *project) override; void requestCompletions(TextEditor::TextEditorWidget *editor); + void requestQuickRefactor( + TextEditor::TextEditorWidget *editor, const QString &instructions = QString()); private: void scheduleRequest(TextEditor::TextEditorWidget *editor); @@ -54,6 +57,7 @@ private: void setupConnections(); void cleanupConnections(); + void handleRefactoringResult(const RefactorResult &result); QHash m_runningRequests; QHash m_scheduledRequests; @@ -64,6 +68,7 @@ private: int m_recentCharCount; CompletionProgressHandler m_progressHandler; EditorChatButtonHandler m_chatButtonHandler; + QuickRefactorHandler *m_refactorHandler{nullptr}; }; } // namespace QodeAssist diff --git a/QuickRefactorHandler.cpp b/QuickRefactorHandler.cpp new file mode 100644 index 0000000..9645d94 --- /dev/null +++ b/QuickRefactorHandler.cpp @@ -0,0 +1,296 @@ +/* + * 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 . + */ + +#include "QuickRefactorHandler.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace QodeAssist { + +QuickRefactorHandler::QuickRefactorHandler(QObject *parent) + : QObject(parent) + , m_requestHandler(new LLMCore::RequestHandler(this)) + , m_currentEditor(nullptr) + , m_isRefactoringInProgress(false) + , m_contextManager(this) +{ + connect( + m_requestHandler, + &LLMCore::RequestHandler::completionReceived, + this, + &QuickRefactorHandler::handleLLMResponse); + + connect( + m_requestHandler, + &LLMCore::RequestHandler::requestFinished, + this, + [this](const QString &requestId, bool success, const QString &errorString) { + if (!success && requestId == m_lastRequestId) { + m_isRefactoringInProgress = false; + RefactorResult result; + result.success = false; + result.errorMessage = errorString; + emit refactoringCompleted(result); + } + }); +} + +QuickRefactorHandler::~QuickRefactorHandler() {} + +void QuickRefactorHandler::sendRefactorRequest( + TextEditor::TextEditorWidget *editor, const QString &instructions) +{ + if (m_isRefactoringInProgress) { + cancelRequest(); + } + + m_currentEditor = editor; + + Utils::Text::Range range; + if (editor->textCursor().hasSelection()) { + QTextCursor cursor = editor->textCursor(); + int startPos = cursor.selectionStart(); + int endPos = cursor.selectionEnd(); + + QTextBlock startBlock = editor->document()->findBlock(startPos); + int startLine = startBlock.blockNumber() + 1; + int startColumn = startPos - startBlock.position(); + + QTextBlock endBlock = editor->document()->findBlock(endPos); + int endLine = endBlock.blockNumber() + 1; + int endColumn = endPos - endBlock.position(); + + range = Utils::Text::Range( + Utils::Text::Position(startLine, startColumn), + Utils::Text::Position(endLine, endColumn)); + } else { + QTextCursor cursor = editor->textCursor(); + int cursorPos = cursor.position(); + + QTextBlock block = editor->document()->findBlock(cursorPos); + int line = block.blockNumber() + 1; + int column = cursorPos - block.position(); + + Utils::Text::Position cursorPosition(line, column); + range = Utils::Text::Range(cursorPosition, cursorPosition); + } + + m_currentRange = range; + prepareAndSendRequest(editor, instructions, range); +} + +void QuickRefactorHandler::prepareAndSendRequest( + TextEditor::TextEditorWidget *editor, + const QString &instructions, + const Utils::Text::Range &range) +{ + auto &settings = Settings::generalSettings(); + + auto &providerRegistry = LLMCore::ProvidersManager::instance(); + auto &promptManager = LLMCore::PromptTemplateManager::instance(); + + const auto providerName = settings.caProvider(); + auto provider = providerRegistry.getProviderByName(providerName); + + if (!provider) { + LOG_MESSAGE(QString("No provider found with name: %1").arg(providerName)); + RefactorResult result; + result.success = false; + result.errorMessage = QString("No provider found with name: %1").arg(providerName); + emit refactoringCompleted(result); + return; + } + + const auto templateName = settings.caTemplate(); + auto promptTemplate = promptManager.getChatTemplateByName(templateName); + + if (!promptTemplate) { + LOG_MESSAGE(QString("No template found with name: %1").arg(templateName)); + RefactorResult result; + result.success = false; + result.errorMessage = QString("No template found with name: %1").arg(templateName); + emit refactoringCompleted(result); + return; + } + + LLMCore::LLMConfig config; + config.requestType = LLMCore::RequestType::Chat; + config.provider = provider; + config.promptTemplate = promptTemplate; + config.url = QString("%1%2").arg(settings.caUrl(), provider->chatEndpoint()); + config.providerRequest + = {{"model", settings.caModel()}, {"stream", Settings::chatAssistantSettings().stream()}}; + config.apiKey = provider->apiKey(); + + LLMCore::ContextData context = prepareContext(editor, range, instructions); + + provider + ->prepareRequest(config.providerRequest, promptTemplate, context, LLMCore::RequestType::Chat); + + QString requestId = QUuid::createUuid().toString(); + m_lastRequestId = requestId; + QJsonObject request{{"id", requestId}}; + + m_isRefactoringInProgress = true; + + m_requestHandler->sendLLMRequest(config, request); +} + +LLMCore::ContextData QuickRefactorHandler::prepareContext( + TextEditor::TextEditorWidget *editor, + const Utils::Text::Range &range, + const QString &instructions) +{ + LLMCore::ContextData context; + + auto textDocument = editor->textDocument(); + Context::DocumentReaderQtCreator documentReader; + auto documentInfo = documentReader.readDocument(textDocument->filePath().toUrlishString()); + + if (!documentInfo.document) { + LOG_MESSAGE("Error: Document is not available"); + return context; + } + + QTextCursor cursor = editor->textCursor(); + int cursorPos = cursor.position(); + + // TODO add selecting content before and after cursor/selection and from others opened files too + QString fullContent = documentInfo.document->toPlainText(); + QString taggedContent = fullContent; + + if (cursor.hasSelection()) { + int selEnd = cursor.selectionEnd(); + int selStart = cursor.selectionStart(); + taggedContent + .insert(selEnd, selEnd == cursorPos ? "" : ""); + taggedContent.insert( + selStart, selStart == cursorPos ? "" : ""); + } else { + taggedContent.insert(cursorPos, ""); + } + + QString systemPrompt = quickRefactorSystemPrompt(); + systemPrompt += "\n\nFile information:"; + systemPrompt += "\nLanguage: " + documentInfo.mimeType; + systemPrompt += "\nFile path: " + documentInfo.filePath; + + systemPrompt += "\n\nCode context with position markers:"; + systemPrompt += taggedContent; + + systemPrompt += "\n\nOutput format:"; + systemPrompt += "\n- Generate ONLY the code that should replace the current selection " + "between or be " + "inserted at cursor position"; + systemPrompt += "\n- Do not include any explanations, comments about the code, or markdown " + "code block markers"; + systemPrompt += "\n- The output should be ready to insert directly into the editor"; + systemPrompt += "\n- Follow the existing code style and indentation patterns"; + + if (Settings::codeCompletionSettings().useOpenFilesContext()) { + systemPrompt += "\n\n" + m_contextManager.openedFilesContext({documentInfo.filePath}); + } + + context.systemPrompt = systemPrompt; + + QVector messages; + messages.append( + {"user", + instructions.isEmpty() ? "Refactor the code to improve its quality and maintainability." + : instructions}); + context.history = messages; + + return context; +} + +void QuickRefactorHandler::handleLLMResponse( + const QString &response, const QJsonObject &request, bool isComplete) +{ + if (request["id"].toString() != m_lastRequestId) { + return; + } + + if (isComplete) { + m_isRefactoringInProgress = false; + + QString cleanedResponse = response.trimmed(); + if (cleanedResponse.startsWith("```")) { + int firstNewLine = cleanedResponse.indexOf('\n'); + int lastFence = cleanedResponse.lastIndexOf("```"); + + if (firstNewLine != -1 && lastFence > firstNewLine) { + cleanedResponse + = cleanedResponse.mid(firstNewLine + 1, lastFence - firstNewLine - 1).trimmed(); + } else if (lastFence != -1) { + cleanedResponse = cleanedResponse.mid(3, lastFence - 3).trimmed(); + } + } + + RefactorResult result; + result.newText = cleanedResponse; + result.insertRange = m_currentRange; + result.success = true; + + LOG_MESSAGE("Refactoring completed successfully. New code to insert: "); + LOG_MESSAGE("---------- BEGIN REFACTORED CODE ----------"); + LOG_MESSAGE(cleanedResponse); + LOG_MESSAGE("----------- END REFACTORED CODE -----------"); + + emit refactoringCompleted(result); + } else { + emit refactoringProgress(response); + } +} + +void QuickRefactorHandler::cancelRequest() +{ + if (m_isRefactoringInProgress) { + m_requestHandler->cancelRequest(m_lastRequestId); + m_isRefactoringInProgress = false; + + RefactorResult result; + result.success = false; + result.errorMessage = "Refactoring request was cancelled"; + emit refactoringCompleted(result); + } +} + +bool QuickRefactorHandler::isRefactoringInProgress() const +{ + return m_isRefactoringInProgress; +} + +QString QuickRefactorHandler::quickRefactorSystemPrompt() const +{ + return QString("You are an expert code refactoring assistant specializing in C++, Qt, and QML " + "development."); +} + +} // namespace QodeAssist diff --git a/QuickRefactorHandler.hpp b/QuickRefactorHandler.hpp new file mode 100644 index 0000000..f910e4f --- /dev/null +++ b/QuickRefactorHandler.hpp @@ -0,0 +1,80 @@ +/* + * 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 . + */ + +#pragma once + +#include +#include + +#include +#include + +#include +#include +#include + +namespace QodeAssist { + +struct RefactorResult +{ + QString newText; + Utils::Text::Range insertRange; + bool success; + QString errorMessage; +}; + +class QuickRefactorHandler : public QObject +{ + Q_OBJECT + +public: + explicit QuickRefactorHandler(QObject *parent = nullptr); + ~QuickRefactorHandler() override; + + void sendRefactorRequest(TextEditor::TextEditorWidget *editor, const QString &instructions); + + void cancelRequest(); + bool isRefactoringInProgress() const; + +signals: + void refactoringCompleted(const QodeAssist::RefactorResult &result); + void refactoringProgress(const QString &partialResult); + +private: + void prepareAndSendRequest( + TextEditor::TextEditorWidget *editor, + const QString &instructions, + const Utils::Text::Range &range); + + void handleLLMResponse(const QString &response, const QJsonObject &request, bool isComplete); + QString quickRefactorSystemPrompt() const; + LLMCore::ContextData prepareContext( + TextEditor::TextEditorWidget *editor, + const Utils::Text::Range &range, + const QString &instructions); + + LLMCore::RequestHandler *m_requestHandler; + TextEditor::TextEditorWidget *m_currentEditor; + Utils::Text::Range m_currentRange; + bool m_isRefactoringInProgress; + QString m_lastRequestId; + Context::ContextManager m_contextManager; +}; + +} // namespace QodeAssist diff --git a/qodeassist.cpp b/qodeassist.cpp index 7e61399..8b6da81 100644 --- a/qodeassist.cpp +++ b/qodeassist.cpp @@ -43,6 +43,7 @@ #include "ConfigurationManager.hpp" #include "QodeAssistClient.hpp" +#include "UpdateStatusWidget.hpp" #include "Version.hpp" #include "chat/ChatOutputPane.h" #include "chat/NavigationPanel.hpp" @@ -50,13 +51,17 @@ #include "llmcore/PromptProviderFim.hpp" #include "llmcore/ProvidersManager.hpp" #include "logger/RequestPerformanceLogger.hpp" +#include "providers/Providers.hpp" #include "settings/GeneralSettings.hpp" #include "settings/ProjectSettingsPanel.hpp" #include "settings/SettingsConstants.hpp" - -#include "UpdateStatusWidget.hpp" -#include "providers/Providers.hpp" #include "templates/Templates.hpp" +#include +#include +#include +#include +#include +#include using namespace Utils; using namespace Core; @@ -134,6 +139,41 @@ public: if (Settings::generalSettings().enableCheckUpdate()) { QTimer::singleShot(3000, this, &QodeAssistPlugin::checkForUpdates); } + + ActionBuilder quickRefactorAction(this, "QodeAssist.QuickRefactor"); + const QKeySequence quickRefactorShortcut = QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_R); + quickRefactorAction.setDefaultKeySequence(quickRefactorShortcut); + quickRefactorAction.setToolTip(Tr::tr("Refactor code using QodeAssist")); + quickRefactorAction.setText(Tr::tr("Quick Refactor with QodeAssist")); + quickRefactorAction.setIcon(QCODEASSIST_ICON.icon()); + quickRefactorAction.addOnTriggered(this, [this] { + if (auto editor = TextEditor::TextEditorWidget::currentTextEditorWidget()) { + bool ok; + if (m_qodeAssistClient && m_qodeAssistClient->reachable()) { + QString instructions = QInputDialog::getText( + Core::ICore::dialogParent(), + Tr::tr("Quick Refactor"), + Tr::tr("Enter refactoring instructions:"), + QLineEdit::Normal, + QString(), + &ok); + if (ok) + m_qodeAssistClient->requestQuickRefactor(editor, instructions); + } else { + qWarning() << "The QodeAssist is not ready. Please check your connection and " + "settings."; + } + } + }); + + Core::ActionContainer *editorContextMenu = Core::ActionManager::actionContainer( + TextEditor::Constants::M_STANDARDCONTEXTMENU); + if (editorContextMenu) { + editorContextMenu->addSeparator(Core::Context(TextEditor::Constants::C_TEXTEDITOR)); + editorContextMenu + ->addAction(quickRefactorAction.command(), Core::Constants::G_DEFAULT_THREE); + editorContextMenu->addAction(requestAction.command(), Core::Constants::G_DEFAULT_THREE); + } } void extensionsInitialized() final {}