diff --git a/CMakeLists.txt b/CMakeLists.txt index 8c8e11b..3cdce74 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -69,6 +69,7 @@ add_qtc_plugin(QodeAssist QodeAssistConstants.hpp QodeAssisttr.h LLMClientInterface.hpp LLMClientInterface.cpp + RefactorContextHelper.hpp templates/Templates.hpp templates/CodeLlamaFim.hpp templates/Ollama.hpp @@ -121,6 +122,10 @@ add_qtc_plugin(QodeAssist widgets/QuickRefactorDialog.hpp widgets/QuickRefactorDialog.cpp widgets/CustomInstructionsManager.hpp widgets/CustomInstructionsManager.cpp widgets/AddCustomInstructionDialog.hpp widgets/AddCustomInstructionDialog.cpp + widgets/RefactorWidget.hpp widgets/RefactorWidget.cpp + widgets/RefactorWidgetHandler.hpp widgets/RefactorWidgetHandler.cpp + widgets/ContextExtractor.hpp + widgets/DiffStatistics.hpp QuickRefactorHandler.hpp QuickRefactorHandler.cpp tools/ToolsFactory.hpp tools/ToolsFactory.cpp diff --git a/QodeAssistClient.cpp b/QodeAssistClient.cpp index aadcfe2..9faf53a 100644 --- a/QodeAssistClient.cpp +++ b/QodeAssistClient.cpp @@ -40,6 +40,9 @@ #include "settings/CodeCompletionSettings.hpp" #include "settings/GeneralSettings.hpp" #include "settings/ProjectSettings.hpp" +#include "settings/QuickRefactorSettings.hpp" +#include "widgets/RefactorWidgetHandler.hpp" +#include "RefactorContextHelper.hpp" #include #include @@ -71,12 +74,14 @@ QodeAssistClient::QodeAssistClient(LLMClientInterface *clientInterface) connect(&m_hintHideTimer, &QTimer::timeout, this, [this]() { m_hintHandler.hideHint(); }); m_refactorHoverHandler = new RefactorSuggestionHoverHandler(); + m_refactorWidgetHandler = new RefactorWidgetHandler(this); } QodeAssistClient::~QodeAssistClient() { cleanupConnections(); delete m_refactorHoverHandler; + delete m_refactorWidgetHandler; } void QodeAssistClient::openDocument(TextEditor::TextDocument *document) @@ -451,11 +456,25 @@ void QodeAssistClient::handleRefactoringResult(const RefactorResult &result) return; } - TextEditorWidget *editorWidget = result.editor; + int displayMode = Settings::quickRefactorSettings().displayMode(); + + if (displayMode == 0) { + displayRefactoringWidget(result); + } else { + displayRefactoringSuggestion(result); + } +} - auto toTextPos = [](const Utils::Text::Position &pos) { - return Utils::Text::Position{pos.line, pos.column}; - }; +namespace { +Utils::Text::Position toTextPos(const Utils::Text::Position &pos) +{ + return Utils::Text::Position{pos.line, pos.column}; +} +} // anonymous namespace + +void QodeAssistClient::displayRefactoringSuggestion(const RefactorResult &result) +{ + TextEditorWidget *editorWidget = result.editor; Utils::Text::Range range{toTextPos(result.insertRange.begin), toTextPos(result.insertRange.end)}; Utils::Text::Position pos = toTextPos(result.insertRange.begin); @@ -510,6 +529,81 @@ void QodeAssistClient::handleRefactoringResult(const RefactorResult &result) LOG_MESSAGE("Displaying refactoring suggestion with hover handler"); } +void QodeAssistClient::displayRefactoringWidget(const RefactorResult &result) +{ + TextEditorWidget *editorWidget = result.editor; + Utils::Text::Range range{toTextPos(result.insertRange.begin), toTextPos(result.insertRange.end)}; + + RefactorContext ctx = RefactorContextHelper::extractContext(editorWidget, range); + + QString displayOriginal; + QString displayRefactored; + QString textToApply = result.newText; + + if (ctx.isInsertion) { + bool isMultiline = result.newText.contains('\n'); + + if (isMultiline) { + displayOriginal = ctx.textBeforeCursor; + displayRefactored = ctx.textBeforeCursor + result.newText; + } else { + displayOriginal = ctx.textBeforeCursor + ctx.textAfterCursor; + displayRefactored = ctx.textBeforeCursor + result.newText + ctx.textAfterCursor; + } + + if (!ctx.textBeforeCursor.isEmpty() || !ctx.textAfterCursor.isEmpty()) { + textToApply = result.newText; + } + } else { + displayOriginal = ctx.originalText; + displayRefactored = result.newText; + } + + m_refactorWidgetHandler->setApplyCallback([this, editorWidget, result](const QString &editedText) { + applyRefactoringEdit(editorWidget, result.insertRange, editedText); + }); + + m_refactorWidgetHandler->setDeclineCallback([]() {}); + + m_refactorWidgetHandler->showRefactorWidget( + editorWidget, displayOriginal, displayRefactored, range, + ctx.contextBefore, ctx.contextAfter); + + m_refactorWidgetHandler->setTextToApply(textToApply); +} + +void QodeAssistClient::applyRefactoringEdit(TextEditor::TextEditorWidget *editor, + const Utils::Text::Range &range, + const QString &text) +{ + const QTextCursor startCursor = range.begin.toTextCursor(editor->document()); + const QTextCursor endCursor = range.end.toTextCursor(editor->document()); + const int startPos = startCursor.position(); + const int endPos = endCursor.position(); + + QTextCursor editCursor(editor->document()); + editCursor.beginEditBlock(); + + if (startPos == endPos) { + bool isMultiline = text.contains('\n'); + editCursor.setPosition(startPos); + + if (isMultiline) { + editCursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor); + editCursor.removeSelectedText(); + } + + editCursor.insertText(text); + } else { + editCursor.setPosition(startPos); + editCursor.setPosition(endPos, QTextCursor::KeepAnchor); + editCursor.removeSelectedText(); + editCursor.insertText(text); + } + + editCursor.endEditBlock(); +} + void QodeAssistClient::handleAutoRequestTrigger(TextEditor::TextEditorWidget *widget, int charsAdded, bool isSpaceOrTab) diff --git a/QodeAssistClient.hpp b/QodeAssistClient.hpp index 2a127f5..b930f10 100644 --- a/QodeAssistClient.hpp +++ b/QodeAssistClient.hpp @@ -34,6 +34,7 @@ #include "widgets/CompletionErrorHandler.hpp" #include "widgets/CompletionHintHandler.hpp" #include "widgets/EditorChatButtonHandler.hpp" +#include "widgets/RefactorWidgetHandler.hpp" #include #include #include @@ -70,6 +71,9 @@ private: void setupConnections(); void cleanupConnections(); void handleRefactoringResult(const RefactorResult &result); + void displayRefactoringSuggestion(const RefactorResult &result); + void displayRefactoringWidget(const RefactorResult &result); + void applyRefactoringEdit(TextEditor::TextEditorWidget *editor, const Utils::Text::Range &range, const QString &text); void handleAutoRequestTrigger(TextEditor::TextEditorWidget *widget, int charsAdded, bool isSpaceOrTab); void handleHintBasedTrigger(TextEditor::TextEditorWidget *widget, int charsAdded, bool isSpaceOrTab, QTextCursor &cursor); @@ -88,6 +92,7 @@ private: EditorChatButtonHandler m_chatButtonHandler; QuickRefactorHandler *m_refactorHandler{nullptr}; RefactorSuggestionHoverHandler *m_refactorHoverHandler{nullptr}; + RefactorWidgetHandler *m_refactorWidgetHandler{nullptr}; LLMClientInterface *m_llmClient; }; diff --git a/QuickRefactorHandler.cpp b/QuickRefactorHandler.cpp index 3b95f4f..290901b 100644 --- a/QuickRefactorHandler.cpp +++ b/QuickRefactorHandler.cpp @@ -24,6 +24,7 @@ #include #include +#include #include #include #include @@ -301,17 +302,65 @@ LLMCore::ContextData QuickRefactorHandler::prepareContext( systemPrompt += "\nLanguage: " + documentInfo.mimeType; systemPrompt += "\nFile path: " + documentInfo.filePath; - systemPrompt += "\n\nCode context with position markers:"; - systemPrompt += taggedContent; + systemPrompt += "\n\n# Code Context with Position Markers\n" + 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"; + systemPrompt += "\n\n# Output Requirements\n## What to Generate:"; + systemPrompt += cursor.hasSelection() + ? "\n- Generate ONLY the code that should REPLACE the selected text between " + " and markers" + "\n- Your output will completely replace the selected code" + : "\n- Generate ONLY the code that should be INSERTED at the position" + "\n- Your output will be inserted at the cursor location"; + + systemPrompt += "\n\n## Formatting Rules:" + "\n- Output ONLY the code itself, without ANY explanations or descriptions" + "\n- Do NOT include markdown code blocks (no ```, no language tags)" + "\n- Do NOT add comments explaining what you changed" + "\n- Do NOT repeat existing code, be precise with context" + "\n- Do NOT send in answer or and other tags" + "\n- The output must be ready to insert directly into the editor as-is"; + + systemPrompt += "\n\n## Indentation and Whitespace:"; + + if (cursor.hasSelection()) { + QTextBlock startBlock = documentInfo.document->findBlock(cursor.selectionStart()); + int leadingSpaces = 0; + for (QChar c : startBlock.text()) { + if (c == ' ') leadingSpaces++; + else if (c == '\t') leadingSpaces += 4; + else break; + } + if (leadingSpaces > 0) { + systemPrompt += QString("\n- CRITICAL: The code to replace starts with %1 spaces of indentation" + "\n- Your output MUST start with exactly %1 spaces (or equivalent tabs)" + "\n- Each line in your output must maintain this base indentation") + .arg(leadingSpaces); + } + systemPrompt += "\n- PRESERVE all indentation from the original code"; + } else { + QTextBlock block = documentInfo.document->findBlock(cursorPos); + QString lineText = block.text(); + int leadingSpaces = 0; + for (QChar c : lineText) { + if (c == ' ') leadingSpaces++; + else if (c == '\t') leadingSpaces += 4; + else break; + } + if (leadingSpaces > 0) { + systemPrompt += QString("\n- CRITICAL: Current line has %1 spaces of indentation" + "\n- If generating multiline code, EVERY line must start with at least %1 spaces" + "\n- If generating single-line code, it will be inserted inline (no indentation needed)") + .arg(leadingSpaces); + } + } + + systemPrompt += "\n- Use the same indentation style (spaces or tabs) as the surrounding code" + "\n- Maintain consistent indentation for nested blocks" + "\n- Do NOT remove or reduce the base indentation level" + "\n\n## Code Style:" + "\n- Match the coding style of the surrounding code (naming, spacing, braces, etc.)" + "\n- Preserve the original code structure when possible" + "\n- Only change what is necessary to fulfill the user's request"; if (Settings::codeCompletionSettings().useOpenFilesInQuickRefactor()) { systemPrompt += "\n\n" + m_contextManager.openedFilesContext({documentInfo.filePath}); @@ -338,19 +387,7 @@ void QuickRefactorHandler::handleLLMResponse( 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(); - } - } + QString cleanedResponse = LLMCore::ResponseCleaner::clean(response); RefactorResult result; result.newText = cleanedResponse; diff --git a/RefactorContextHelper.hpp b/RefactorContextHelper.hpp new file mode 100644 index 0000000..ff6494c --- /dev/null +++ b/RefactorContextHelper.hpp @@ -0,0 +1,115 @@ +/* + * 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 + +namespace QodeAssist { + +struct RefactorContext +{ + QString originalText; + QString textBeforeCursor; + QString textAfterCursor; + QString contextBefore; + QString contextAfter; + int startPos{0}; + int endPos{0}; + bool isInsertion{false}; +}; + +class RefactorContextHelper +{ +public: + static RefactorContext extractContext(TextEditor::TextEditorWidget *editor, + const Utils::Text::Range &range, + int contextLinesBefore = 3, + int contextLinesAfter = 3) + { + RefactorContext ctx; + if (!editor) { + return ctx; + } + + QTextDocument *doc = editor->document(); + ctx.startPos = range.begin.toPositionInDocument(doc); + ctx.endPos = range.end.toPositionInDocument(doc); + ctx.isInsertion = (ctx.startPos == ctx.endPos); + + if (!ctx.isInsertion) { + QTextCursor cursor(doc); + cursor.setPosition(ctx.startPos); + cursor.setPosition(ctx.endPos, QTextCursor::KeepAnchor); + ctx.originalText = cursor.selectedText(); + ctx.originalText.replace(QChar(0x2029), "\n"); + } else { + QTextCursor cursor(doc); + cursor.setPosition(ctx.startPos); + + int posInBlock = cursor.positionInBlock(); + cursor.movePosition(QTextCursor::StartOfBlock); + cursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, posInBlock); + ctx.textBeforeCursor = cursor.selectedText(); + + cursor.setPosition(ctx.startPos); + cursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor); + ctx.textAfterCursor = cursor.selectedText(); + } + + ctx.contextBefore = extractContextLines(doc, ctx.startPos, contextLinesBefore, true); + ctx.contextAfter = extractContextLines(doc, ctx.endPos, contextLinesAfter, false); + + return ctx; + } + +private: + static QString extractContextLines(QTextDocument *doc, int position, int lineCount, bool before) + { + QTextCursor cursor(doc); + cursor.setPosition(position); + QTextBlock currentBlock = cursor.block(); + + QStringList lines; + + if (before) { + QTextBlock block = currentBlock.previous(); + for (int i = 0; i < lineCount && block.isValid(); ++i) { + lines.prepend(block.text()); + block = block.previous(); + } + } else { + QTextBlock block = currentBlock.next(); + for (int i = 0; i < lineCount && block.isValid(); ++i) { + lines.append(block.text()); + block = block.next(); + } + } + + return lines.join('\n'); + } +}; + +} // namespace QodeAssist + diff --git a/llmcore/CMakeLists.txt b/llmcore/CMakeLists.txt index 8137595..83a9ab6 100644 --- a/llmcore/CMakeLists.txt +++ b/llmcore/CMakeLists.txt @@ -18,6 +18,7 @@ add_library(LLMCore STATIC BaseTool.hpp BaseTool.cpp ContentBlocks.hpp RulesLoader.hpp RulesLoader.cpp + ResponseCleaner.hpp ) target_link_libraries(LLMCore diff --git a/llmcore/ResponseCleaner.hpp b/llmcore/ResponseCleaner.hpp new file mode 100644 index 0000000..e2dc683 --- /dev/null +++ b/llmcore/ResponseCleaner.hpp @@ -0,0 +1,119 @@ +/* + * 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 + +namespace QodeAssist::LLMCore { + +class ResponseCleaner +{ +public: + static QString clean(const QString &response) + { + QString cleaned = removeCodeBlocks(response); + cleaned = trimWhitespace(cleaned); + cleaned = removeExplanations(cleaned); + return cleaned; + } + +private: + static QString removeCodeBlocks(const QString &text) + { + if (!text.contains("```")) { + return text; + } + + QRegularExpression codeBlockRegex("```\\w*\\n([\\s\\S]*?)```"); + QRegularExpressionMatch match = codeBlockRegex.match(text); + if (match.hasMatch()) { + return match.captured(1); + } + + int firstFence = text.indexOf("```"); + int lastFence = text.lastIndexOf("```"); + if (firstFence != -1 && lastFence > firstFence) { + int firstNewLine = text.indexOf('\n', firstFence); + if (firstNewLine != -1) { + return text.mid(firstNewLine + 1, lastFence - firstNewLine - 1); + } + } + + return text; + } + + static QString trimWhitespace(const QString &text) + { + QString result = text; + while (result.startsWith('\n') || result.startsWith('\r')) { + result = result.mid(1); + } + while (result.endsWith('\n') || result.endsWith('\r')) { + result.chop(1); + } + return result; + } + + static QString removeExplanations(const QString &text) + { + static const QStringList explanationPrefixes = { + "here's the", "here is the", "here's", "here is", + "the refactored", "refactored code:", "code:", + "i've refactored", "i refactored", "i've changed", "i changed" + }; + + QStringList lines = text.split('\n'); + int startLine = 0; + + for (int i = 0; i < qMin(3, lines.size()); ++i) { + QString line = lines[i].trimmed().toLower(); + bool isExplanation = false; + + for (const QString &prefix : explanationPrefixes) { + if (line.startsWith(prefix) || line.contains(prefix + " code")) { + isExplanation = true; + break; + } + } + + if (line.length() < 50 && line.endsWith(':')) { + isExplanation = true; + } + + if (isExplanation) { + startLine = i + 1; + } else if (!line.isEmpty()) { + break; + } + } + + if (startLine > 0 && startLine < lines.size()) { + lines = lines.mid(startLine); + return lines.join('\n'); + } + + return text; + } +}; + +} // namespace QodeAssist::LLMCore + diff --git a/settings/QuickRefactorSettings.cpp b/settings/QuickRefactorSettings.cpp index a188b2a..7396ca0 100644 --- a/settings/QuickRefactorSettings.cpp +++ b/settings/QuickRefactorSettings.cpp @@ -155,6 +155,50 @@ QuickRefactorSettings::QuickRefactorSettings() readStringsAfterCursor.setRange(0, 10000); readStringsAfterCursor.setDefaultValue(30); + displayMode.setSettingsKey(Constants::QR_DISPLAY_MODE); + displayMode.setLabelText(Tr::tr("Display Mode:")); + displayMode.setToolTip( + Tr::tr("Choose how to display refactoring suggestions:\n" + "- Inline Widget: Shows refactor in a widget overlay with Apply/Decline buttons (default)\n" + "- Qt Creator Suggestion: Uses Qt Creator's built-in suggestion system")); + displayMode.addOption(Tr::tr("Inline Widget")); + displayMode.addOption(Tr::tr("Qt Creator Suggestion")); + displayMode.setDefaultValue(0); + + widgetOrientation.setSettingsKey(Constants::QR_WIDGET_ORIENTATION); + widgetOrientation.setLabelText(Tr::tr("Widget Orientation:")); + widgetOrientation.setToolTip( + Tr::tr("Choose default orientation for refactor widget:\n" + "- Horizontal: Original and refactored code side by side (default)\n" + "- Vertical: Original and refactored code stacked vertically")); + widgetOrientation.addOption(Tr::tr("Horizontal")); + widgetOrientation.addOption(Tr::tr("Vertical")); + widgetOrientation.setDefaultValue(0); + + widgetMinWidth.setSettingsKey(Constants::QR_WIDGET_MIN_WIDTH); + widgetMinWidth.setLabelText(Tr::tr("Widget Minimum Width:")); + widgetMinWidth.setToolTip(Tr::tr("Minimum width for the refactor widget (in pixels)")); + widgetMinWidth.setRange(400, 2000); + widgetMinWidth.setDefaultValue(600); + + widgetMaxWidth.setSettingsKey(Constants::QR_WIDGET_MAX_WIDTH); + widgetMaxWidth.setLabelText(Tr::tr("Widget Maximum Width:")); + widgetMaxWidth.setToolTip(Tr::tr("Maximum width for the refactor widget (in pixels)")); + widgetMaxWidth.setRange(600, 3000); + widgetMaxWidth.setDefaultValue(1400); + + widgetMinHeight.setSettingsKey(Constants::QR_WIDGET_MIN_HEIGHT); + widgetMinHeight.setLabelText(Tr::tr("Widget Minimum Height:")); + widgetMinHeight.setToolTip(Tr::tr("Minimum height for the refactor widget (in pixels)")); + widgetMinHeight.setRange(80, 800); + widgetMinHeight.setDefaultValue(120); + + widgetMaxHeight.setSettingsKey(Constants::QR_WIDGET_MAX_HEIGHT); + widgetMaxHeight.setLabelText(Tr::tr("Widget Maximum Height:")); + widgetMaxHeight.setToolTip(Tr::tr("Maximum height for the refactor widget (in pixels)")); + widgetMaxHeight.setRange(200, 1200); + widgetMaxHeight.setDefaultValue(500); + systemPrompt.setSettingsKey(Constants::QR_SYSTEM_PROMPT); systemPrompt.setLabelText(Tr::tr("System Prompt:")); systemPrompt.setDisplayStyle(Utils::StringAspect::TextEditDisplay); @@ -198,6 +242,12 @@ QuickRefactorSettings::QuickRefactorSettings() contextGrid.addRow({Row{readFullFile}}); contextGrid.addRow({Row{readFileParts, readStringsBeforeCursor, readStringsAfterCursor}}); + auto displayGrid = Grid{}; + displayGrid.addRow({Row{displayMode}}); + displayGrid.addRow({Row{widgetOrientation}}); + displayGrid.addRow({Row{widgetMinWidth, widgetMaxWidth}}); + displayGrid.addRow({Row{widgetMinHeight, widgetMaxHeight}}); + return Column{ Row{Stretch{1}, resetToDefaults}, Space{8}, @@ -212,6 +262,8 @@ QuickRefactorSettings::QuickRefactorSettings() Space{8}, Group{title(Tr::tr("Context Settings")), Column{Row{contextGrid, Stretch{1}}}}, Space{8}, + Group{title(Tr::tr("Display Settings")), Column{Row{displayGrid, Stretch{1}}}}, + Space{8}, Group{title(Tr::tr("Prompt Settings")), Column{Row{systemPrompt}}}, Space{8}, Group{title(Tr::tr("Ollama Settings")), Column{Row{ollamaGrid, Stretch{1}}}}, @@ -240,6 +292,32 @@ void QuickRefactorSettings::setupConnections() writeSettings(); } }); + + // Enable/disable widgetOrientation based on displayMode + // 0 = Inline Widget, 1 = Qt Creator Suggestion + auto updateWidgetOrientationEnabled = [this]() { + bool isInlineWidget = (displayMode.volatileValue() == 0); + widgetOrientation.setEnabled(isInlineWidget); + }; + + connect(&displayMode, &Utils::SelectionAspect::volatileValueChanged, + this, updateWidgetOrientationEnabled); + + updateWidgetOrientationEnabled(); + + auto validateWidgetSizes = [this]() { + if (widgetMinWidth.volatileValue() > widgetMaxWidth.volatileValue()) { + widgetMaxWidth.setValue(widgetMinWidth.volatileValue()); + } + if (widgetMinHeight.volatileValue() > widgetMaxHeight.volatileValue()) { + widgetMaxHeight.setValue(widgetMinHeight.volatileValue()); + } + }; + + connect(&widgetMinWidth, &Utils::IntegerAspect::volatileValueChanged, this, validateWidgetSizes); + connect(&widgetMaxWidth, &Utils::IntegerAspect::volatileValueChanged, this, validateWidgetSizes); + connect(&widgetMinHeight, &Utils::IntegerAspect::volatileValueChanged, this, validateWidgetSizes); + connect(&widgetMaxHeight, &Utils::IntegerAspect::volatileValueChanged, this, validateWidgetSizes); } void QuickRefactorSettings::resetSettingsToDefaults() @@ -272,6 +350,12 @@ void QuickRefactorSettings::resetSettingsToDefaults() resetAspect(readFileParts); resetAspect(readStringsBeforeCursor); resetAspect(readStringsAfterCursor); + resetAspect(displayMode); + resetAspect(widgetOrientation); + resetAspect(widgetMinWidth); + resetAspect(widgetMaxWidth); + resetAspect(widgetMinHeight); + resetAspect(widgetMaxHeight); resetAspect(systemPrompt); writeSettings(); } diff --git a/settings/QuickRefactorSettings.hpp b/settings/QuickRefactorSettings.hpp index 05f3bf4..54405f8 100644 --- a/settings/QuickRefactorSettings.hpp +++ b/settings/QuickRefactorSettings.hpp @@ -67,6 +67,14 @@ public: Utils::IntegerAspect readStringsBeforeCursor{this}; Utils::IntegerAspect readStringsAfterCursor{this}; + // Display Settings + Utils::SelectionAspect displayMode{this}; + Utils::SelectionAspect widgetOrientation{this}; + Utils::IntegerAspect widgetMinWidth{this}; + Utils::IntegerAspect widgetMaxWidth{this}; + Utils::IntegerAspect widgetMinHeight{this}; + Utils::IntegerAspect widgetMaxHeight{this}; + // Prompt Settings Utils::StringAspect systemPrompt{this}; diff --git a/settings/SettingsConstants.hpp b/settings/SettingsConstants.hpp index 3ce74f1..a877e78 100644 --- a/settings/SettingsConstants.hpp +++ b/settings/SettingsConstants.hpp @@ -220,5 +220,11 @@ const char QR_READ_FULL_FILE[] = "QodeAssist.qrReadFullFile"; const char QR_READ_STRINGS_BEFORE_CURSOR[] = "QodeAssist.qrReadStringsBeforeCursor"; const char QR_READ_STRINGS_AFTER_CURSOR[] = "QodeAssist.qrReadStringsAfterCursor"; const char QR_SYSTEM_PROMPT[] = "QodeAssist.qrSystemPrompt"; +const char QR_DISPLAY_MODE[] = "QodeAssist.qrDisplayMode"; +const char QR_WIDGET_ORIENTATION[] = "QodeAssist.qrWidgetOrientation"; +const char QR_WIDGET_MIN_WIDTH[] = "QodeAssist.qrWidgetMinWidth"; +const char QR_WIDGET_MAX_WIDTH[] = "QodeAssist.qrWidgetMaxWidth"; +const char QR_WIDGET_MIN_HEIGHT[] = "QodeAssist.qrWidgetMinHeight"; +const char QR_WIDGET_MAX_HEIGHT[] = "QodeAssist.qrWidgetMaxHeight"; } // namespace QodeAssist::Constants diff --git a/widgets/ContextExtractor.hpp b/widgets/ContextExtractor.hpp new file mode 100644 index 0000000..5541042 --- /dev/null +++ b/widgets/ContextExtractor.hpp @@ -0,0 +1,125 @@ +/* + * 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 + +namespace QodeAssist { + +class ContextExtractor +{ +public: + static QString extractBefore(TextEditor::TextEditorWidget *editor, + const Utils::Text::Range &range, + int lineCount) + { + if (!editor || lineCount <= 0) { + return QString(); + } + + QTextDocument *doc = editor->document(); + int startLine = range.begin.line; + int contextStartLine = qMax(1, startLine - lineCount); + + QStringList contextLines; + for (int line = contextStartLine; line < startLine; ++line) { + QTextBlock block = doc->findBlockByNumber(line - 1); + if (block.isValid()) { + contextLines.append(block.text()); + } + } + + return contextLines.join('\n'); + } + + static QString extractAfter(TextEditor::TextEditorWidget *editor, + const Utils::Text::Range &range, + int lineCount) + { + if (!editor || lineCount <= 0) { + return QString(); + } + + QTextDocument *doc = editor->document(); + int endLine = range.end.line; + int totalLines = doc->blockCount(); + int contextEndLine = qMin(totalLines, endLine + lineCount); + + QStringList contextLines; + for (int line = endLine + 1; line <= contextEndLine; ++line) { + QTextBlock block = doc->findBlockByNumber(line - 1); + if (block.isValid()) { + contextLines.append(block.text()); + } + } + + return contextLines.join('\n'); + } + + static QString extractLineContext(QTextDocument *doc, int position, bool before) + { + QTextCursor cursor(doc); + cursor.setPosition(position); + + if (before) { + int posInBlock = cursor.positionInBlock(); + cursor.movePosition(QTextCursor::StartOfBlock); + cursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, posInBlock); + return cursor.selectedText(); + } else { + cursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor); + return cursor.selectedText(); + } + } + + static QStringList extractSurroundingLines(QTextDocument *doc, int position, int linesBefore, int linesAfter) + { + QTextCursor cursor(doc); + cursor.setPosition(position); + QTextBlock currentBlock = cursor.block(); + + QStringList result; + + QTextBlock blockBefore = currentBlock.previous(); + QStringList beforeLines; + for (int i = 0; i < linesBefore && blockBefore.isValid(); ++i) { + beforeLines.prepend(blockBefore.text()); + blockBefore = blockBefore.previous(); + } + result.append(beforeLines); + + QTextBlock blockAfter = currentBlock.next(); + for (int i = 0; i < linesAfter && blockAfter.isValid(); ++i) { + result.append(blockAfter.text()); + blockAfter = blockAfter.next(); + } + + return result; + } +}; + +} // namespace QodeAssist + diff --git a/widgets/DiffStatistics.hpp b/widgets/DiffStatistics.hpp new file mode 100644 index 0000000..89b64f9 --- /dev/null +++ b/widgets/DiffStatistics.hpp @@ -0,0 +1,71 @@ +/* + * 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 + +namespace QodeAssist { + +class DiffStatistics +{ + Q_DECLARE_TR_FUNCTIONS(DiffStatistics) + +public: + DiffStatistics() = default; + + void calculate(const QList &diffList) + { + m_linesAdded = 0; + m_linesRemoved = 0; + + for (const auto &diff : diffList) { + if (diff.command == Utils::Diff::Insert) { + m_linesAdded += diff.text.count('\n') + (diff.text.isEmpty() ? 0 : 1); + } else if (diff.command == Utils::Diff::Delete) { + m_linesRemoved += diff.text.count('\n') + (diff.text.isEmpty() ? 0 : 1); + } + } + } + + int linesAdded() const { return m_linesAdded; } + int linesRemoved() const { return m_linesRemoved; } + + QString formatSummary() const + { + if (m_linesAdded > 0 && m_linesRemoved > 0) { + return tr("+%1 lines, -%2 lines").arg(m_linesAdded).arg(m_linesRemoved); + } else if (m_linesAdded > 0) { + return tr("+%1 lines").arg(m_linesAdded); + } else if (m_linesRemoved > 0) { + return tr("-%1 lines").arg(m_linesRemoved); + } + return tr("No changes"); + } + +private: + int m_linesAdded{0}; + int m_linesRemoved{0}; +}; + +} // namespace QodeAssist + diff --git a/widgets/RefactorWidget.cpp b/widgets/RefactorWidget.cpp new file mode 100644 index 0000000..7f1bce1 --- /dev/null +++ b/widgets/RefactorWidget.cpp @@ -0,0 +1,732 @@ +/* + * 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 "RefactorWidget.hpp" +#include "DiffStatistics.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "settings/QuickRefactorSettings.hpp" + +namespace QodeAssist { + +CustomSplitterHandle::CustomSplitterHandle(Qt::Orientation orientation, QSplitter *parent) + : QSplitterHandle(orientation, parent) +{ + if (orientation == Qt::Horizontal) { + setCursor(Qt::SplitHCursor); + } else { + setCursor(Qt::SplitVCursor); + } + + setMouseTracking(true); +} + +void CustomSplitterHandle::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + + QColor bgColor = Utils::creatorColor(Utils::Theme::BackgroundColorHover); + bgColor.setAlpha(m_hovered ? 150 : 50); + painter.fillRect(rect(), bgColor); + + QColor lineColor = Utils::creatorColor(Utils::Theme::SplitterColor); + lineColor.setAlpha(m_hovered ? 255 : 180); + + const int lineWidth = m_hovered ? 3 : 2; + const int margin = 10; + painter.setPen(QPen(lineColor, lineWidth)); + + if (orientation() == Qt::Horizontal) { + int x = width() / 2; + painter.drawLine(x, margin, x, height() - margin); + + painter.setBrush(lineColor); + int centerY = height() / 2; + const int dotSize = m_hovered ? 3 : 2; + const int dotSpacing = 8; + for (int i = -2; i <= 2; ++i) { + painter.drawEllipse(QPoint(x, centerY + i * dotSpacing), dotSize, dotSize); + } + } else { + int y = height() / 2; + painter.drawLine(margin, y, width() - margin, y); + } +} + +void CustomSplitterHandle::enterEvent(QEnterEvent *event) +{ + m_hovered = true; + update(); + QSplitterHandle::enterEvent(event); +} + +void CustomSplitterHandle::leaveEvent(QEvent *event) +{ + m_hovered = false; + update(); + QSplitterHandle::leaveEvent(event); +} + +CustomSplitter::CustomSplitter(Qt::Orientation orientation, QWidget *parent) + : QSplitter(orientation, parent) +{ +} + +QSplitterHandle *CustomSplitter::createHandle() +{ + return new CustomSplitterHandle(orientation(), this); +} + +RefactorWidget::RefactorWidget(TextEditor::TextEditorWidget *sourceEditor, QWidget *parent) + : QWidget(parent) + , m_sourceEditor(sourceEditor) + , m_leftEditor(nullptr) + , m_rightEditor(nullptr) + , m_leftContainer(nullptr) + , m_splitter(nullptr) + , m_statsLabel(nullptr) + , m_applyButton(nullptr) + , m_declineButton(nullptr) + , m_editorWidth(800) + , m_syncingScroll(false) + , m_isClosing(false) + , m_linesAdded(0) + , m_linesRemoved(0) +{ + setupUi(); + applyEditorSettings(); + setWindowFlags(Qt::Popup | Qt::FramelessWindowHint); + setAttribute(Qt::WA_DeleteOnClose); + setFocusPolicy(Qt::StrongFocus); +} + +RefactorWidget::~RefactorWidget() +{ +} + +void RefactorWidget::setupUi() +{ + auto *mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(6, 6, 6, 6); + mainLayout->setSpacing(4); + + m_statsLabel = new QLabel(this); + m_statsLabel->setAlignment(Qt::AlignLeft); + mainLayout->addWidget(m_statsLabel); + + m_leftDocument = QSharedPointer::create(); + m_rightDocument = QSharedPointer::create(); + + Qt::Orientation initialOrientation = Settings::quickRefactorSettings().widgetOrientation.value() == 1 + ? Qt::Vertical : Qt::Horizontal; + + m_splitter = new CustomSplitter(initialOrientation, this); + m_splitter->setChildrenCollapsible(false); + m_splitter->setHandleWidth(12); + m_splitter->setStyleSheet("QSplitter::handle { background-color: transparent; }"); + + m_leftEditor = new TextEditor::TextEditorWidget(); + m_leftEditor->setTextDocument(m_leftDocument); + m_leftEditor->setReadOnly(true); + m_leftEditor->setFrameStyle(QFrame::StyledPanel); + m_leftEditor->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_leftEditor->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); + m_leftEditor->setMinimumHeight(100); + m_leftEditor->setMinimumWidth(150); + m_leftEditor->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + m_leftEditor->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard); + + m_rightEditor = new TextEditor::TextEditorWidget(); + m_rightEditor->setTextDocument(m_rightDocument); + m_rightEditor->setReadOnly(false); + m_rightEditor->setFrameStyle(QFrame::StyledPanel); + m_rightEditor->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + m_rightEditor->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); + m_rightEditor->setMinimumHeight(100); + m_rightEditor->setMinimumWidth(150); + m_rightEditor->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + + m_leftContainer = new QWidget(); + m_leftContainer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + auto *leftLayout = new QVBoxLayout(m_leftContainer); + leftLayout->setSpacing(2); + leftLayout->setContentsMargins(0, 0, 0, 0); + + auto *originalLabel = new QLabel(tr("◄ Original"), m_leftContainer); + leftLayout->addWidget(originalLabel); + leftLayout->addWidget(m_leftEditor); + + auto *rightContainer = new QWidget(); + rightContainer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + auto *rightLayout = new QVBoxLayout(rightContainer); + rightLayout->setSpacing(2); + rightLayout->setContentsMargins(0, 0, 0, 0); + + auto *refactoredLabel = new QLabel(tr("Refactored ►"), rightContainer); + rightLayout->addWidget(refactoredLabel); + rightLayout->addWidget(m_rightEditor); + + m_splitter->addWidget(m_leftContainer); + m_splitter->addWidget(rightContainer); + + m_splitter->setStretchFactor(0, 1); + m_splitter->setStretchFactor(1, 1); + + mainLayout->addWidget(m_splitter); + + connect(m_leftEditor->verticalScrollBar(), &QScrollBar::valueChanged, + this, &RefactorWidget::syncLeftScroll); + connect(m_rightEditor->verticalScrollBar(), &QScrollBar::valueChanged, + this, &RefactorWidget::syncRightScroll); + connect(m_leftEditor->horizontalScrollBar(), &QScrollBar::valueChanged, + this, &RefactorWidget::syncLeftHorizontalScroll); + connect(m_rightEditor->horizontalScrollBar(), &QScrollBar::valueChanged, + this, &RefactorWidget::syncRightHorizontalScroll); + + connect(m_rightDocument->document(), &QTextDocument::contentsChanged, + this, &RefactorWidget::onRightEditorTextChanged); + + auto *buttonLayout = new QHBoxLayout(); + buttonLayout->setContentsMargins(0, 2, 0, 0); + buttonLayout->setSpacing(6); + +#ifdef Q_OS_MACOS + m_applyButton = new QPushButton(tr("✓ Apply (⌘+Enter)"), this); +#else + m_applyButton = new QPushButton(tr("✓ Apply (Ctrl+Enter)"), this); +#endif + m_applyButton->setFocusPolicy(Qt::NoFocus); + m_applyButton->setCursor(Qt::PointingHandCursor); + m_applyButton->setMaximumHeight(24); + + m_declineButton = new QPushButton(tr("✗ Decline (Esc)"), this); + m_declineButton->setFocusPolicy(Qt::NoFocus); + m_declineButton->setCursor(Qt::PointingHandCursor); + m_declineButton->setMaximumHeight(24); + + buttonLayout->addStretch(); + buttonLayout->addWidget(m_applyButton); + buttonLayout->addWidget(m_declineButton); + + mainLayout->addLayout(buttonLayout); + + connect(m_applyButton, &QPushButton::clicked, this, &RefactorWidget::applyRefactoring); + connect(m_declineButton, &QPushButton::clicked, this, &RefactorWidget::declineRefactoring); +} + +void RefactorWidget::setDiffContent(const QString &originalText, const QString &refactoredText) +{ + setDiffContent(originalText, refactoredText, QString(), QString()); +} + +void RefactorWidget::setDiffContent(const QString &originalText, const QString &refactoredText, + const QString &contextBefore, const QString &contextAfter) +{ + m_originalText = originalText; + m_refactoredText = refactoredText; + m_contextBefore = contextBefore; + m_contextAfter = contextAfter; + + m_leftContainer->setVisible(true); + + QString leftFullText; + QString rightFullText; + + if (!contextBefore.isEmpty()) { + leftFullText = contextBefore + "\n"; + rightFullText = contextBefore + "\n"; + } + + leftFullText += originalText; + rightFullText += refactoredText; + + if (!contextAfter.isEmpty()) { + leftFullText += "\n" + contextAfter; + rightFullText += "\n" + contextAfter; + } + + m_leftDocument->setPlainText(leftFullText); + m_rightDocument->setPlainText(rightFullText); + + applySyntaxHighlighting(); + + if (!contextBefore.isEmpty() || !contextAfter.isEmpty()) { + dimContextLines(contextBefore, contextAfter); + } + + Utils::Differ differ; + m_cachedDiffList = differ.diff(m_originalText, m_refactoredText); + + highlightDifferences(); + addLineMarkers(); + + calculateStats(); + updateStatsLabel(); + + updateSizeToContent(); +} + +void RefactorWidget::highlightDifferences() +{ + if (m_cachedDiffList.isEmpty()) { + return; + } + + QList leftDiffs; + QList rightDiffs; + Utils::Differ::splitDiffList(m_cachedDiffList, &leftDiffs, &rightDiffs); + + int contextBeforeOffset = m_contextBefore.isEmpty() ? 0 : (m_contextBefore.length() + 1); + + QColor normalTextColor = Utils::creatorColor(Utils::Theme::TextColorNormal); + + QTextCursor leftCursor(m_leftDocument->document()); + QTextCharFormat removedFormat; + QColor removedBg = Utils::creatorColor(Utils::Theme::TextColorError); + removedBg.setAlpha(30); + removedFormat.setBackground(removedBg); + removedFormat.setForeground(normalTextColor); + + int leftPos = 0; + for (const auto &diff : leftDiffs) { + if (diff.command == Utils::Diff::Delete) { + leftCursor.setPosition(contextBeforeOffset + leftPos); + leftCursor.setPosition(contextBeforeOffset + leftPos + diff.text.length(), QTextCursor::KeepAnchor); + leftCursor.setCharFormat(removedFormat); + } + if (diff.command != Utils::Diff::Insert) { + leftPos += diff.text.length(); + } + } + + QTextCursor rightCursor(m_rightDocument->document()); + QTextCharFormat addedFormat; + QColor addedBg = Utils::creatorColor(Utils::Theme::IconsRunColor); + addedBg.setAlpha(60); + addedFormat.setBackground(addedBg); + addedFormat.setForeground(normalTextColor); + + int rightPos = 0; + for (const auto &diff : rightDiffs) { + if (diff.command == Utils::Diff::Insert) { + rightCursor.setPosition(contextBeforeOffset + rightPos); + rightCursor.setPosition(contextBeforeOffset + rightPos + diff.text.length(), QTextCursor::KeepAnchor); + rightCursor.setCharFormat(addedFormat); + } + if (diff.command != Utils::Diff::Delete) { + rightPos += diff.text.length(); + } + } +} + +void RefactorWidget::dimContextLines(const QString &contextBefore, const QString &contextAfter) +{ + QTextCharFormat dimFormat; + dimFormat.setForeground(Utils::creatorColor(Utils::Theme::TextColorDisabled)); + + auto dimLines = [&](QTextDocument *doc, int lineCount, bool fromStart) { + QTextCursor cursor(doc); + if (!fromStart) { + cursor.movePosition(QTextCursor::End); + } + + for (int i = 0; i < lineCount; ++i) { + cursor.movePosition(QTextCursor::StartOfBlock); + cursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor); + cursor.setCharFormat(dimFormat); + + if (fromStart) { + if (!cursor.block().isValid() || !cursor.movePosition(QTextCursor::NextBlock)) { + break; + } + } else { + if (!cursor.movePosition(QTextCursor::PreviousBlock)) { + break; + } + } + } + }; + + if (!contextBefore.isEmpty()) { + int lines = contextBefore.count('\n') + (contextBefore.endsWith('\n') ? 0 : 1); + dimLines(m_leftDocument->document(), lines, true); + dimLines(m_rightDocument->document(), lines, true); + } + + if (!contextAfter.isEmpty()) { + int lines = contextAfter.count('\n') + (contextAfter.endsWith('\n') ? 0 : 1); + dimLines(m_leftDocument->document(), lines, false); + dimLines(m_rightDocument->document(), lines, false); + } +} + +QString RefactorWidget::getRefactoredText() const +{ + return m_applyText; +} + +void RefactorWidget::setRange(const Utils::Text::Range &range) +{ + m_range = range; +} + +void RefactorWidget::setEditorWidth(int width) +{ + m_editorWidth = width; + updateSizeToContent(); +} + +void RefactorWidget::setApplyCallback(std::function callback) +{ + m_applyCallback = callback; +} + +void RefactorWidget::setDeclineCallback(std::function callback) +{ + m_declineCallback = callback; +} + +void RefactorWidget::applyRefactoring() +{ + if (m_isClosing) return; + m_isClosing = true; + + if (m_applyCallback) { + m_applyCallback(m_applyText); + } + emit applied(); + close(); +} + +void RefactorWidget::declineRefactoring() +{ + if (m_isClosing) return; + m_isClosing = true; + + if (m_declineCallback) { + m_declineCallback(); + } + emit declined(); + close(); +} + +void RefactorWidget::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + + const QColor bgColor = Utils::creatorColor(Utils::Theme::BackgroundColorNormal); + const QColor borderColor = Utils::creatorColor(Utils::Theme::SplitterColor); + + painter.fillRect(rect(), bgColor); + painter.setPen(QPen(borderColor, 2)); + painter.drawRoundedRect(rect().adjusted(2, 2, -2, -2), 6, 6); +} + +bool RefactorWidget::event(QEvent *event) +{ + if (event->type() == QEvent::ShortcutOverride) { + auto *keyEvent = static_cast(event); + + if (((keyEvent->key() == Qt::Key_Enter || keyEvent->key() == Qt::Key_Return) && + keyEvent->modifiers() == Qt::ControlModifier) || + keyEvent->key() == Qt::Key_Escape) { + event->accept(); + return true; + } + } + + if (event->type() == QEvent::KeyPress) { + auto *keyEvent = static_cast(event); + + if ((keyEvent->key() == Qt::Key_Enter || keyEvent->key() == Qt::Key_Return) && + keyEvent->modifiers() == Qt::ControlModifier) { + applyRefactoring(); + return true; + } + + if (keyEvent->key() == Qt::Key_Escape) { + declineRefactoring(); + return true; + } + } + + return QWidget::event(event); +} + +void RefactorWidget::syncLeftScroll(int value) +{ + if (m_syncingScroll) return; + m_syncingScroll = true; + m_rightEditor->verticalScrollBar()->setValue(value); + m_syncingScroll = false; +} + +void RefactorWidget::syncRightScroll(int value) +{ + if (m_syncingScroll) return; + m_syncingScroll = true; + m_leftEditor->verticalScrollBar()->setValue(value); + m_syncingScroll = false; +} + +void RefactorWidget::syncLeftHorizontalScroll(int value) +{ + if (m_syncingScroll) return; + m_syncingScroll = true; + m_rightEditor->horizontalScrollBar()->setValue(value); + m_syncingScroll = false; +} + +void RefactorWidget::syncRightHorizontalScroll(int value) +{ + if (m_syncingScroll) return; + m_syncingScroll = true; + m_leftEditor->horizontalScrollBar()->setValue(value); + m_syncingScroll = false; +} + +void RefactorWidget::onRightEditorTextChanged() +{ + QString fullText = m_rightDocument->plainText(); + int startPos = m_contextBefore.isEmpty() ? 0 : m_contextBefore.length() + 1; + int endPos = m_contextAfter.isEmpty() ? fullText.length() : fullText.length() - m_contextAfter.length() - 1; + m_applyText = fullText.mid(startPos, endPos - startPos); +} + +void RefactorWidget::closeEvent(QCloseEvent *event) +{ + if (!m_isClosing) { + declineRefactoring(); + } + event->accept(); +} + +void RefactorWidget::calculateStats() +{ + DiffStatistics stats; + stats.calculate(m_cachedDiffList); + m_linesAdded = stats.linesAdded(); + m_linesRemoved = stats.linesRemoved(); +} + +void RefactorWidget::updateStatsLabel() +{ + DiffStatistics stats; + stats.calculate(m_cachedDiffList); + m_statsLabel->setText("📊 " + stats.formatSummary()); +} + +void RefactorWidget::applySyntaxHighlighting() +{ + if (!m_sourceEditor) { + return; + } + + auto *sourceDoc = m_sourceEditor->textDocument(); + if (!sourceDoc || !sourceDoc->syntaxHighlighter()) { + return; + } + + m_leftDocument->setMimeType(sourceDoc->mimeType()); + m_rightDocument->setMimeType(sourceDoc->mimeType()); +} + +void RefactorWidget::addLineMarkers() +{ + if (m_cachedDiffList.isEmpty()) { + return; + } + + QList leftDiffs; + QList rightDiffs; + Utils::Differ::splitDiffList(m_cachedDiffList, &leftDiffs, &rightDiffs); + + int contextBeforeOffset = m_contextBefore.isEmpty() ? 0 : (m_contextBefore.length() + 1); + + QColor removedMarker = Utils::creatorColor(Utils::Theme::TextColorError); + QColor addedMarker = Utils::creatorColor(Utils::Theme::IconsRunColor); + + QTextCursor leftCursor(m_leftDocument->document()); + int leftPos = 0; + + for (const auto &diff : leftDiffs) { + if (diff.command == Utils::Diff::Delete) { + leftCursor.setPosition(contextBeforeOffset + leftPos); + leftCursor.movePosition(QTextCursor::StartOfBlock); + leftCursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor); + + QTextBlockFormat blockFormat; + blockFormat.setBackground(QBrush(removedMarker.lighter(185))); + blockFormat.setLeftMargin(4); + blockFormat.setProperty(QTextFormat::FullWidthSelection, true); + + leftCursor.setBlockFormat(blockFormat); + } + if (diff.command != Utils::Diff::Insert) { + leftPos += diff.text.length(); + } + } + + QTextCursor rightCursor(m_rightDocument->document()); + int rightPos = 0; + + for (const auto &diff : rightDiffs) { + if (diff.command == Utils::Diff::Insert) { + rightCursor.setPosition(contextBeforeOffset + rightPos); + rightCursor.movePosition(QTextCursor::StartOfBlock); + rightCursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor); + + QTextBlockFormat blockFormat; + blockFormat.setBackground(QBrush(addedMarker.lighter(195))); + blockFormat.setLeftMargin(4); + blockFormat.setProperty(QTextFormat::FullWidthSelection, true); + + rightCursor.setBlockFormat(blockFormat); + } + if (diff.command != Utils::Diff::Delete) { + rightPos += diff.text.length(); + } + } +} + +void RefactorWidget::updateSizeToContent() +{ + QFontMetrics fm(m_rightEditor->font()); + int charWidth = fm.horizontalAdvance('m'); + int lineHeight = fm.height(); + int lineCount = m_rightDocument->document()->blockCount(); + + bool horizontal = m_splitter->orientation() == Qt::Horizontal; + + const int minWidth = Settings::quickRefactorSettings().widgetMinWidth(); + const int maxWidth = qMin(Settings::quickRefactorSettings().widgetMaxWidth(), m_editorWidth - 40); + const int minHeight = Settings::quickRefactorSettings().widgetMinHeight(); + const int maxHeight = Settings::quickRefactorSettings().widgetMaxHeight(); + + if (horizontal) { + int totalWidth = qBound(minWidth, charWidth * 60 * 2 + 90, maxWidth); + setFixedWidth(totalWidth); + + int editorHeight = qBound(minHeight, lineCount * lineHeight, maxHeight); + m_leftEditor->setMinimumHeight(editorHeight); + m_leftEditor->setMaximumHeight(editorHeight); + m_rightEditor->setMinimumHeight(editorHeight); + m_rightEditor->setMaximumHeight(editorHeight); + } else { + int editorWidth = qBound(minWidth, charWidth * 85 + 80, maxWidth); + setFixedWidth(editorWidth); + + int editorHeight = qBound(minHeight, lineCount * lineHeight, maxHeight); + m_leftEditor->setMinimumHeight(editorHeight); + m_leftEditor->setMaximumHeight(editorHeight); + m_rightEditor->setMinimumHeight(editorHeight); + m_rightEditor->setMaximumHeight(editorHeight); + } + + updateGeometry(); + adjustSize(); +} + +void RefactorWidget::applyEditorSettings() +{ + if (!m_sourceEditor || !m_leftEditor || !m_rightEditor) { + return; + } + + QFont editorFont = m_sourceEditor->font(); + m_leftEditor->setFont(editorFont); + m_rightEditor->setFont(editorFont); + + QString labelStyle = QString("color: %1; padding: 2px 4px;") + .arg(Utils::creatorColor(Utils::Theme::TextColorDisabled).name()); + + for (auto *label : findChildren()) { + if (label != m_statsLabel) { + QFont labelFont = label->font(); + labelFont.setPointSize(qMax(8, editorFont.pointSize() - 2)); + label->setFont(labelFont); + label->setStyleSheet(labelStyle); + } + } + + QFont statsFont = m_statsLabel->font(); + statsFont.setBold(true); + statsFont.setPointSize(qMax(9, editorFont.pointSize() - 1)); + m_statsLabel->setFont(statsFont); + + m_statsLabel->setStyleSheet(QString( + "color: %1; padding: 4px 6px; background-color: %2; border-radius: 3px;") + .arg(Utils::creatorColor(Utils::Theme::TextColorNormal).name()) + .arg(Utils::creatorColor(Utils::Theme::BackgroundColorHover).name())); + + updateButtonStyles(); +} + +void RefactorWidget::updateButtonStyles() +{ + if (!m_applyButton || !m_declineButton) { + return; + } + + int baseFontSize = m_sourceEditor ? qMax(9, m_sourceEditor->font().pointSize() - 2) : 10; + + auto createStyle = [&](const QColor &color, bool bold) { + return QString( + "QPushButton {" + " background-color: %1; color: %2; border: 1px solid %3;" + " border-radius: 3px; padding: 2px 8px; font-size: %4pt;%5" + "}" + "QPushButton:hover { background-color: %6; border: 1px solid %2; }" + "QPushButton:pressed { background-color: %7; }") + .arg(Utils::creatorColor(Utils::Theme::BackgroundColorNormal).name()) + .arg(color.name()) + .arg(Utils::creatorColor(Utils::Theme::SplitterColor).name()) + .arg(baseFontSize) + .arg(bold ? QLatin1StringView(" font-weight: bold;") : QLatin1StringView("")) + .arg(Utils::creatorColor(Utils::Theme::BackgroundColorHover).name()) + .arg(Utils::creatorColor(Utils::Theme::BackgroundColorSelected).name()); + }; + + m_applyButton->setStyleSheet(createStyle(Utils::creatorColor(Utils::Theme::TextColorNormal), true)); + m_declineButton->setStyleSheet(createStyle(Utils::creatorColor(Utils::Theme::TextColorError), false)); +} + +} // namespace QodeAssist diff --git a/widgets/RefactorWidget.hpp b/widgets/RefactorWidget.hpp new file mode 100644 index 0000000..b990fbf --- /dev/null +++ b/widgets/RefactorWidget.hpp @@ -0,0 +1,141 @@ +/* + * 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 +#include +#include +#include + +#include +#include +#include + +namespace QodeAssist { + +class CustomSplitterHandle : public QSplitterHandle +{ + Q_OBJECT +public: + explicit CustomSplitterHandle(Qt::Orientation orientation, QSplitter *parent); +protected: + void paintEvent(QPaintEvent *event) override; + void enterEvent(QEnterEvent *event) override; + void leaveEvent(QEvent *event) override; +private: + bool m_hovered = false; +}; + +class CustomSplitter : public QSplitter +{ + Q_OBJECT +public: + explicit CustomSplitter(Qt::Orientation orientation, QWidget *parent = nullptr); +protected: + QSplitterHandle *createHandle() override; +}; + +class RefactorWidget : public QWidget +{ + Q_OBJECT + +public: + explicit RefactorWidget(TextEditor::TextEditorWidget *sourceEditor, QWidget *parent = nullptr); + ~RefactorWidget() override; + + void setDiffContent(const QString &originalText, const QString &refactoredText); + void setDiffContent(const QString &originalText, const QString &refactoredText, + const QString &contextBefore, const QString &contextAfter); + + void setApplyText(const QString &text) { m_applyText = text; } + void setRange(const Utils::Text::Range &range); + void setEditorWidth(int width); + + QString getRefactoredText() const; + + void setApplyCallback(std::function callback); + void setDeclineCallback(std::function callback); + +signals: + void applied(); + void declined(); + +protected: + void paintEvent(QPaintEvent *event) override; + bool event(QEvent *event) override; + void closeEvent(QCloseEvent *event) override; + +private slots: + void syncLeftScroll(int value); + void syncRightScroll(int value); + void syncLeftHorizontalScroll(int value); + void syncRightHorizontalScroll(int value); + void onRightEditorTextChanged(); + +private: + TextEditor::TextEditorWidget *m_sourceEditor; + TextEditor::TextEditorWidget *m_leftEditor; + TextEditor::TextEditorWidget *m_rightEditor; + QSharedPointer m_leftDocument; + QSharedPointer m_rightDocument; + QWidget *m_leftContainer; + QSplitter *m_splitter; + QLabel *m_statsLabel; + QPushButton *m_applyButton; + QPushButton *m_declineButton; + + QString m_originalText; + QString m_refactoredText; + QString m_applyText; + QString m_contextBefore; + QString m_contextAfter; + Utils::Text::Range m_range; + int m_editorWidth; + bool m_syncingScroll; + bool m_isClosing; + int m_linesAdded; + int m_linesRemoved; + + QList m_cachedDiffList; + + std::function m_applyCallback; + std::function m_declineCallback; + + void setupUi(); + void applyRefactoring(); + void declineRefactoring(); + void updateSizeToContent(); + void highlightDifferences(); + void dimContextLines(const QString &contextBefore, const QString &contextAfter); + void calculateStats(); + void updateStatsLabel(); + void applySyntaxHighlighting(); + void addLineMarkers(); + void applyEditorSettings(); + void updateButtonStyles(); +}; + +} // namespace QodeAssist diff --git a/widgets/RefactorWidgetHandler.cpp b/widgets/RefactorWidgetHandler.cpp new file mode 100644 index 0000000..634aea7 --- /dev/null +++ b/widgets/RefactorWidgetHandler.cpp @@ -0,0 +1,163 @@ +/* + * 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 "RefactorWidgetHandler.hpp" +#include "RefactorWidget.hpp" +#include "ContextExtractor.hpp" + +#include +#include + +namespace QodeAssist { + +RefactorWidgetHandler::RefactorWidgetHandler(QObject *parent) + : QObject(parent) +{ +} + +RefactorWidgetHandler::~RefactorWidgetHandler() +{ + hideRefactorWidget(); +} + +void RefactorWidgetHandler::showRefactorWidget( + TextEditor::TextEditorWidget *editor, + const QString &originalText, + const QString &refactoredText, + const Utils::Text::Range &range) +{ + QString contextBefore = ContextExtractor::extractBefore(editor, range, 3); + QString contextAfter = ContextExtractor::extractAfter(editor, range, 3); + showRefactorWidget(editor, originalText, refactoredText, range, contextBefore, contextAfter); +} + +void RefactorWidgetHandler::showRefactorWidget( + TextEditor::TextEditorWidget *editor, + const QString &originalText, + const QString &refactoredText, + const Utils::Text::Range &range, + const QString &contextBefore, + const QString &contextAfter) +{ + if (!editor) { + return; + } + + hideRefactorWidget(); + + m_editor = editor; + m_refactorWidget = new RefactorWidget(editor); + + m_refactorWidget->setDiffContent(originalText, refactoredText, contextBefore, contextAfter); + m_refactorWidget->setApplyText(refactoredText); + m_refactorWidget->setRange(range); + m_refactorWidget->setEditorWidth(getEditorWidth()); + + if (m_applyCallback) { + m_refactorWidget->setApplyCallback(m_applyCallback); + } + + if (m_declineCallback) { + m_refactorWidget->setDeclineCallback(m_declineCallback); + } + + updateWidgetPosition(); + m_refactorWidget->show(); + m_refactorWidget->raise(); +} + +void RefactorWidgetHandler::hideRefactorWidget() +{ + if (!m_refactorWidget.isNull()) { + m_refactorWidget->close(); + m_refactorWidget = nullptr; + } + m_editor = nullptr; +} + +void RefactorWidgetHandler::setApplyCallback(std::function callback) +{ + m_applyCallback = callback; +} + +void RefactorWidgetHandler::setDeclineCallback(std::function callback) +{ + m_declineCallback = callback; +} + +void RefactorWidgetHandler::setTextToApply(const QString &text) +{ + if (!m_refactorWidget.isNull()) { + m_refactorWidget->setApplyText(text); + } +} + +void RefactorWidgetHandler::updateWidgetPosition() +{ + if (m_refactorWidget.isNull() || m_editor.isNull()) { + return; + } + + QPoint position = calculateWidgetPosition(); + m_refactorWidget->move(position); +} + +QPoint RefactorWidgetHandler::calculateWidgetPosition() +{ + if (m_editor.isNull()) { + return QPoint(0, 0); + } + + QTextCursor cursor = m_editor->textCursor(); + QRect cursorRect = m_editor->cursorRect(cursor); + QPoint globalPos = m_editor->mapToGlobal(cursorRect.bottomLeft()); + globalPos.setY(globalPos.y() + 10); + + if (m_refactorWidget) { + QRect widgetRect(globalPos, m_refactorWidget->size()); + QRect screenRect = m_editor->screen()->availableGeometry(); + + if (widgetRect.right() > screenRect.right()) { + globalPos.setX(screenRect.right() - m_refactorWidget->width() - 10); + } + + if (widgetRect.bottom() > screenRect.bottom()) { + globalPos.setY(m_editor->mapToGlobal(cursorRect.topLeft()).y() + - m_refactorWidget->height() - 10); + } + + if (globalPos.x() < screenRect.left()) { + globalPos.setX(screenRect.left() + 10); + } + + if (globalPos.y() < screenRect.top()) { + globalPos.setY(screenRect.top() + 10); + } + } + + return globalPos; +} + +int RefactorWidgetHandler::getEditorWidth() const +{ + return m_editor.isNull() ? 800 : m_editor->viewport()->width(); +} + +} // namespace QodeAssist + diff --git a/widgets/RefactorWidgetHandler.hpp b/widgets/RefactorWidgetHandler.hpp new file mode 100644 index 0000000..c268715 --- /dev/null +++ b/widgets/RefactorWidgetHandler.hpp @@ -0,0 +1,75 @@ +/* + * 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 + +namespace QodeAssist { + +class RefactorWidget; + +class RefactorWidgetHandler : public QObject +{ + Q_OBJECT + +public: + explicit RefactorWidgetHandler(QObject *parent = nullptr); + ~RefactorWidgetHandler() override; + + void showRefactorWidget( + TextEditor::TextEditorWidget *editor, + const QString &originalText, + const QString &refactoredText, + const Utils::Text::Range &range); + + void showRefactorWidget( + TextEditor::TextEditorWidget *editor, + const QString &originalText, + const QString &refactoredText, + const Utils::Text::Range &range, + const QString &contextBefore, + const QString &contextAfter); + + void hideRefactorWidget(); + + bool isWidgetVisible() const { return !m_refactorWidget.isNull(); } + + void setApplyCallback(std::function callback); + void setDeclineCallback(std::function callback); + void setTextToApply(const QString &text); + +private: + QPointer m_editor; + QPointer m_refactorWidget; + + std::function m_applyCallback; + std::function m_declineCallback; + + void updateWidgetPosition(); + QPoint calculateWidgetPosition(); + int getEditorWidth() const; +}; + +} // namespace QodeAssist +